"""ADHD-friendly live focus REPL (issue #16). Single-key, calm, interruptible. Designed to be testable without a real TTY: every interaction is driven by injected `wait_input` and `display` callables. """ from __future__ import annotations import time from typing import Callable, Optional from .box import BoxController from .timebox import BoxState DisplayFn = Callable[[str], None] InputFn = Callable[[float], Optional[str]] def _mmss(seconds) -> str: seconds = max(0, int(float(seconds))) return f"{seconds // 60:02d}:{seconds % 60:02d}" HELP = ( "commands (single key, Enter to repeat):\n" " p - pause the timer (nothing lost)\n" " r - resume where you left off\n" " i - interrupt with a reason (recoverable)\n" " c - complete the box\n" " q - quit (abandons the box, no judgment)\n" " h - show this help\n" " Enter - refresh the countdown" ) def _headline(box) -> str: if box.state == BoxState.running: return ( f"focusing - {_mmss(box.remaining_seconds())} left " f"of {_mmss(box.duration_seconds)}" ) if box.state == BoxState.paused: return f"paused - {_mmss(box.remaining_seconds)} left" if box.state == BoxState.interrupted: return f"interrupted - {_mmss(box.remaining_seconds())} left" if box.state == BoxState.open: return "fresh - starting now" return box.state.value def _progress_bar(box) -> str: progress = box.progress_percent() bar_len = 20 filled = bar_len * progress // 100 bar = "#" * filled + "-" * (bar_len - filled) return f" [{bar}] {progress}%" def _refresh(box, display: DisplayFn) -> None: display(_headline(box)) if box.state == BoxState.running: display(_progress_bar(box)) def _auto_enter(store, box, ctrl, display: DisplayFn): """On entry, auto-start a fresh box or auto-resume a paused one.""" if box.state == BoxState.open: box = ctrl.start(box) display("starting your focus session...") _refresh(box, display) elif box.state in (BoxState.paused, BoxState.interrupted): box = ctrl.resume(box) display("resuming where you left off...") _refresh(box, display) return box def run_repl( store, box, wait_input: InputFn, display: DisplayFn, tick_interval: float, ) -> ...: """Run the live focus loop and return the final box state. Args: store: a Store-like object supporting get_timebox / update_timebox. box: the TimeBox to work on (its current state is respected). wait_input: callable(timeout) -> input line or None on timeout. display: callable(str) -> emits one line of output. tick_interval: seconds between idle redraws (0 for event-driven). """ ctrl = BoxController(store) display("focus mode (press h for help)") display(HELP) display("-" * 40) # Re-fetch from store so external updates are visible. box = store.get_timebox(box.id) if box.id is not None else box box = _auto_enter(store, box, ctrl, display) try: while not box.is_terminal(): # Auto-expire if the timer ran out between ticks. if box.state == BoxState.running and box.remaining_seconds() <= 0: box = ctrl.expire(box) display("") display("timer ran out. take a breather.") _refresh(box, display) break # Idle refresh while running (only when there's a tick cadence). if tick_interval > 0: _refresh(box, display) time.sleep(tick_interval) box = store.get_timebox(box.id) if box.id is not None else box continue # Event-driven: poll input non-blocking. line = wait_input(0.0) if line is None: # No input yet; re-check expiry without blocking forever. box = store.get_timebox(box.id) if box.id is not None else box if box.state == BoxState.running and box.remaining_seconds() <= 0: box = ctrl.expire(box) display("timer ran out. take a breather.") break # Tiny yield so a pure-0 interval doesn't busy-spin in real use. if tick_interval == 0: continue time.sleep(tick_interval) continue line = line.strip() if line == "": _refresh(box, display) continue parts = line.split(None, 1) cmd = parts[0].lower() arg = parts[1] if len(parts) > 1 else "" try: if cmd == "h": display(HELP) elif cmd == "p": box = ctrl.pause(box) display(f"paused - {_mmss(box.remaining_seconds())} saved") elif cmd == "r": box = ctrl.resume(box) display(f"resumed - {_mmss(box.remaining_seconds())} to go") elif cmd == "i": if not arg: display("an interrupt needs a reason: type 'i '") display("(this helps you notice patterns later)") continue box = ctrl.interrupt(box, reason=arg) display(f"interrupted - {_mmss(box.remaining_seconds())} saved for later") display(f" reason: {arg}") elif cmd == "c": box = ctrl.complete(box) display(f"completed after {_mmss(box.elapsed_seconds())} - well done") elif cmd == "q": reason = arg or "" box = ctrl.abandon(box, reason=reason) display("abandoned. it's okay - come back when you want.") else: display(f"unknown command: '{cmd}'. press h for help.") except ValueError as e: display(f"can't do that: {e}") display("press h for help.") except KeyboardInterrupt: # Ctrl+C in real use: abandon cleanly, no judgment. if not box.is_terminal(): box = ctrl.abandon(box, reason="keyboard interrupt") display("") display("interrupted. box abandoned - it's okay, come back anytime.") display("-" * 40) display(f"box #{box.id} [{box.state.value}]") return box