- TimeBox model with explicit recoverable BoxState state machine - Store layer: time_boxes schema, CRUD, active-box recovery - BoxController: start/pause/resume/interrupt/complete/expire with elapsed math - CLI group (start/status/pause/resume/interrupt/complete/abandon/recover/list/focus) - Live ADHD-friendly focus REPL with single-key commands, calm UX, no-shame quit - Interruptions logged with timestamp + reason for pattern recognition - Non-interactive helpers + auto-resume on entry - 65 tests green, strict vertical RED-GREEN TDD
183 lines
6.4 KiB
Python
183 lines
6.4 KiB
Python
"""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 <why> - 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 <why>'")
|
|
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
|