diff --git a/dikte/app.py b/dikte/app.py index b53969a..51385d6 100644 --- a/dikte/app.py +++ b/dikte/app.py @@ -637,12 +637,24 @@ class Dikte: # inactive: at forty the title bar visibly blinks, at ten it does not. # Two messages to AppKit per tick, for at most a second and a half. watch.setInterval(10) + # activateWithOptions: answers whether macOS accepted the request, not + # whether the other application is already back in front. Keep the + # watch alive until that asynchronous handoff is observable; on Intel + # Macs it can take hundreds of milliseconds after the call returned. + restore_requested = False def look(): - if mac_window.is_frontmost(): - mac_window.activate(was_in_front) + nonlocal restore_requested + if time.monotonic() > deadline: self._stop_watching_the_front() - elif time.monotonic() > deadline: + return + if mac_window.is_frontmost(): + if not restore_requested: + restore_requested = mac_window.activate(was_in_front) + elif restore_requested: + # The request has landed. Stop only now, rather than as soon + # as AppKit accepted it, so a delayed or failed handoff stays + # under observation until the deadline guard above. self._stop_watching_the_front() watch.timeout.connect(look) diff --git a/tests/test_ui.py b/tests/test_ui.py index 5818cbf..7e68203 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -604,7 +604,7 @@ class GivingTheFrontBack(DikteTest): from dikte import mac_window self.dikte = dikte_module self.activated = [] - self.patch_attr(mac_window, "activate", self.activated.append) + self.patch_attr(mac_window, "activate", self.activate) self.ticks = [] outer = self @@ -645,6 +645,10 @@ class GivingTheFrontBack(DikteTest): self.bare = BareDikte + def activate(self, pid): + self.activated.append(pid) + return True + def watching(self, was_in_front, dikte_in_front, on=None): from dikte import mac_window self.patch_attr(mac_window, "is_frontmost", lambda: dikte_in_front) @@ -656,7 +660,36 @@ class GivingTheFrontBack(DikteTest): watch = self.watching(4242, dikte_in_front=True) watch.tick() self.assertEqual(self.activated, [4242]) - self.assertFalse(watch.running) # one is enough; it stops watching + self.assertTrue(watch.running) # accepted is not the same as landed + self.patch_attr(self.mac_window_module(), "is_frontmost", lambda: False) + watch.tick() + self.assertFalse(watch.running) + + def test_an_accepted_restore_is_not_sent_again_while_it_is_landing(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242]) + self.assertTrue(watch.running) + + def test_an_accepted_restore_that_never_lands_still_times_out(self): + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + with mock.patch.object(self.dikte.time, "monotonic", + return_value=self.dikte.time.monotonic() + 60): + watch.tick() + self.assertFalse(watch.running) + self.assertEqual(self.activated, [4242]) + + def test_a_restore_the_system_refused_is_retried(self): + from dikte import mac_window + self.patch_attr(mac_window, "activate", + lambda pid: self.activated.append(pid) or False) + watch = self.watching(4242, dikte_in_front=True) + watch.tick() + watch.tick() + self.assertEqual(self.activated, [4242, 4242]) + self.assertTrue(watch.running) def test_a_front_that_was_never_taken_is_left_where_it_is(self): """The microphone does not always take it, and pulling an application