diff --git a/CLAUDE.md b/CLAUDE.md index 3b946b1..757f3d8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -148,11 +148,18 @@ deliberately, one at a time, and say why in the code. for as long as it is true. Chat is the same shape with a different peelable thing — its first Esc unpicks a selected message. It used to be a 2–2 split, which put opposite outcomes behind the same keystroke on the same affordance. -- **^W** unwinds every frame back to the main menu, **^Q** quits from anywhere. Both are - answered in `TuiSession._dispatch` and neither is ever advertised — a global verb has no - screen to belong to, and the F-key lane has only three free slots per screen. They are - stated once, on the About page. `test_navigation` walks every string literal in the - package to keep them out of the UI. ^W arms **every frame on the stack**, not just the +- **^W** unwinds every frame back to the main menu, **^Q** asks to quit from anywhere: it + floats the same confirm the menu's Quit row opens, detached over whatever is up so + nothing under it stops, and a second ^Q while it asks leaves at once + (`TuiSession.request_quit`; ^Q sits one key from ^W). Both are answered in + `TuiSession._dispatch`, and neither is advertised — a global verb has no screen to + belong to, and the F-key lane has only three free slots per screen — except `^Q quit` + on the main menu's own hint, where Esc is inert and the reader looks for the way out. + `test_navigation` walks every string literal in the package to keep them out of the rest + of the UI. +- **Esc at the main menu does nothing** but peel a typed filter: the menu is the bottom of + the stack, so Esc-mashing up to it settles there. Leaving is the Quit row or ^Q, both + behind the confirm. ^W arms **every frame on the stack**, not just the top (`request_pop_all`), stopping at anything modal: a screen opened from a key handler — the packet viewer, the chat's delivery paths, a trace path flow — runs in a task no navigation frame awaits, so an unwind sent only to the top died there while the hub @@ -265,7 +272,8 @@ as a grouped list does. Filling a page in is editing its `.md`; no Python follow - ≤72 cells. Sentence shape: navigation keys, then action keys, **Esc last**. - Esc verb by surface: `Esc back` leaves a screen · `Esc close` dismisses a read-only floating view · `Esc cancel` abandons a prompt/dialog · `Esc keep` leaves a value - picker unchanged · `Esc quit` only at the main menu · `Esc bye` only on the device + picker unchanged · no Esc atom at the main menu, which ends on `^Q quit` instead · + `Esc bye` only on the device splash, which is the door rather than a screen — nothing has been started there to quit out of. While a find-as-you-type filter is standing the verb becomes `Esc clear`, because that is what the press does then. diff --git a/meshterm/ui/menu.py b/meshterm/ui/menu.py index 0b0bd64..940f78f 100644 --- a/meshterm/ui/menu.py +++ b/meshterm/ui/menu.py @@ -39,7 +39,6 @@ from .menus import ( from .surface import TuiUi from .theme import make_console from .tui import ( - CANCEL, Choice, PopToMenu, ReconnectDialog, @@ -398,6 +397,8 @@ async def run_menu(ctx: AppContext) -> None: header_cache: dict = {} session = TuiSession(header=lambda cols: _header(ctx, header_cache, cols)) ctx.ui = TuiUi(session) + # The one quit confirm, asked by the menu's Quit row and by the quit chord from anywhere. + session.set_quit_confirm(lambda: _confirm_quit(ctx, session)) async def main() -> None: try: @@ -482,11 +483,81 @@ async def _unpair_on_exit(ctx: AppContext) -> None: #: can show, the same as any tool's. _QUIT_ICON = "🚪" -#: The value the Quit row resolves with — Esc's ``CANCEL`` and this both open the quit -#: confirm (see :func:`_menu_round`). +#: The value the Quit row resolves with, which opens the quit confirm (see +#: :func:`_menu_round`). Esc at the menu does not: it is inert there (see :class:`_MainMenu`). _QUIT_VALUE = "__quit__" +async def _confirm_quit(ctx: AppContext, session: TuiSession) -> bool: + """Float the quit confirm over whatever is up; return whether to leave. + + One dialog for both doors — the menu's Quit row and the quit chord, which asks from + anywhere (see :meth:`~meshterm.ui.tui.session.TuiSession.request_quit`). Cancel (Esc) + sits left of Quit (Enter), and Quit starts highlighted so Enter commits it. + + It offers to drop the OS pairing on the way out, but only when there is a live Bluetooth + bond to drop — never on serial, an open (PIN-less) companion, or a platform we can't + unpair. That button sits between Cancel and Quit and is never the default, so it takes a + deliberate choice, not a stray Enter. The unpair itself is deferred to teardown (see + :func:`run_menu`), because the link has to be disconnected first; the remembered-device + record is intentionally kept, so the device just asks for its PIN again. + + Args: + ctx: The shared application context (``unpair_on_exit`` is set on *Unpair & quit*). + session: The running TUI session. + + Returns: + ``True`` if the reader chose Quit or Unpair & quit. + """ + buttons = [("Cancel", "cancel")] + if await _can_unpair(ctx): + buttons.append(("Unpair & quit", "unpair")) + buttons.append(("Quit", "quit")) + choice = await session.button_dialog( + "Are you sure you want to quit?", + buttons, + title="Quit MeshTerm", + default=len(buttons) - 1, # highlight Quit + # The hint stays the dialog default: Enter commits *the highlighted button*, + # which is Quit only until ←→ moves it — and with a third button in the row, + # naming the key that moves it is the point. + prompt_style="warn", + button_style="selected", + button_idle_style="muted", + border_style="warn", + ) + if choice == "unpair": + ctx.unpair_on_exit = True + return choice in ("unpair", "quit") + + +class _MainMenu(SelectScreen): + """The main menu's list, on which Esc peels a typed filter and otherwise does nothing. + + Pressing Esc over and over is the natural climb back to the menu, and when Esc at the + top opened the quit confirm, the presses that landed after the climb opened it too — the + reader asked for the menu and got a question about leaving (issue #22). The menu is the + bottom of the stack, so there is nothing for Esc to go back to; leaving is a choice made + on the Quit row, or with the quit chord, both of which ask first. + + The footer therefore ends on the quit chord rather than on Esc, until a filter is + standing and ``Esc clear`` joins it last, because that is what the press does then — + the same atom every filtering list shows. + """ + + def handle(self, action: str, data: str = "") -> None: + """Answer every key as a select list does, except a bare Esc, which is inert.""" + if action == "escape" and not self._filter: + return + super().handle(action, data) + + @property + def footer_hint(self) -> str: + """The list's own hint, gaining ``Esc clear`` only while a filter is standing.""" + base = super().footer_hint + return f"{base} · Esc clear" if self._filter else base + + def _menu_lane(tools: Sequence[Any]) -> int: """The main menu's icon column in cells: every tool's mark *and* the Quit row's. @@ -614,18 +685,20 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: # Drive the menu list ourselves (rather than via session.select) so it stays on the # stack while the quit dialog floats over it: the confirm is drawn as a centered box # on top of the still-visible menu, not as a screen that replaces it. - menu = SelectScreen( + menu = _MainMenu( "What would you like to do?", items, - footer_hint="↑↓ move · type to filter · Enter select · Esc quit", + # The one place the quit chord is named: the menu is where a reader looks for the + # way out, and with Esc inert here the footer would otherwise name none. Everywhere + # else it stays unadvertised, like ^W (see session._CTRL_LETTER_CHORDS). + footer_hint="↑↓ move · type to filter · Enter select · ^Q quit", ) session.set_root(menu) loop = asyncio.get_running_loop() while True: try: await _menu_round(ctx, session, menu, tools, loop) - except _Quit as chosen: - ctx.unpair_on_exit = chosen.unpair + except _Quit: return except PopToMenu: # ^W from somewhere deep. Every frame between there and here has already @@ -635,18 +708,14 @@ async def _menu_loop(ctx: AppContext, session: TuiSession) -> None: class _Quit(Exception): - """The user confirmed the quit dialog; ``unpair`` says whether to drop the OS bond too. + """The user confirmed the quit dialog from the menu's Quit row. A menu round runs a whole tool inside itself, so "the user chose Quit" cannot be spelled as a ``return`` — the loop would just show the menu again. It is raised instead, and - caught in the one frame that owns the decision to leave. + caught in the one frame that owns the decision to leave. Whether to drop the OS bond too + is already recorded on the context by the confirm (see :func:`_confirm_quit`). """ - def __init__(self, *, unpair: bool) -> None: - """Record whether the OS pairing should be dropped on the way out.""" - super().__init__("quit") - self.unpair = unpair - async def _menu_round( ctx: AppContext, @@ -677,41 +746,11 @@ async def _menu_round( ran_over_menu = False try: selection = await menu.future - if selection is CANCEL: # Esc at the top level - selection = None - # Both picking "quit" and pressing Esc ask to leave; confirm on a dialog floating - # over the (still-pushed) menu so a stray key doesn't drop the user out. Cancel - # (Esc) sits left of Quit (Enter); Quit starts highlighted so Enter commits it. - if selection in (None, _QUIT_VALUE): - # Offer to drop the OS pairing on the way out, but only when there is a live - # Bluetooth bond to drop — never on serial, an open (PIN-less) companion, or a - # platform we can't unpair. The extra button sits between Cancel and Quit and is - # never the default, so it takes a deliberate choice, not a stray Enter. - unpairable = await _can_unpair(ctx) - buttons = [("Cancel", "cancel")] - if unpairable: - buttons.append(("Unpair & quit", "unpair")) - buttons.append(("Quit", "quit")) - choice = await session.button_dialog( - "Are you sure you want to quit?", - buttons, - title="Quit MeshTerm", - default=len(buttons) - 1, # highlight Quit - # The hint stays the dialog default: Enter commits *the highlighted - # button*, which is Quit only until ←→ moves it — and with a third - # button in the row, naming the key that moves it is the point. - prompt_style="warn", - button_style="selected", - button_idle_style="muted", - border_style="warn", - ) - # "Unpair & quit" forgets the OS bond as we leave; the disconnect must happen - # first, so the actual unpair is deferred to teardown (see run_menu). The - # remembered-device record is intentionally kept — the device just asks for its - # PIN again. Cancel (button or Esc → None) ends the round and redraws the menu, - # whose cursor never moved off the row they came from. - if choice in ("unpair", "quit"): - raise _Quit(unpair=choice == "unpair") + # The Quit row asks first, on a dialog floating over the (still-pushed) menu; Cancel + # ends the round and redraws the menu, whose cursor never moved off the row. + if selection == _QUIT_VALUE: + if await session.confirm_quit(): + raise _Quit() return # A popup tool runs while the menu is still pushed, so its prompts and # result float over it as modal dialogs instead of replacing the screen. diff --git a/meshterm/ui/tui/frame.py b/meshterm/ui/tui/frame.py index 2c51368..048a9d9 100644 --- a/meshterm/ui/tui/frame.py +++ b/meshterm/ui/tui/frame.py @@ -212,7 +212,7 @@ _BASE_BOX_CACHE: tuple[tuple, list[str]] | None = None #: The screen's own way out, lifted off the end of its footer hint. The grammar is #: mandated — "navigation keys, then action keys, **Esc last**" — so the last atom is the #: Esc clause wherever a screen has one, and its verb is the true one for that surface -#: (``back`` on a screen, ``quit`` at the main menu, ``keep`` in a value picker). +#: (``back`` on a screen, ``keep`` in a value picker; the main menu names none). _ESC_ATOM = re.compile(r"(?:^|·\s*)(Esc\s+\S+)\s*$") #: Rule cells the title keeps on each side before the bar gives the Esc hint back. Below @@ -274,10 +274,11 @@ def _title_bar(screen: Screen, cols: int, more_above: bool, more_below: bool) -> its five slots do — so nothing on the screen said that Esc leaves, which is the one key every screen answers to and the reason no screen spends a row on a *Back* item. The verb is the screen's own (:func:`_esc_hint` lifts the atom off its footer hint, so a - value picker says ``keep`` and the main menu says ``quit``); a screen whose hint names - no Esc gets nothing. Compact by construction: where a long title would be crowded, the - atom drops to a bare ``Esc`` and then out altogether — the title is what the reader - came for, and the arrows are two cells nothing else can spend. + value picker says ``keep``); a screen whose hint names no Esc gets nothing — the + main menu, where Esc is inert until a filter is typed. Compact by construction: where + a long title would be crowded, the atom drops to a bare ``Esc`` and then out + altogether — the title is what the reader came for, and the arrows are two cells + nothing else can spend. """ border = "accent" head_span = 3 # the arrow pair, plus the space parting it from the rule diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index d56b3b1..b90dad5 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -68,10 +68,12 @@ from .spinner import spinner_interval #: walk, because what it names on both is our own node. #: #: Two of these are app-wide rather than a screen's: ``to_menu`` (^W) unwinds the whole -#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) leaves -#: the app from anywhere. Neither is advertised in a footer hint or an F-key chip — the lane -#: has three free slots per screen and a global verb would claim one on every screen forever, -#: so both are stated once on the About page's key list instead (JP, 2026-08-30). ^W was +#: navigation stack back to the main menu, and ``quit`` (^Q, alongside the older ^C) asks +#: to leave the app from anywhere — a second press while it asks is the leaving (see +#: :meth:`TuiSession.request_quit`). Neither is advertised in a footer hint or an F-key +#: chip — the lane has three free slots per screen and a global verb would claim one on +#: every screen forever (JP, 2026-08-30) — save ``^Q quit`` on the main menu's own hint, +#: where Esc is inert and the reader looks for the way out (issue #22). ^W was #: picked for the close-this-whole-thing reflex; both were verified deliverable on the #: Canadian Multilingual layout (^W ``U+0017``, ^Q ``U+0011``) and neither is eaten by the #: terminal — prompt_toolkit's raw mode clears ``IXON``/``IXOFF``, so ^Q is not XON here. @@ -515,6 +517,11 @@ class TuiSession: # the top rather than pointlessly rebuilding it — and what a popup floats over when # nothing else is pushed (see :meth:`_floated`). ``None`` until the menu declares it. self._root: Screen | None = None + # The quit confirm ^Q raises from anywhere (see :meth:`request_quit`), declared by + # the app with :meth:`set_quit_confirm`; and whether it is up right now, which is + # what turns the next ^Q into the leaving itself. + self._quit_confirm: Callable[[], Awaitable[bool]] | None = None + self._quit_asking = False # --- stack --------------------------------------------------------------- @@ -596,6 +603,74 @@ class TuiSession: """The declared navigation root (the main menu), or ``None`` before it is declared.""" return self._root + def set_quit_confirm(self, ask: Callable[[], Awaitable[bool]] | None) -> None: + """Declare the question the quit chord asks before it leaves. + + The app declares it once, because only the app knows what the question offers — + the main menu's confirm adds *Unpair & quit* over a Bluetooth bond, which needs the + application context the session never holds. ``ask`` floats its dialog and returns + whether to leave; it owns any side effect of the answer (recording an unpair). + + Args: + ask: The confirm, or ``None`` to have the quit chord leave without asking. + """ + self._quit_confirm = ask + + async def confirm_quit(self) -> bool: + """Ask the declared quit confirm, as the main menu's Quit row does. + + The same question ^Q asks, and the same dialog: while it is up the chord's next + press leaves outright (see :meth:`request_quit`), whichever door opened it. + + Returns: + Whether the reader chose to leave — ``True`` with no confirm declared. + """ + self._quit_asking = True + return await self._asking_quit() + + async def _asking_quit(self) -> bool: + """Run the declared confirm with :attr:`_quit_asking` already raised, then lower it.""" + try: + return self._quit_confirm is None or bool(await self._quit_confirm()) + finally: + self._quit_asking = False + + def request_quit(self) -> None: + """Answer the quit chord (^Q, and ^C beside it): ask first, leave on the second press. + + ^Q sits one key from ^W, the chord that climbs back to the menu, so a slip on the way + to the menu used to close the app — and a running capture or a courier queue with + it. It now floats the quit confirm over whatever is up, from anywhere (issue #22, + option D). The confirm is a detached flow (:meth:`run_detached`): nothing under it + is interrupted, a capture keeps capturing behind the box, and Esc puts the reader + back exactly where they were. + + While the confirm is up, the chord leaves at once, so the fastest way out is still + two keys and still works when a screen is wedged or a device read hangs — the + second press never waits on the dialog to paint or take a key. The flag is raised + here, synchronously, so two presses arriving in one input batch are still a quit + rather than two dialogs. + + Leaving is clean either way: :meth:`run` cancels the coroutine driving the app and + waits for its unwind, so the history and chat runs close, the services stop, and the + exit watchdog is armed, the same as quitting from the menu. + """ + if self._quit_asking or self._quit_confirm is None: + self._exit_app() + return + self._quit_asking = True + + async def ask() -> None: + if await self._asking_quit(): + self._exit_app() + + self.run_detached(ask()) + + def _exit_app(self) -> None: + """Exit the running application, from under whatever flow is driving it.""" + if self._app is not None and self._app.is_running: + self._app.exit() + def request_pop_all(self) -> bool: """Arm the unwind to the navigation root (the ^W key). Returns whether it fired. @@ -1603,7 +1678,8 @@ class TuiSession: # touches the screen. Errors from ``main`` still propagate via ``driver``/``box``. await self._app.run_async(pre_run=pre_run, set_exception_handler=False) # The app can also exit from *under* the driver: the quit chords (^Q/^C) call - # ``Application.exit`` straight from the key handler, so ``run_async`` returns while + # ``Application.exit`` from their detached confirm, or straight from the key handler + # on the second press (see request_quit), so ``run_async`` returns while # ``main`` is still parked on whatever screen was up. Left alone, that coroutine is # simply abandoned mid-await and its ``finally`` never runs — which is where the # history and chat runs are closed, the background services stopped, and the exit @@ -2030,20 +2106,10 @@ class TuiSession: self.invalidate() return if action == "quit": - # Straight out (^Q/^C), with no confirmation — deliberately, and not an - # oversight to be tidied up later. The quit dialog exists to catch *one Esc too - # many* at the main menu, where a single reflexive keystroke on the way out of - # something would otherwise end the session (JP, 2026-08-30). Neither of these - # chords can be pressed by accident, so there is nothing for a confirm to catch - # — and both are the escape hatch: a screen that is wedged, or a device read - # that is hanging, is exactly when a confirm dialog (itself a screen, which has - # to paint and take a key) would be the thing in the way. Leaving is still - # clean: ``run`` cancels the coroutine driving the app and waits for its unwind, - # so the history and chat runs close, the services stop, and the exit watchdog - # is armed, the same as quitting from the menu. What this route does *not* offer - # is the dialog's "Unpair & quit" — that stays a deliberate, menu-only choice. - if self._app is not None: - self._app.exit() + # Asks first, from anywhere; a second press while it asks leaves (see + # request_quit). "Unpair & quit" rides the confirm the menu declared. + self.request_quit() + self.invalidate() return if action == "paste_clipboard": # Some terminals deliver Ctrl-V as the literal control key — no bracketed-paste diff --git a/tests/test_navigation.py b/tests/test_navigation.py index ab84b10..21c5648 100644 --- a/tests/test_navigation.py +++ b/tests/test_navigation.py @@ -866,6 +866,110 @@ async def test_quit_from_a_nested_screen_still_runs_the_apps_teardown() -> None: assert torn_down == ["services closed"] +def _asking_session(inp, asked: list[str], answer: str | None = None): + """A session whose quit chord floats a Cancel/Quit confirm, the way the menu declares it. + + ``answer`` resolves the dialog as soon as it is pushed (``None`` leaves it up for the + test to drive); ``asked`` records each time the question is put. + """ + session = _session(inp) + + async def ask() -> bool: + asked.append("asked") + dialog = ButtonDialog("Quit?", [("Cancel", "cancel"), ("Quit", "quit")], default=1) + if answer is not None: + asyncio.get_running_loop().call_soon(dialog.resolve, answer) + return await session.run_dialog(dialog) == "quit" + + session.set_quit_confirm(ask) + return session + + +async def test_quit_asks_first_and_cancelling_leaves_the_flow_where_it_was() -> None: + """One ^Q floats the confirm over whatever is up; Cancel puts the reader back. + + The screen under it is never resolved or popped — a capture behind the box keeps + capturing — which is the whole difference from the old straight-out chord (issue #22). + """ + with create_pipe_input() as inp: + asked: list[str] = [] + session = _asking_session(inp, asked, answer="cancel") + screen = ScrollScreen("a capture in progress") + + async def main() -> None: + session.push(screen) + session._dispatch("quit") + for _ in range(5): + await asyncio.sleep(0) + assert asked == ["asked"] + assert session.top is screen, "Cancel lands back on the screen it floated over" + assert not session._quit_asking + session.pop(screen) + + await asyncio.wait_for(session.run(main()), timeout=5) + assert screen.future is None or not screen.future.done() + + +async def test_a_second_quit_while_the_confirm_is_up_leaves_at_once() -> None: + """^Q ^Q is the fast way out, and it never waits on the dialog to take a key.""" + with create_pipe_input() as inp: + asked: list[str] = [] + session = _asking_session(inp, asked) + torn_down: list[str] = [] + + async def main() -> None: + try: + await session.run_screen(ScrollScreen("deep")) + finally: + torn_down.append("services closed") + + # Both presses in one batch: the flag is raised synchronously, so the second is + # the leaving rather than a second dialog. + inp.send_text(QUIT_KEY + QUIT_KEY) + await asyncio.wait_for(session.run(main()), timeout=5) + assert torn_down == ["services closed"] + assert len(asked) <= 1 + + +async def test_quit_confirmed_from_anywhere_exits_the_app() -> None: + """Choosing Quit in the floated confirm exits, and the flow's teardown still runs.""" + with create_pipe_input() as inp: + asked: list[str] = [] + session = _asking_session(inp, asked, answer="quit") + torn_down: list[str] = [] + + async def main() -> None: + try: + await session.run_screen(ScrollScreen("deep")) + finally: + torn_down.append("services closed") + + inp.send_text(QUIT_KEY) + await asyncio.wait_for(session.run(main()), timeout=5) + assert asked == ["asked"] + assert torn_down == ["services closed"] + + +def test_esc_at_the_main_menu_does_nothing_but_peel_a_filter() -> None: + """Esc-mashing up to the menu settles there instead of opening the quit confirm.""" + from meshterm.ui.menu import _MainMenu + + menu = _MainMenu( + "menu", + [Choice("alpha", 1), Choice("beta", 2)], + footer_hint="↑↓ move · type to filter · Enter select · ^Q quit", + ) + menu.future = asyncio.new_event_loop().create_future() + menu.handle("escape") + assert not menu.future.done(), "a bare Esc at the menu resolves nothing" + assert not menu.footer_hint.endswith("Esc clear") + menu.handle("text", "b") + assert menu.footer_hint.endswith("^Q quit · Esc clear") + menu.handle("escape") + assert menu._filter == "" and not menu.future.done() + assert menu.footer_hint.endswith("^Q quit") + + # --- the bindings themselves ------------------------------------------------- @@ -885,10 +989,12 @@ def test_both_global_chords_are_bound_on_both_ctrl_keys() -> None: def test_neither_global_chord_is_advertised_anywhere() -> None: - """Both are deliberately undiscoverable in the UI — the About page states them instead. + """Both are deliberately undiscoverable in the UI, bar the menu's own way out. A global verb has no screen to belong to, so a footer atom or an F-key chip would have to - ride on *every* screen; the lane only has three free slots per screen to begin with. + ride on *every* screen; the lane only has three free slots per screen to begin with. The + one exception is the main menu's hint, which names ^Q because Esc is inert there and the + menu is where a reader looks for the way out (issue #22) — exactly one string, there. """ import ast import re @@ -919,6 +1025,9 @@ def test_neither_global_chord_is_advertised_anywhere() -> None: and chord.search(node.value) ): offenders.append(f"{path.name}:{node.lineno}: {node.value.strip()[:70]}") + menu_hint = [o for o in offenders if o.startswith("menu.py:") and o.endswith("· ^Q quit")] + assert len(menu_hint) == 1, "the main menu's hint must name the quit chord, once" + offenders.remove(menu_hint[0]) assert not offenders, "the global chords must not appear in any hint or chip:\n" + "\n".join( offenders ) diff --git a/tests/test_tui.py b/tests/test_tui.py index 774f18c..675c5dd 100644 --- a/tests/test_tui.py +++ b/tests/test_tui.py @@ -2327,6 +2327,7 @@ def test_right_ctrl_rescue_covers_the_sessions_own_chords(monkeypatch) -> None: class FakeApp: def __init__(self) -> None: self.exited = False + self.is_running = True def exit(self) -> None: self.exited = True