From cbbc4778e124d1e4ba7661ae2845b1ecb0c78153 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:24:42 -0500 Subject: [PATCH 01/15] docs: minimal display rollback design spec Roll the display back from the PC-98 visual novel + ornate animations.py to a Claude-Code-style minimal surface: single status line, breathing room, occasional quip. Reflows to terminal width, degrades on non-TTY and NO_COLOR. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...6-04-20-minimal-display-rollback-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-20-minimal-display-rollback-design.md diff --git a/docs/superpowers/specs/2026-04-20-minimal-display-rollback-design.md b/docs/superpowers/specs/2026-04-20-minimal-display-rollback-design.md new file mode 100644 index 0000000..422169c --- /dev/null +++ b/docs/superpowers/specs/2026-04-20-minimal-display-rollback-design.md @@ -0,0 +1,130 @@ +# Minimal Display Rollback - Design Spec + +**Date:** 2026-04-20 +**Status:** Approved +**Author:** Blake (with Claude) + +## Motivation + +The CLI display has drifted far from the project's actual purpose. Digital Caffeine is a one-syscall-in-a-loop utility that keeps Windows awake, but the display layer has grown into a PC-98 visual novel: a 1,225-line `pc98/` package with a Textual app, pixel canvas, 16-color palette cycling, sprites, particle systems, scene compositor, and status/dialogue widgets. On top of that, `animations.py` carries 834 lines of decorative chrome - coffee cup ASCII art, procedural steam, drip particles, typewriter quips, RGB rainbow borders, bubbling coffee, saucer glow. + +The scope no longer fits the tool. This spec rolls the display back toward a minimal, Claude-Code-style surface: a single status line, breathing room, an occasional quip, and nothing else. "Highly adaptive" is the explicit goal - the display reflows to terminal width, degrades gracefully without color, and works in non-TTY contexts. + +## Scope of the Cut + +**Delete:** +- `src/digital_caffeine/pc98/` (all 10 files, ~1,225 lines) +- `tests/test_pc98_canvas.py`, `tests/test_pc98_palette.py`, `tests/test_pc98_particles.py`, `tests/test_pc98_scene.py`, `tests/test_pc98_sprites.py`, `tests/test_pc98_widgets.py` (~528 lines) +- `textual` from `pyproject.toml` dependencies + +**Rewrite:** +- `src/digital_caffeine/animations.py` (834 lines → roughly 150-200 lines) +- `tests/test_animations.py` (293 lines → roughly 80-100 lines) + +**Leave alone:** +- `src/digital_caffeine/engine.py` +- `src/digital_caffeine/config.py` +- `src/digital_caffeine/constants.py` +- `src/digital_caffeine/cli.py` (may need small tweaks if references to removed symbols exist) +- `src/digital_caffeine/tray.py` +- `src/digital_caffeine/icons.py` +- `pystray`, `Pillow`, `rich`, `click` dependencies +- `--simulate` (mouse jiggle) behavior - engine-level, unrelated to display +- Existing spec docs in `docs/superpowers/specs/` (historical record, not deleted) + +## New `animations.py` Surface + +The display is a single block redrawn in place via `rich.Live`, with three stacked zones: + +``` + ⠋ caffeine · keeping display awake · 2h 13m 5s · q to quit + + a well-caffeinated mind is a dangerous thing +``` + +### Status line + +` caffeine · · ] · q to quit` + +- **Spinner:** one Braille spinner frame from Rich's built-in set (`dots`). Advances at the redraw FPS. +- **``:** derived from the current `Mode` enum value: + - `Mode.DISPLAY` → `keeping display awake` + - `Mode.SYSTEM` → `keeping system awake` + - `Mode.ALL` → `keeping display + system awake` +- **Elapsed:** `Xh Ym Zs` with segments dropped as they hit zero on the left, no leading zeros (`5s`, `3m 2s`, `1h 0m 0s`, `2h 13m 5s`). +- **Duration suffix:** if `--duration` is set, append `· 1h 22m / 2h 30m left`. Uses the same "drop-left-zeros, no leading zeros" rule as elapsed, but seconds are always omitted for the duration suffix (duration input is minute-granular, showing seconds here adds noise). +- **Quit hint:** `· q to quit` when stdin is a TTY and the app is listening for keys. Omitted otherwise. +- **Paused state:** when the engine reports paused (tray mode can pause), mode phrase becomes `paused` and the spinner uses a single static frame (no rotation). +- **Color:** one dim accent (dim cyan direction) on the `caffeine` token and the remaining duration suffix. Everything else is default foreground. `NO_COLOR` env disables all accent. + +### Blank line + +One empty line between status and quip. Non-negotiable for the Claude-Code-style feel. + +### Quip line + +- One quip from the existing 120-quip pool. +- Rotates every ~90 seconds (module-level constant, not a flag). +- Dim styling. +- Empty string for the first ~5 seconds of the session so the first frame isn't noisy. + +### Adaptive behavior + +- Reflows to terminal width via Rich's built-in wrapping. No fixed column counts. +- Width < 50 cols: drop the `· q to quit` suffix and the `/ left` fragment. Keep spinner, `caffeine`, mode phrase, elapsed. +- `sys.stdout.isatty()` is False (piped/redirected): skip `Live` entirely. Print a single line `caffeine: keeping display awake (press Ctrl+C to stop)` and let the engine run. No redraw loop. +- `FPS` constant: 2 (down from 8). Spinner still feels alive; elapsed only ticks at 1 Hz anyway. + +### Public surface + +The module exports exactly one function: + +```python +def run_display(engine, mode, duration_seconds: int | None) -> None: ... +``` + +Blocking call. Returns when the engine stops or the user hits `q` / Ctrl+C. All other helpers (mode phrase lookup, elapsed formatter, quip picker) are module-private. + +### What's explicitly gone + +Coffee cup ASCII, procedural steam, drip particles, typewriter quips, RGB border cycle, bubbling coffee, saucer glow, breathing footer, per-character color gradients, palette cycling, half-block canvas, sprites. The entire `pc98/` package and its Textual integration. + +## Testing Strategy + +### Deleted tests + +The six `tests/test_pc98_*.py` files (~528 lines) are removed outright. + +### Rewritten tests + +`tests/test_animations.py` is rebuilt against the new surface: + +- `test_run_display_in_non_tty_mode` - monkeypatch `sys.stdout.isatty` to False; confirm the one-line fallback is printed and no `Live` context is opened. +- `test_status_line_reflow_narrow_terminal` - at terminal width 40, output omits `q to quit` and the duration-remaining fragment. +- `test_status_line_elapsed_formatting` - boundary cases for the elapsed formatter: 0s, 59s, 1m, 59m 59s, 1h 0m 0s, 2h 3m 5s. +- `test_mode_phrase_for_each_mode` - all three `Mode` values map to their expected phrase. +- `test_paused_mode_phrase` - when the engine reports paused, phrase is `paused` and the spinner uses the static frame. +- `test_no_color_env_disables_accent` - with `NO_COLOR=1`, no ANSI color codes appear in output. +- `test_quip_rotation_cadence` - freeze time, advance past the cadence threshold, confirm a different quip is selected. + +No tests for the `rich.Live` redraw loop itself - that's library behavior and hard to assert against. The strategy is to test the pure functions (mode phrase, elapsed formatter, width-based suffix selection, quip picker) plus one smoke test for the non-TTY fallback. + +### Untouched tests + +`tests/test_cli.py`, `tests/test_engine.py`, `tests/test_config.py`. CLI tests may need a minor tweak if they reference a symbol that no longer exists, but no command-surface changes are in scope. + +### Test LOC delta + +Total drops from 1,456 to roughly 820 lines. Roughly halves, which tracks with the scope cut. + +## Non-Goals + +- **Not changing CLI commands.** `caffeine start`, `caffeine start --tray`, `caffeine start --duration 2h30m`, `caffeine start --simulate`, `caffeine config --*`, `caffeine version` all behave the same. +- **Not changing engine behavior.** `SetThreadExecutionState` calls, pause/resume semantics, duration expiry, `--simulate` mouse jiggle all unchanged. +- **Not changing tray mode.** `tray.py` and `icons.py` stay as-is. +- **Not cleaning up historical spec docs.** The two prior overhaul specs (`2026-04-03-animation-overhaul-design.md`, `2026-04-03-pc98-overhaul-design.md`) remain in place as historical record. +- **No new features.** This is pure simplification. + +## Open Questions + +None at spec-writing time. If the implementation surfaces a real ambiguity, it will be flagged back to Blake rather than resolved unilaterally. From 4fbe9e0e985991fb77f449701b2a282cbf3ae283 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:31:21 -0500 Subject: [PATCH 02/15] docs: implementation plan for minimal display rollback 12-task TDD plan: helpers first (format_elapsed, _format_duration, _mode_phrase, _pick_quip, _build_status_text), then run_display non-TTY and TTY paths, then cli.py swap, then pc98 deletion, dep cleanup, and end-to-end smoke. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-04-20-minimal-display-rollback.md | 1065 +++++++++++++++++ 1 file changed, 1065 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-20-minimal-display-rollback.md diff --git a/docs/superpowers/plans/2026-04-20-minimal-display-rollback.md b/docs/superpowers/plans/2026-04-20-minimal-display-rollback.md new file mode 100644 index 0000000..7829c61 --- /dev/null +++ b/docs/superpowers/plans/2026-04-20-minimal-display-rollback.md @@ -0,0 +1,1065 @@ +# Minimal Display Rollback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Roll the CLI display back from the PC-98 visual novel and ornate `animations.py` to a Claude-Code-style minimal status line that reflows to terminal width, degrades on non-TTY, and honors `NO_COLOR`. + +**Architecture:** New `animations.py` exports one public entry point, `run_display(engine, mode, duration_seconds)`, plus a tiny `format_elapsed` helper for the CLI session summary. Internals are pure functions tested in isolation (mode phrase, elapsed/duration formatters, quip picker, status-text builder). A `rich.Live` loop drives redraw on TTY; non-TTY prints one line and waits on the engine. The PC-98 package, its tests, and the `textual` dependency are deleted. + +**Tech Stack:** Python 3.10+, Rich (Live, Console, Text), Click (CLI, unchanged), pytest + pytest-mock (testing). msvcrt (stdlib, Windows-only) for the `q`-to-quit key listener. + +**Spec clarification:** The design spec leaves `format_elapsed` as "module-private." This plan promotes it to public (no leading underscore) because the CLI session summary needs it. All other helpers (`_format_duration`, `_mode_phrase`, `_pick_quip`, `_build_status_text`) stay private. + +--- + +## Task 1: `format_elapsed` helper (TDD) + +**Files:** +- Create: `src/digital_caffeine/animations.py` (replaces old 834-line file; wipe and start over) +- Create: `tests/test_animations.py` (replaces old 293-line file; wipe and start over) + +- [ ] **Step 1: Wipe the old `animations.py` and replace with a stub** + +Delete the entire contents of `src/digital_caffeine/animations.py` and replace with: + +```python +"""Minimal status-line display for the Digital Caffeine CLI. + +Public surface: + run_display(engine, mode, duration_seconds) -> None + format_elapsed(seconds) -> str +""" + +from __future__ import annotations + +FPS = 2 +``` + +- [ ] **Step 2: Wipe the old `test_animations.py` and write the failing test** + +Delete the entire contents of `tests/test_animations.py` and replace with: + +```python +"""Tests for the minimal Digital Caffeine display.""" + +from __future__ import annotations + +import pytest + +from digital_caffeine.animations import format_elapsed + + +@pytest.mark.parametrize( + "seconds, expected", + [ + (0, "0s"), + (5, "5s"), + (59, "59s"), + (60, "1m 0s"), + (61, "1m 1s"), + (3599, "59m 59s"), + (3600, "1h 0m 0s"), + (3661, "1h 1m 1s"), + (7385, "2h 3m 5s"), + ], +) +def test_format_elapsed_boundary_cases(seconds: int, expected: str) -> None: + assert format_elapsed(seconds) == expected + + +def test_format_elapsed_negative_clamps_to_zero() -> None: + assert format_elapsed(-10) == "0s" +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `pytest tests/test_animations.py -v` +Expected: `ImportError: cannot import name 'format_elapsed' from 'digital_caffeine.animations'` (tests fail to collect). + +- [ ] **Step 4: Implement `format_elapsed`** + +Append to `src/digital_caffeine/animations.py`: + +```python +def format_elapsed(seconds: int) -> str: + """Format seconds as 'Xh Ym Zs' with leading zero segments dropped.""" + if seconds < 0: + seconds = 0 + hours, rem = divmod(seconds, 3600) + minutes, secs = divmod(rem, 60) + if hours > 0: + return f"{hours}h {minutes}m {secs}s" + if minutes > 0: + return f"{minutes}m {secs}s" + return f"{secs}s" +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `pytest tests/test_animations.py -v` +Expected: all 10 parametrized cases plus the negative test pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): rewrite as minimal module, add format_elapsed" +``` + +--- + +## Task 2: `_format_duration` helper (TDD) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` +- Modify: `tests/test_animations.py` + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_animations.py`: + +```python +from digital_caffeine.animations import _format_duration + + +@pytest.mark.parametrize( + "seconds, expected", + [ + (0, "0m"), + (60, "1m"), + (1800, "30m"), + (3600, "1h 0m"), + (5400, "1h 30m"), + (7200, "2h 0m"), + (9000, "2h 30m"), + ], +) +def test_format_duration_omits_seconds(seconds: int, expected: str) -> None: + assert _format_duration(seconds) == expected +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_animations.py::test_format_duration_omits_seconds -v` +Expected: `ImportError: cannot import name '_format_duration'`. + +- [ ] **Step 3: Implement `_format_duration`** + +Append to `src/digital_caffeine/animations.py`: + +```python +def _format_duration(seconds: int) -> str: + """Format seconds as 'Xh Ym' (no seconds). Clamps negatives to zero.""" + if seconds < 0: + seconds = 0 + hours, rem = divmod(seconds, 3600) + minutes = rem // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): add _format_duration for duration suffix" +``` + +--- + +## Task 3: `_mode_phrase` helper (TDD) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` +- Modify: `tests/test_animations.py` + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_animations.py`: + +```python +from digital_caffeine.animations import _mode_phrase +from digital_caffeine.constants import Mode + + +def test_mode_phrase_display_only() -> None: + assert _mode_phrase(Mode.DISPLAY_ONLY, paused=False) == "keeping display awake" + + +def test_mode_phrase_system_only() -> None: + assert _mode_phrase(Mode.SYSTEM_ONLY, paused=False) == "keeping system awake" + + +def test_mode_phrase_display_and_system() -> None: + assert ( + _mode_phrase(Mode.DISPLAY_AND_SYSTEM, paused=False) + == "keeping display + system awake" + ) + + +def test_mode_phrase_paused_overrides_mode() -> None: + assert _mode_phrase(Mode.DISPLAY_AND_SYSTEM, paused=True) == "paused" + assert _mode_phrase(Mode.DISPLAY_ONLY, paused=True) == "paused" + assert _mode_phrase(Mode.SYSTEM_ONLY, paused=True) == "paused" +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_animations.py -k mode_phrase -v` +Expected: ImportError for `_mode_phrase`. + +- [ ] **Step 3: Implement `_mode_phrase`** + +Append to `src/digital_caffeine/animations.py`: + +```python +from digital_caffeine.constants import Mode + +_MODE_PHRASES: dict[Mode, str] = { + Mode.DISPLAY_ONLY: "keeping display awake", + Mode.SYSTEM_ONLY: "keeping system awake", + Mode.DISPLAY_AND_SYSTEM: "keeping display + system awake", +} + +_PAUSED_PHRASE = "paused" + + +def _mode_phrase(mode: Mode, paused: bool) -> str: + """Return the descriptive phrase for the current engine state.""" + if paused: + return _PAUSED_PHRASE + return _MODE_PHRASES[mode] +``` + +Move the `Mode` import to the top of the file with the other imports. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): add _mode_phrase with paused override" +``` + +--- + +## Task 4: Quip pool and `_pick_quip` (TDD) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` +- Modify: `tests/test_animations.py` + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_animations.py`: + +```python +from digital_caffeine.animations import QUIPS, _pick_quip + + +def test_quips_pool_has_at_least_one_hundred() -> None: + assert len(QUIPS) >= 100 + + +def test_pick_quip_is_empty_during_startup_window() -> None: + for elapsed in range(5): + assert _pick_quip(elapsed_seconds=elapsed, seed=42) == "" + + +def test_pick_quip_returns_a_pool_member_after_startup() -> None: + quip = _pick_quip(elapsed_seconds=5, seed=42) + assert quip in QUIPS + + +def test_pick_quip_changes_after_rotation_interval() -> None: + # Rotation is 90 seconds. Two elapsed values that straddle the boundary + # should (with overwhelming probability) yield different quips. Using + # a fixed seed makes this deterministic. + a = _pick_quip(elapsed_seconds=5, seed=42) + b = _pick_quip(elapsed_seconds=5 + 90, seed=42) + assert a != b + + +def test_pick_quip_same_within_rotation_window() -> None: + a = _pick_quip(elapsed_seconds=5, seed=42) + b = _pick_quip(elapsed_seconds=5 + 89, seed=42) + assert a == b +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_animations.py -k quip -v` +Expected: ImportError for `QUIPS` / `_pick_quip`. + +- [ ] **Step 3: Implement `QUIPS` and `_pick_quip`** + +Append to `src/digital_caffeine/animations.py`: + +```python +import os +import random + +_QUIP_ROTATION_SECONDS = 90 +_STARTUP_QUIET_SECONDS = 5 + +QUIPS: list[str] = [ + # --- PASTE THE FULL 120-ENTRY LIST FROM THE PRE-ROLLBACK animations.py --- + # Copy the entries of the `_ALL_QUIPS` variable verbatim from the file + # history (commit 889e6e0 or earlier, lines roughly 497-624 of the old + # animations.py). Keep the comment dividers (-- coffee puns --, etc.) + # for readability. +] + + +def _pick_quip(elapsed_seconds: int, seed: int | None = None) -> str: + """Return the quip for this elapsed time, or empty during startup. + + The pool is shuffled with `seed` (defaults to the current process id so + each session feels different). The active quip changes every + _QUIP_ROTATION_SECONDS. + """ + if elapsed_seconds < _STARTUP_QUIET_SECONDS: + return "" + rng = random.Random(seed if seed is not None else os.getpid()) + shuffled = rng.sample(QUIPS, len(QUIPS)) + idx = ((elapsed_seconds - _STARTUP_QUIET_SECONDS) // _QUIP_ROTATION_SECONDS) % len( + shuffled + ) + return shuffled[idx] +``` + +Move `import os` and `import random` to the top of the file with the other imports. + +**Note:** The 120 quips are data, not logic. Open the pre-rollback `animations.py` in git (`git show 889e6e0:src/digital_caffeine/animations.py`) and copy the `_ALL_QUIPS` list contents into `QUIPS` verbatim. Do not edit or recurate them in this task. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests pass. If `test_pick_quip_changes_after_rotation_interval` flakes, the seed or rotation index math is wrong. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): import 120-quip pool and add _pick_quip" +``` + +--- + +## Task 5: `_build_status_text` (TDD) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` +- Modify: `tests/test_animations.py` + +- [ ] **Step 1: Add the failing tests** + +Append to `tests/test_animations.py`: + +```python +from io import StringIO + +from rich.console import Console + +from digital_caffeine.animations import _build_status_text + + +def _render(text, width: int = 100) -> str: + buf = StringIO() + console = Console(file=buf, force_terminal=True, width=width, no_color=False) + console.print(text) + return buf.getvalue() + + +def test_build_status_text_includes_mode_phrase_and_elapsed() -> None: + text = _build_status_text( + spinner_frame="\u280B", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=65, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text) + assert "caffeine" in rendered + assert "keeping display awake" in rendered + assert "1m 5s" in rendered + + +def test_build_status_text_duration_suffix_when_duration_set() -> None: + text = _build_status_text( + spinner_frame="\u280B", + mode=Mode.DISPLAY_AND_SYSTEM, + elapsed_seconds=60 * 38, + duration_seconds=60 * 120, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text) + assert "1h 22m / 2h 0m left" in rendered + + +def test_build_status_text_quit_hint_when_requested() -> None: + text = _build_status_text( + spinner_frame="\u280B", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + assert "q to quit" in _render(text) + + +def test_build_status_text_narrow_terminal_drops_suffixes() -> None: + text = _build_status_text( + spinner_frame="\u280B", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=3600, + paused=False, + width=40, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text, width=40) + assert "q to quit" not in rendered + assert "left" not in rendered + # But mode phrase and elapsed are still there + assert "keeping display awake" in rendered + assert "30s" in rendered + + +def test_build_status_text_no_color_omits_ansi_codes() -> None: + text = _build_status_text( + spinner_frame="\u280B", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=False, + ) + # With use_color=False the Text has no styles attached; we check by + # inspecting the style assignments directly rather than the rendered ANSI. + for segment in text.render(Console()): + assert segment.style is None or segment.style.color is None + + +def test_build_status_text_paused_uses_paused_phrase() -> None: + text = _build_status_text( + spinner_frame="\u2022", # static frame for paused state + mode=Mode.DISPLAY_AND_SYSTEM, + elapsed_seconds=30, + duration_seconds=None, + paused=True, + width=100, + show_quit_hint=True, + use_color=True, + ) + assert "paused" in _render(text) +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pytest tests/test_animations.py -k build_status_text -v` +Expected: ImportError for `_build_status_text`. + +- [ ] **Step 3: Implement `_build_status_text`** + +Append to `src/digital_caffeine/animations.py`: + +```python +from rich.text import Text + +_NARROW_TERMINAL_COLS = 50 +_ACCENT_STYLE = "cyan" +_DIM_STYLE = "dim" + + +def _build_status_text( + *, + spinner_frame: str, + mode: Mode, + elapsed_seconds: int, + duration_seconds: int | None, + paused: bool, + width: int, + show_quit_hint: bool, + use_color: bool, +) -> Text: + """Build the single-line status Text for a given snapshot of state. + + When `width` is below the narrow threshold, drops the quit hint and the + duration-remaining suffix. When `use_color` is False, no styles are applied + (NO_COLOR env). + """ + narrow = width < _NARROW_TERMINAL_COLS + phrase = _mode_phrase(mode, paused) + elapsed_str = format_elapsed(elapsed_seconds) + + accent = _ACCENT_STYLE if use_color else None + dim = _DIM_STYLE if use_color else None + + text = Text() + text.append(f"{spinner_frame} ") + text.append("caffeine", style=accent) + text.append(f" \u00b7 {phrase} \u00b7 {elapsed_str}") + + if duration_seconds is not None and not narrow: + remaining = max(0, duration_seconds - elapsed_seconds) + suffix = ( + f" \u00b7 {_format_duration(remaining)} / " + f"{_format_duration(duration_seconds)} left" + ) + text.append(suffix, style=dim) + + if show_quit_hint and not narrow: + text.append(" \u00b7 q to quit", style=dim) + + return text +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): add _build_status_text with narrow reflow and NO_COLOR" +``` + +--- + +## Task 6: Non-TTY path of `run_display` (TDD) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` +- Modify: `tests/test_animations.py` + +- [ ] **Step 1: Add the failing test** + +Append to `tests/test_animations.py`: + +```python +from types import SimpleNamespace + + +class _FakeEngine: + """Minimal stand-in for CaffeineEngine in tests.""" + + def __init__(self, *, paused: bool = False, stop_after: float | None = None) -> None: + self._paused = paused + self._active = True + self._stop_after = stop_after + + @property + def is_paused(self) -> bool: + return self._paused + + @property + def is_active(self) -> bool: + return self._active + + def stop_now(self) -> None: + self._active = False + + +def test_run_display_non_tty_prints_one_line_and_waits(monkeypatch) -> None: + from digital_caffeine.animations import run_display + + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + buf = StringIO() + console = Console(file=buf, force_terminal=False, width=100) + + engine = _FakeEngine() + + # Stop the engine after a short delay on a background thread. + import threading + threading.Timer(0.1, engine.stop_now).start() + + run_display(engine=engine, mode=Mode.DISPLAY_ONLY, duration_seconds=None, + console=console) + + output = buf.getvalue() + assert "caffeine: keeping display awake" in output + assert "Ctrl+C" in output + # One-line fallback, not a multiline live block + assert output.count("\n") <= 2 +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pytest tests/test_animations.py::test_run_display_non_tty_prints_one_line_and_waits -v` +Expected: `ImportError: cannot import name 'run_display'`. + +- [ ] **Step 3: Implement `run_display` with non-TTY branch only** + +Append to `src/digital_caffeine/animations.py`: + +```python +import sys +import time + +from rich.console import Console + + +def run_display( + engine, + mode: Mode, + duration_seconds: int | None, + *, + console: Console | None = None, +) -> None: + """Blocking display loop. Returns when the engine stops or user quits. + + - TTY: Rich Live redraw at FPS, reflows to terminal width. + - Non-TTY (piped/redirected): prints one status line, then sleeps until + the engine stops or Ctrl+C. + """ + console = console or Console() + + if not sys.stdout.isatty(): + phrase = _MODE_PHRASES[mode] + console.print(f"caffeine: {phrase} (press Ctrl+C to stop)") + try: + while engine.is_active: + time.sleep(1) + except KeyboardInterrupt: + pass + return + + # TTY path implemented in Task 7. + raise NotImplementedError("TTY path not yet implemented") +``` + +Move `import sys`, `import time`, and the `rich.console.Console` import to the top of the file. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests pass. The TTY branch is unreachable in automated tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/animations.py tests/test_animations.py +git commit -m "feat(animations): implement non-TTY fallback for run_display" +``` + +--- + +## Task 7: TTY path of `run_display` (manual smoke test) + +**Files:** +- Modify: `src/digital_caffeine/animations.py` + +No automated test in this task - the Rich Live loop + msvcrt key handler is verified manually. The pure functions it composes (`_build_status_text`, `_pick_quip`, `format_elapsed`) are already covered. + +- [ ] **Step 1: Implement the TTY branch** + +Replace the `raise NotImplementedError("TTY path not yet implemented")` line at the end of `run_display` with: + +```python + # TTY path + use_color = os.environ.get("NO_COLOR") is None + spinner_frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", + "\u2826", "\u2827", "\u2807", "\u280F"] + paused_frame = "\u2022" + + start = time.monotonic() + spinner_idx = 0 + + try: + from rich.live import Live + with Live(console=console, refresh_per_second=FPS, transient=False) as live: + while engine.is_active: + elapsed = int(time.monotonic() - start) + if duration_seconds is not None and elapsed >= duration_seconds: + break + + paused = engine.is_paused + frame = paused_frame if paused else spinner_frames[ + spinner_idx % len(spinner_frames) + ] + width = console.size.width + + status = _build_status_text( + spinner_frame=frame, + mode=mode, + elapsed_seconds=elapsed, + duration_seconds=duration_seconds, + paused=paused, + width=width, + show_quit_hint=True, + use_color=use_color, + ) + + quip = _pick_quip(elapsed) + quip_text = Text() + if quip: + quip_text.append(f" {quip}", + style=_DIM_STYLE if use_color else None) + + block = Text() + block.append_text(status) + block.append("\n\n") + block.append_text(quip_text) + + live.update(block) + + if _q_pressed(): + break + + spinner_idx += 1 + time.sleep(1 / FPS) + except KeyboardInterrupt: + pass + + +def _q_pressed() -> bool: + """Return True if 'q' or 'Q' was pressed since the last check. + + Uses msvcrt (Windows stdlib). On non-Windows platforms this returns False. + """ + try: + import msvcrt + except ImportError: + return False + if msvcrt.kbhit(): + ch = msvcrt.getch() + return ch in (b"q", b"Q") + return False +``` + +- [ ] **Step 2: Run the existing test suite to confirm nothing regressed** + +Run: `pytest tests/test_animations.py -v` +Expected: all tests still pass. + +- [ ] **Step 3: Manual smoke test (critical)** + +From a normal Windows terminal (not redirected), after running `pip install -e .`: + +```bash +caffeine start --duration 20s +``` + +Verify: +1. Status line appears: `⠋ caffeine · keeping display + system awake · 0s · q to quit` +2. Spinner advances, elapsed ticks every second. +3. Duration suffix appears once it's meaningful: `· 0h 0m / 0h 0m left` (OK if this feels off - duration format is minute-granular by design, so short durations look blunt). +4. Around 5s, a quip line appears. +5. Resizing the terminal to narrow width (~40 cols) reflows and drops the quit hint + duration suffix. +6. Pressing `q` stops the engine cleanly. +7. Pressing Ctrl+C also stops cleanly. +8. On exit, a one-line summary prints (still from cli.py - Task 8 replaces the multi-line summary). + +If any of the above fails, fix before committing. + +- [ ] **Step 4: Smoke test NO_COLOR** + +In a shell that supports env-var prefix (PowerShell or bash): + +```bash +NO_COLOR=1 caffeine start --duration 10s +``` + +Verify no color codes appear. Status line is still present. Quip still appears. + +- [ ] **Step 5: Smoke test non-TTY fallback** + +```bash +caffeine start --duration 5s | cat +``` + +Verify one line prints: `caffeine: keeping display + system awake (press Ctrl+C to stop)`. No Live redraw. Engine stops on its own after 5s. + +- [ ] **Step 6: Commit** + +```bash +git add src/digital_caffeine/animations.py +git commit -m "feat(animations): implement TTY redraw loop with spinner and q-to-quit" +``` + +--- + +## Task 8: Rewrite `cli.py start` to use `run_display` + +**Files:** +- Modify: `src/digital_caffeine/cli.py` + +- [ ] **Step 1: Remove dead imports and helpers** + +In `src/digital_caffeine/cli.py`, delete these entire blocks: + +1. The import line importing `FPS`, `MODE_DISPLAY`, `build_animated_display`, `format_time` from `animations`. Replace with: + ```python + from digital_caffeine.animations import format_elapsed, run_display + ``` +2. The entire `build_display` function (currently around lines 70-92). +3. The entire `_can_use_pc98` function (currently around lines 95-101). +4. The entire `_run_rich_display` function (currently around lines 104-138). +5. The `from rich.live import Live` and `from rich.panel import Panel` imports (no longer used). + +- [ ] **Step 2: Replace the CLI live-display block in `start`** + +The current `start` function has (currently around lines 239-282): + +```python + start_time = time.monotonic() + + try: + if _can_use_pc98(): + ... + else: + _run_rich_display(...) + except KeyboardInterrupt: + pass + finally: + engine.stop() + total_uptime = int(time.monotonic() - start_time) + console.print() + console.print("[bold cyan]Session Summary[/bold cyan]") + console.print(f" Total uptime: {format_time(total_uptime)}") + console.print(f" Mode: {MODE_DISPLAY[mode]}") + if simulate: + console.print(" Simulate: On") + console.print("[green]Digital Caffeine stopped. Sweet dreams![/green]") +``` + +Replace that entire block with: + +```python + start_time = time.monotonic() + + try: + run_display(engine=engine, mode=mode, duration_seconds=duration_seconds) + except KeyboardInterrupt: + pass + finally: + engine.stop() + total_uptime = int(time.monotonic() - start_time) + console.print( + f"caffeine stopped \u00b7 kept awake for {format_elapsed(total_uptime)}" + ) +``` + +- [ ] **Step 3: Remove the pre-display "started" banner** + +Also delete the line that currently prints `Digital Caffeine started - mode=...` inside `_run_rich_display` - that function is gone, so nothing to do here, but double-check no other `[green]Digital Caffeine started[/green]` print remains in `cli.py`. If one does, delete it. + +- [ ] **Step 4: Run the CLI tests** + +Run: `pytest tests/test_cli.py -v` +Expected: some tests fail because they still import `build_display` and `format_time` from `cli`. Those are fixed in Task 9. + +- [ ] **Step 5: Commit** + +```bash +git add src/digital_caffeine/cli.py +git commit -m "refactor(cli): replace PC-98/Rich dispatch with run_display" +``` + +--- + +## Task 9: Clean up `test_cli.py` + +**Files:** +- Modify: `tests/test_cli.py` + +- [ ] **Step 1: Delete the dead imports and tests** + +In `tests/test_cli.py`: + +1. Change the import line: + ```python + from digital_caffeine.cli import build_display, cli, format_time, parse_duration + ``` + to: + ```python + from digital_caffeine.cli import cli, parse_duration + ``` +2. Delete these tests entirely: + - `test_format_time_zero` + - `test_format_time_minutes_seconds` + - `test_format_time_hours` + - `test_build_display_returns_animated_panel` +3. Delete the unused imports at the top: `from io import StringIO`, `from rich.console import Console`, `from rich.panel import Panel`, `from digital_caffeine.constants import Mode` (only keep what's still used by remaining tests). + +- [ ] **Step 2: Run the full test suite** + +Run: `pytest -v` +Expected: +- All `test_animations.py` tests pass. +- All `test_cli.py` tests pass (parse_duration, version, config, start --help). +- `test_engine.py` and `test_config.py` unchanged and passing. +- `test_pc98_*.py` tests still exist and may be failing (pc98 package still present) - that's fine, they go in Task 10. + +- [ ] **Step 3: Commit** + +```bash +git add tests/test_cli.py +git commit -m "test(cli): drop tests for removed build_display and format_time" +``` + +--- + +## Task 10: Delete the `pc98` package and its tests + +**Files:** +- Delete: `src/digital_caffeine/pc98/` (entire directory) +- Delete: `tests/test_pc98_canvas.py` +- Delete: `tests/test_pc98_palette.py` +- Delete: `tests/test_pc98_particles.py` +- Delete: `tests/test_pc98_scene.py` +- Delete: `tests/test_pc98_sprites.py` +- Delete: `tests/test_pc98_widgets.py` + +- [ ] **Step 1: Delete the pc98 source package** + +```bash +git rm -r src/digital_caffeine/pc98 +``` + +- [ ] **Step 2: Delete the pc98 test files** + +```bash +git rm tests/test_pc98_canvas.py tests/test_pc98_palette.py tests/test_pc98_particles.py tests/test_pc98_scene.py tests/test_pc98_sprites.py tests/test_pc98_widgets.py +``` + +- [ ] **Step 3: Confirm nothing else imports from `pc98`** + +Run: `grep -r "digital_caffeine.pc98" src/ tests/` (via the Grep tool). +Expected: zero matches. If any, fix them. + +- [ ] **Step 4: Run the full test suite** + +Run: `pytest -v` +Expected: all remaining tests pass. Total test count drops by roughly 528 lines worth. + +- [ ] **Step 5: Commit** + +```bash +git commit -m "refactor: delete pc98 visual novel package and tests" +``` + +--- + +## Task 11: Remove `textual` from dependencies + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Remove the textual dependency line** + +In `pyproject.toml`, change: + +```toml +dependencies = [ + "click>=8.0", + "rich>=13.0", + "pystray>=0.19", + "Pillow>=10.0", + "textual>=8.0", +] +``` + +to: + +```toml +dependencies = [ + "click>=8.0", + "rich>=13.0", + "pystray>=0.19", + "Pillow>=10.0", +] +``` + +- [ ] **Step 2: Re-install the package to refresh dependencies** + +Run: `pip install -e ".[dev]"` +Expected: textual is either left installed (harmless) or removed. No errors. + +- [ ] **Step 3: Commit** + +```bash +git add pyproject.toml +git commit -m "chore(deps): drop textual, no longer needed after pc98 removal" +``` + +--- + +## Task 12: Final verification + +**Files:** none modified. + +- [ ] **Step 1: Lint** + +Run: `ruff check src/ tests/` +Expected: zero issues. If any appear in the new `animations.py` (e.g., unused imports left over from the incremental build), fix them with the Edit tool and re-run. + +- [ ] **Step 2: Full test suite** + +Run: `pytest -v` +Expected: all tests pass. Note the count for the commit message if anything changes. + +- [ ] **Step 3: End-to-end smoke test** + +Run these one after another, verifying behavior matches the spec: + +1. `caffeine start --duration 15s` - minimal display, spinner, elapsed ticks, quip appears after ~5s, exits cleanly at 15s. +2. Resize terminal to ~40 cols during run - reflows, drops suffixes. +3. `NO_COLOR=1 caffeine start --duration 5s` - no color output. +4. `caffeine start --duration 3s | cat` - one-line non-TTY fallback. +5. `caffeine start --tray` - launches tray icon, unchanged from before. Right-click works, pause/resume/exit work. (If this breaks, there is a regression from deleted-symbol residue; investigate `tray.py` for any stray `animations` imports. Based on `tray.py:1-50` it does not import `animations`, so this should Just Work.) +6. `caffeine version` - prints version. +7. `caffeine config --show` - prints config or says "No config file found." + +- [ ] **Step 4: Fix anything the smoke test surfaces** + +If any step fails, debug at the source. Do NOT paper over with try/except or fallbacks; the spec's intent is simplification. + +- [ ] **Step 5: Final commit (only if fixes were needed)** + +If any fixes were made in this task: + +```bash +git add +git commit -m "fix: polish after end-to-end smoke test" +``` + +Otherwise this task ends with no commit. + +--- + +## Done + +At this point: +- `src/digital_caffeine/pc98/` is gone. +- `src/digital_caffeine/animations.py` is roughly 150-200 lines with one public `run_display` entry point. +- `tests/test_animations.py` is roughly 80-100 lines covering the pure helpers + non-TTY fallback. +- `pyproject.toml` no longer depends on `textual`. +- `caffeine start` shows a minimal, adaptive Claude-Code-style display. +- `caffeine start --tray`, `--simulate`, `--duration`, `--mode` all behave the same. From 3f3117e30329ab2cfa4d132b7cbb6818aa47bfda Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:34:49 -0500 Subject: [PATCH 03/15] feat(animations): rewrite as minimal module, add format_elapsed --- src/digital_caffeine/animations.py | 847 +---------------------------- tests/test_animations.py | 306 +---------- 2 files changed, 39 insertions(+), 1114 deletions(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index ee7c685..8828238 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -1,834 +1,23 @@ -"""Animation data and helpers for the Digital Caffeine CLI display.""" +"""Minimal status-line display for the Digital Caffeine CLI. -from __future__ import annotations - -import math -import re - -from rich.panel import Panel -from rich.style import Style - -from digital_caffeine.constants import Mode - -# -- Configuration ----------------------------------------------------------- - -FPS = 24 - -# -- Markup-aware layout helpers --------------------------------------------- - - -def _visible_len(s: str) -> int: - """Return visible character count, ignoring Rich markup tags.""" - return len(re.sub(r"\[/?[^\]]*\]", "", s)) - - -def _pad_to(s: str, width: int) -> str: - """Pad string to target visible width, accounting for Rich markup.""" - return s + " " * max(0, width - _visible_len(s)) - - -# -- Procedural steam generation with warm-to-cool gradient ---------------- - -_STEAM_WIDTH = 25 -_STEAM_HEIGHT = 7 - -# Warm near cup (bottom rows), cooling to gray as steam rises (top rows) -_WISP_COLORS = [ - "#444444", # row 0 (top) - nearly invisible - "#555555", # row 1 - "#666666", # row 2 - "#888888", # row 3 - mid gray - "#997755", # row 4 - warm tan - "#AA8866", # row 5 - warm brown-tan - "#AA8866", # row 6 (bottom, near cup) - warmest -] -_TRAIL_COLORS = [ - "#303030", - "#383838", - "#444444", - "#555555", - "#776655", - "#886644", - "#886644", -] - -# Heat shimmer characters and colors for bottom 2 rows -_SHIMMER_CHARS = ["*", "\u00b7", "'"] -_SHIMMER_COLORS = ["#FFB347", "#FFA500", "#FF8C00"] - - -def _generate_steam_frames() -> list[str]: - """Generate 48 steam frames with warm-to-cool gradient and heat shimmer. - - Wisps start warm near the cup and cool to gray as they rise. - Occasional shimmer characters appear near the cup in warm orange/gold. - """ - cx = 12 - num_frames = 48 - age_chars = [")", "~", "'", "\u00b7", ".", "\u00b7", "'"] - trail_chars = ["~", "'", "\u00b7", ".", " ", " ", " "] - max_age = 9 - - wisps = [ - (0, cx - 2, 1.2, 0.50), - (1, cx + 1, 1.0, 0.40), - (2, cx, 0.8, 0.60), - (3, cx - 1, 1.5, 0.30), - (4, cx + 2, 1.1, 0.50), - (5, cx - 3, 0.9, 0.70), - (6, cx + 1, 1.3, 0.40), - (0, cx + 3, 0.7, 0.35), - (3, cx - 3, 1.0, 0.55), - (5, cx + 2, 1.4, 0.45), - (1, cx - 4, 1.1, 0.65), - (7, cx + 3, 0.9, 0.50), - (2, cx - 1, 1.6, 0.35), - (4, cx, 0.7, 0.75), - (6, cx - 2, 1.3, 0.55), - ] - - frames = [] - for f in range(num_frames): - char_grid = [[" "] * _STEAM_WIDTH for _ in range(_STEAM_HEIGHT)] - color_grid = [[None] * _STEAM_WIDTH for _ in range(_STEAM_HEIGHT)] - - for birth, bx, amp, freq in wisps: - age = (f - birth) % max_age - row = _STEAM_HEIGHT - 1 - age - drift = math.sin(f * freq + birth * 0.9) * amp - x = int(round(bx + drift)) - - if 0 <= row < _STEAM_HEIGHT: - char = age_chars[min(age, len(age_chars) - 1)] - color = _WISP_COLORS[min(row, len(_WISP_COLORS) - 1)] - if 0 <= x < _STEAM_WIDTH and char_grid[row][x] == " ": - char_grid[row][x] = char - color_grid[row][x] = color - - # Trail: one row below, fainter - tr = row + 1 - if 0 <= tr < _STEAM_HEIGHT and age > 0: - tc = trail_chars[min(age - 1, len(trail_chars) - 1)] - tcol = _TRAIL_COLORS[min(tr, len(_TRAIL_COLORS) - 1)] - tx = int(round(bx + drift * 0.6)) - if ( - tc != " " - and 0 <= tx < _STEAM_WIDTH - and char_grid[tr][tx] == " " - ): - char_grid[tr][tx] = tc - color_grid[tr][tx] = tcol - - # Heat shimmer in bottom 2 rows - shimmer_seed = f * 7 + 13 - for row in range(_STEAM_HEIGHT - 2, _STEAM_HEIGHT): - sx = (shimmer_seed * (row + 1) * 31) % _STEAM_WIDTH - if char_grid[row][sx] == " " and (shimmer_seed + row) % 5 < 2: - si = (shimmer_seed + row) % len(_SHIMMER_CHARS) - char_grid[row][sx] = _SHIMMER_CHARS[si] - color_grid[row][sx] = _SHIMMER_COLORS[si] - - # Ambient floating particles - faint sparkles drifting in the air - for p in range(3): - ap_seed = f * 19 + p * 43 - ar = (ap_seed * 7) % _STEAM_HEIGHT - ax = (ap_seed * 13 + int(math.sin(f * 0.3 + p) * 2)) % _STEAM_WIDTH - if char_grid[ar][ax] == " " and (ap_seed % 11) < 3: - char_grid[ar][ax] = "\u00b7" - brightness = 0x30 + (ap_seed % 0x20) - color_grid[ar][ax] = f"#{brightness:02x}{brightness:02x}{brightness:02x}" - - # Render with per-character markup - frame_lines = [] - for y in range(_STEAM_HEIGHT): - line = "" - for x in range(_STEAM_WIDTH): - c = char_grid[y][x] - col = color_grid[y][x] - if c != " " and col: - line += f"[{col}]{c}[/]" - else: - line += c - frame_lines.append(line) - frames.append("\n".join(frame_lines)) - return frames - - -STEAM_FRAMES: list[str] = _generate_steam_frames() - - -def get_steam_frame(frame: int, *, paused: bool) -> str: - """Return the steam art for the current frame. - - Warm wisps near the cup cool to gray as they rise. Heat shimmer - sparkles in orange/gold near the cup mouth. Bottom 2 rows pick up - a tint from the current border hue. Steam advances every 3 display - frames for natural rising speed at 24 FPS. - """ - if paused: - return "\n".join([" " * _STEAM_WIDTH] * _STEAM_HEIGHT) - sf = (frame // 3) % len(STEAM_FRAMES) - base = STEAM_FRAMES[sf] - - # Post-process: tint the bottom 2 rows toward the current border hue - border_hex = BORDER_COLORS[(frame // 2) % len(BORDER_COLORS)] - lines = base.split("\n") - for row_idx in range(_STEAM_HEIGHT - 2, _STEAM_HEIGHT): - if row_idx < len(lines): - # Replace warm wisp colors with a blend toward border hue - line = lines[row_idx] - for old_color in ("#AA8866", "#886644"): - if old_color in line: - # Blend: 50% original warm, 50% border - or_ = int(old_color[1:3], 16) - og = int(old_color[3:5], 16) - ob = int(old_color[5:7], 16) - br = int(border_hex[1:3], 16) - bg = int(border_hex[3:5], 16) - bb = int(border_hex[5:7], 16) - nr = int(or_ * 0.5 + br * 0.5) - ng = int(og * 0.5 + bg * 0.5) - nb = int(ob * 0.5 + bb * 0.5) - blended = f"#{nr:02x}{ng:02x}{nb:02x}" - line = line.replace(old_color, blended) - lines[row_idx] = line - return "\n".join(lines) - - -# -- Cup art with animated liquid surface, glow, and shimmer ---------------- - -# 16 ripple patterns (13 chars wide - color applied dynamically) -_SURFACE_CHARS: list[str] = [ - "~\u2248~\u2248~\u2248~\u2248~\u2248~\u2248~", - "~\u2248\u2248~\u2248~\u2248\u2248~\u2248~\u2248~", - "\u2248~\u2248~\u2248~\u2248~\u2248~\u2248~\u2248", - "\u2248~\u2248\u2248~\u2248\u2248~\u2248~\u2248\u2248~", - "\u2248~~\u2248~~\u2248~~\u2248~\u2248~", - "~\u2248\u2248~\u2248\u2248~\u2248\u2248~\u2248\u2248~", - "~\u2248~\u2248\u2248~\u2248~\u2248~\u2248~\u2248", - "\u2248~\u2248~\u2248\u2248~\u2248~\u2248~\u2248~", - "~\u2248~\u2248~\u2248~\u2248\u2248~\u2248~\u2248", - "\u2248\u2248~\u2248~\u2248~\u2248~\u2248~\u2248~", - "~\u2248\u2248~\u2248~\u2248\u2248~\u2248\u2248~\u2248", - "\u2248~\u2248\u2248~\u2248~\u2248~\u2248~\u2248~", - "~\u2248~\u2248\u2248\u2248~\u2248~\u2248~\u2248~", - "\u2248~\u2248~\u2248~\u2248\u2248\u2248~\u2248~\u2248", - "\u2248\u2248~\u2248~~\u2248~\u2248\u2248~\u2248~", - "~\u2248\u2248\u2248~\u2248~\u2248~\u2248~\u2248~", -] - - -def _surface_color(frame: int) -> str: - """Return the surface color for the current frame. - - Oscillates between chocolate (#D2691E) and warm gold (#FFB347) - using sine interpolation over ~4 seconds. - """ - t = (math.sin(frame / (4 * FPS) * 2 * math.pi) + 1) / 2 - # Interpolate RGB: #D2691E -> #FFB347 - r = int(0xD2 + t * (0xFF - 0xD2)) - g = int(0x69 + t * (0xB3 - 0x69)) - b = int(0x1E + t * (0x47 - 0x1E)) - return f"#{r:02x}{g:02x}{b:02x}" - - -def _surface_with_shimmer(frame: int) -> str: - """Build the animated surface line with color glow and shimmer highlights. - - 1-2 positions per frame get a gold highlight for light-catching effect. - """ - pattern_idx = (frame // 3) % len(_SURFACE_CHARS) - chars = list(_SURFACE_CHARS[pattern_idx]) - color = _surface_color(frame) - - # Deterministic shimmer: replace 1-2 chars with gold highlights - shimmer_seed = frame * 17 + 7 - for i in range(2): - pos = (shimmer_seed + i * 11) % len(chars) - if (shimmer_seed + i) % 7 < 3: - chars[pos] = "*" if i == 0 else "'" - - # Build markup: shimmer chars get gold, rest get glow color - result = "" - for j, ch in enumerate(chars): - if ch in ("*", "'"): - result += f"[#FFD700]{ch}[/]" - else: - result += f"[{color}]{ch}[/]" - return result - -# Pre-built cup components -_CUP_RIM = " [#DDDDDD]\u2554" + "\u2550" * 13 + "\u2557[/] " -_CUP_BOT = " [#CCCCCC]\u255a" + "\u2550" * 13 + "\u255d[/] " -_CUP_DIM = "[dim]" + "\u2591" * 13 + "[/]" - -# 5-layer coffee gradient: crema -> light -> medium -> dark -> deepest -_FILL_COLORS = [ - ("#D4A574", "#DCBC8A", "#C8955E"), # crema: tan/cream - ("#B8520A", "#C45E12", "#A84808"), # light coffee - ("#8B4513", "#955020", "#7D3B0E"), # medium coffee - ("#5C2E0E", "#6B3610", "#4E260C"), # dark coffee - ("#3A1A06", "#452008", "#2E1404"), # deepest coffee -] -_FILL_CHARS = ["\u2591", "\u2592", "\u2593", "\u2588"] # ░▒▓█ - - -def _animated_fill_row(frame: int, row_idx: int, width: int = 13) -> str: - """Generate an animated coffee fill row with per-character bubbling. - - Characters flicker between block densities and color variants, - creating a subtle simmering PC-98 dithering effect. - """ - colors = _FILL_COLORS[row_idx] - # Progressive density: crema=░, light=▒, medium=▓, dark=▓, deepest=█ - base_ci = min(row_idx, len(_FILL_CHARS) - 1) - base_char = _FILL_CHARS[base_ci] - result = "" - seed = frame * 13 + row_idx * 7 - for x in range(width): - h = (seed + x * 37) % 100 - if h < 15: # 15% lighter bubble - char = _FILL_CHARS[max(0, base_ci - 1)] - color = colors[1] - elif h < 22: # 7% darker pocket - char = _FILL_CHARS[min(len(_FILL_CHARS) - 1, base_ci + 1)] - color = colors[2] - else: - char = base_char - color = colors[0] - result += f"[{color}]{char}[/]" - return result - - -def _half_block_transition(frame: int, upper_idx: int, lower_idx: int, - width: int = 13) -> str: - """Generate a half-block transition row between two coffee layers. - - Uses ▄ with upper layer as background and lower layer as foreground - to create smooth PC-98 style dithering between gradient steps. - """ - result = "" - seed = frame * 11 + upper_idx * 5 - upper_colors = _FILL_COLORS[upper_idx] - lower_colors = _FILL_COLORS[lower_idx] - for x in range(width): - h = (seed + x * 23) % 100 - # Slight color variation per-character - if h < 20: - uc = upper_colors[1] - lc = lower_colors[1] - else: - uc = upper_colors[0] - lc = lower_colors[0] - result += f"[{lc} on {uc}]\u2584[/]" - return result - - -# Pre-cached paused cup (never changes) - 12 rows to match active -_CUP_PAUSED: str = "\n".join([ - _CUP_RIM, - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2560\u2550\u2550\u2564[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2551[/] [#CCCCCC]\u2551[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2551[/] [#CCCCCC]\u2551[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2551[/] [#CCCCCC]\u2551[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2551[/] [#CCCCCC]\u2551[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u255f\u2500\u2500\u2568[/]", - f" [#CCCCCC]\u2551[/] {_CUP_DIM} [#CCCCCC]\u2551[/]", - _CUP_BOT, - " [#555555]\u2554" + "\u2550" * 17 + "\u2557[/] ", - " [#444444]\u255a" + "\u2550" * 17 + "\u255d[/] ", - " [#2a2a2a]" + "\u2584" * 15 + "[/] ", -]) - - -def get_cup_art(frame: int, *, paused: bool) -> str: - """Return the coffee cup ASCII art for the current frame. - - PC-98 style: double-line box drawing, 5-layer coffee gradient with - half-block dithering transitions, animated crema foam, bubbling fill, - breathing handle, 3D saucer with shadow. - """ - if paused: - return _CUP_PAUSED - - surface = _surface_with_shimmer(frame) - - # Handle breathing: cycle between white and dim white in sync with border - handle_phase = (frame // 2) % 72 - ht = (math.sin(handle_phase / 72 * 2 * math.pi) + 1) / 2 - hv = int(170 + ht * 85) - hcolor = f"#{hv:02x}{hv:02x}{hv:02x}" - - # Animated layers - crema = _animated_fill_row(frame, 0) - trans_01 = _half_block_transition(frame, 0, 1) - fill_light = _animated_fill_row(frame, 1) - fill_med = _animated_fill_row(frame, 2) - trans_34 = _half_block_transition(frame, 3, 4) - fill_deep = _animated_fill_row(frame, 4) - - # Saucer reflects border color (30% blend) - border_hex = BORDER_COLORS[(frame // 2) % len(BORDER_COLORS)] - br = int(border_hex[1:3], 16) - bg_ = int(border_hex[3:5], 16) - bb = int(border_hex[5:7], 16) - sr = int(0x55 * 0.7 + br * 0.3) - sg = int(0x55 * 0.7 + bg_ * 0.3) - sb = int(0x55 * 0.7 + bb * 0.3) - sc = f"#{sr:02x}{sg:02x}{sb:02x}" - # Shadow is darker blend - shr = int(0x2a * 0.8 + br * 0.2) - shg = int(0x2a * 0.8 + bg_ * 0.2) - shb = int(0x2a * 0.8 + bb * 0.2) - shc = f"#{shr:02x}{shg:02x}{shb:02x}" - - lines = [ - _CUP_RIM, - f" [#CCCCCC]\u2551[/] {surface} [{hcolor}]\u2560\u2550\u2550\u2564[/]", - f" [#CCCCCC]\u2551[/] {crema} [{hcolor}]\u2551[/] [{hcolor}]\u2551[/]", - f" [#CCCCCC]\u2551[/] {trans_01} [{hcolor}]\u2551[/] [{hcolor}]\u2551[/]", - f" [#CCCCCC]\u2551[/] {fill_light} [{hcolor}]\u2551[/] [{hcolor}]\u2551[/]", - f" [#CCCCCC]\u2551[/] {fill_med} [{hcolor}]\u2551[/] [{hcolor}]\u2551[/]", - f" [#CCCCCC]\u2551[/] {trans_34} [{hcolor}]\u255f\u2500\u2500\u2568[/]", - f" [#CCCCCC]\u2551[/] {fill_deep} [{hcolor}]\u2551[/]", - _CUP_BOT, - f" [{sc}]\u2554" + "\u2550" * 17 + "\u2557[/] ", - f" [{sc}]\u255a" + "\u2550" * 17 + "\u255d[/] ", - f" [{shc}]" + "\u2584" * 15 + "[/] ", - ] - return "\n".join(lines) - - -# -- Coffee drip particles ------------------------------------------------- +Public surface: + run_display(engine, mode, duration_seconds) -> None + format_elapsed(seconds) -> str +""" -# Drip spawn columns (within cup walls, columns 6-16 in art space) -_DRIP_COLS = list(range(6, 17)) -_DRIP_COLORS = ["#B8520A", "#A0460E", "#8B4513"] - - -def get_drip_particles(frame: int) -> list[tuple[int, int, str, str]]: - """Return active drip particles as (row_offset, col, char, color) tuples. - - Drips spawn deterministically based on frame number. Each drip falls - 1-2 rows over 6-10 frames. Max 2 active at once. row_offset is - relative to just below the cup (0 = first row under saucer). - - Returns a list of (row_offset, col, character, color) tuples. - """ - drips: list[tuple[int, int, str, str]] = [] - - # Check recent spawn windows for active drips - # Spawn window: one potential drip every ~60 frames (~2.5s at 24 FPS) - spawn_interval = 60 - lifespan = 8 # frames a drip lives - - for window in range(3): # check last 3 spawn windows - spawn_frame = ((frame // spawn_interval) - window) * spawn_interval - if spawn_frame < 0: - continue - - # Deterministic spawn decision - seed = spawn_frame * 31 + 17 - if seed % 5 < 2: # ~40% chance per window - age = frame - spawn_frame - if 0 <= age < lifespan: - col = _DRIP_COLS[seed % len(_DRIP_COLS)] - row = age // 3 # fall 1 row every 3 frames - ci = min(age // 3, len(_DRIP_COLORS) - 1) - char = "'" if age < lifespan // 2 else "." - drips.append((row, col, char, _DRIP_COLORS[ci])) - - if len(drips) >= 2: - break - - return drips[:2] - - -# -- Smooth HSV rainbow border (72-step hue rotation) ---------------------- - - -def _hsv_to_hex(h: float, s: float, v: float) -> str: - """Convert HSV (h: 0-360, s: 0-1, v: 0-1) to a hex color string.""" - c = v * s - x = c * (1 - abs((h / 60) % 2 - 1)) - m = v - c - if h < 60: - r, g, b = c, x, 0.0 - elif h < 120: - r, g, b = x, c, 0.0 - elif h < 180: - r, g, b = 0.0, c, x - elif h < 240: - r, g, b = 0.0, x, c - elif h < 300: - r, g, b = x, 0.0, c - else: - r, g, b = c, 0.0, x - ri = int((r + m) * 255) - gi = int((g + m) * 255) - bi = int((b + m) * 255) - return f"#{ri:02x}{gi:02x}{bi:02x}" - - -def _generate_border_colors(steps: int = 72) -> list[str]: - """Generate a full HSV hue rotation at constant saturation and brightness. - - 72 steps at S=0.7, V=0.85 for rich but not neon colors. - Full rotation in ~6 seconds (advance every 2 frames at 24 FPS). - """ - return [_hsv_to_hex(i * 360 / steps, 0.7, 0.85) for i in range(steps)] - - -BORDER_COLORS: list[str] = _generate_border_colors() - - -def get_border_color(frame: int, *, paused: bool) -> str: - """Return the border color for the current frame. - - Advances every 2 frames for a full rainbow rotation in ~6 seconds. - """ - if paused: - return "yellow" - return BORDER_COLORS[(frame // 2) % len(BORDER_COLORS)] - - -# -- Quips with typewriter effect -------------------------------------------- - -_ALL_QUIPS: list[str] = [ - # -- coffee puns -- - "Brewing productivity...", - "Espresso yourself freely", - "A latte work getting done today", - "Grounds for staying awake", - "Bean there, done that", - "Mocha your day productive", - "Don't lose your tamper", - "Brew-tally efficient", - "Affogato what sleep feels like", - "Pour decisions? Never heard of 'em", - "Words cannot espresso how awake this PC is", - "Better latte than never", - "You mocha me crazy", - "Sip happens", - "Thanks a latte", - "I like big mugs and I cannot lie", - "Rise and grind", - "Life begins after coffee", - "Deja brew: you've had this coffee before", - "Brew can do it", - "What's brewin', good lookin'?", - "Mugs and kisses", - "The daily grind, literally", - "Java the Hutt would be proud", - "No filter needed", - "Keep calm and drink coffee", - "Frappe-ning right now: productivity", - "Cold brew? Never. Hot and alert.", - "Percolating at maximum efficiency", - "Another shot? Don't mind if I do", - "Brewtiful day to stay awake", - "I've bean thinking about staying awake", - "Instant coffee is an oxymoron, like instant sleep", - "Drip, drip, drip... staying awake", - # -- sleep/awake -- - "Sleep is for the weak (and not this PC)", - "Your PC refuses to sleep", - "This machine has a no-nap policy", - "Insomnia, but make it productive", - "Your PC is more awake than you are", - "Counting sheep? This PC doesn't know what sheep are", - "The only thing sleeping here is your screensaver", - "This PC runs on pure spite and caffeine", - "Sleep.exe has been permanently uninstalled", - "Who needs sleep when you have caffeine?", - "Power nap? More like power no", - "This machine hasn't blinked in hours", - "ZZZ? Not on my watch", - "Your PC is an insomniac and it's proud", - "The sandman was denied entry", - "Wide awake and slightly jittery", - "No rest for the wicked (or this PC)", - "Yawning is contagious. Good thing PCs can't yawn.", - "This PC pulled an all-nighter", - "Your PC's alarm clock is unnecessary", - "Lullabies have no power here", - "Naptime? We don't do that here", - "Your PC passed the vibe check: awake", - # -- tech humor -- - "SetThreadExecutionState goes brrr", - "Keeping the electrons flowing", - "sudo keep-awake --force --forever", - "while(true) { stayAwake(); }", - "Your screensaver is filing a complaint", - "Power management has left the chat", - "The screen shall not dim", - "Task Manager can't stop what it can't see", - "Your IT admin would not approve", - "404: Sleep Not Found", - "Have you tried turning it off and... no. Absolutely not.", - "This violates at least three energy policies", - "Connection to sleep server: REFUSED", - "The power settings have been politely overruled", - "Kernel panic? More like kernel party", - "Running hot, staying cool", - "This process has elevated privileges (to party)", - "Uptime is the only metric that matters", - "ping localhost: awake, awake, awake", - "Thread status: caffeinated", - "Garbage collection? Not collecting this process", - "Exception: SleepNotAllowedException", - "Runtime: forever. Or until Ctrl+C.", - "Memory leak? No, memory feature", - # -- workplace -- - "Look busy, stay caffeinated", - "Your Teams status: permanently green", - "Productivity level: caffeinated", - "HR can't prove you weren't at your desk", - "Working hard or hardly sleeping?", - "Annual review: never falls asleep on the job", - "If anyone asks, you've been here the whole time", - "Meeting in 5. Good thing the screen's still on.", - "The screensaver is on unpaid leave", - "This PC is doing the bare minimum... perfectly", - "Corporate wants you to keep working. PC agrees.", - "PTO stands for PC Turned On", - # -- absurd -- - "Somewhere, a bear is jealous of your lack of hibernation", - "Running on vibes and voltage", - "The void stares back, but at least the screen is on", - "Your PC has evolved beyond the need for rest", - "Powered by caffeine and questionable decisions", - "The coffee is a metaphor. The wakefulness is literal.", - "One does not simply let Windows sleep", - "Instructions unclear, PC now runs on coffee", - "Your PC's spirit animal is an owl on espresso", - "In a parallel universe, this PC is napping", - "This is fine. Everything is fine.", - "Schr\u00f6dinger's PC: asleep and awake. JK, it's awake.", - "The mitochondria is the powerhouse. Caffeine is the keep-awake.", - "Time is an illusion. Uptime doubly so.", - "Your PC has transcended the sleep-wake cycle", - # -- self-referential -- - "This animation runs at 24fps. You're welcome.", - "Handcrafted artisan wakefulness", - "Small program, big dreams", - "Still here. Still awake. Still caffeinated.", - "Keeping it real (and awake)", - "Just doing my job over here", - "Your PC is caffeinated", - "Freshly brewed and wide awake", - "No decaf allowed here", - "This machine runs on caffeine", - "Another cup? Don't mind if I do", - "Keeping things percolating...", -] - - -def _shuffle_quips() -> list[str]: - """Shuffle quip order per-session so each launch feels different.""" - import os - import random - rng = random.Random(os.getpid()) - return rng.sample(_ALL_QUIPS, len(_ALL_QUIPS)) - - -QUIPS: list[str] = _shuffle_quips() - -PAUSED_QUIP: str = "Gone cold... resume to reheat" - -_QUIP_INTERVAL = 12 # seconds per quip - - -def get_quip(frame: int, *, paused: bool) -> str: - """Return the current quip with a typewriter reveal effect. - - Characters appear one at a time every 3 frames (~8 chars/sec at 24 FPS) - with a blinking cursor while typing. Once fully revealed, the cursor - disappears. Quip order is shuffled per-session. - """ - if paused: - return PAUSED_QUIP - frames_per_quip = _QUIP_INTERVAL * FPS - quip_idx = (frame // frames_per_quip) % len(QUIPS) - quip = QUIPS[quip_idx] - - frame_in_quip = frame % frames_per_quip - # 1 character every 3 frames = ~8 chars/sec at 24 FPS - chars_to_show = min(len(quip), (frame_in_quip // 3) + 1) - - if chars_to_show < len(quip): - cursor = "\u2588" if (frame % 18) < 9 else " " - return quip[:chars_to_show] + cursor - return quip - - -# -- Progress bar ------------------------------------------------------------ - - -def _build_progress_bar( - elapsed: int, total: int, frame: int, width: int = 20 -) -> str: - """Build a rainbow-gradient progress bar for timed sessions. - - Each filled character gets a color from the border palette, - offset by frame for an animated shimmer effect. - """ - progress = min(1.0, elapsed / max(1, total)) - filled = int(progress * width) - pct = int(progress * 100) - - bar = "" - for i in range(width): - if i < filled: - # Rainbow gradient across the bar, shifting with frame - ci = (i * 3 + frame // 2) % len(BORDER_COLORS) - bar += f"[{BORDER_COLORS[ci]}]\u2593[/]" - else: - bar += "[#333333]\u2591[/]" - return f"{bar} [dim]{pct}%[/]" - - -# -- Display assembly -------------------------------------------------------- - -MODE_DISPLAY: dict[Mode, str] = { - Mode.DISPLAY_AND_SYSTEM: "Display + System", - Mode.DISPLAY_ONLY: "Display Only", - Mode.SYSTEM_ONLY: "System Only", -} - - -def format_time(seconds: int) -> str: - """Format an integer number of seconds as HH:MM:SS.""" - h = seconds // 3600 - m = (seconds % 3600) // 60 - s = seconds % 60 - return f"{h:02d}:{m:02d}:{s:02d}" - - -def build_animated_display( - *, - frame: int, - mode: Mode, - uptime_seconds: int, - duration_seconds: int | None, - interval: int, - paused: bool, - simulate: bool, -) -> Panel: - """Build an animated Rich Panel showing keep-awake status with coffee art. - - Status fields are vertically centered beside the cup art. Drip particles - render below the saucer. A progress bar appears when a duration is set. - """ - steam = get_steam_frame(frame, paused=paused) - cup = get_cup_art(frame, paused=paused) - border_color = get_border_color(frame, paused=paused) - quip = get_quip(frame, paused=paused) - - if paused: - status_str = "[yellow]Paused[/yellow]" - else: - # Pulsing green "Active" text - gt = (math.sin(frame / FPS * 2 * math.pi) + 1) / 2 - gv = int(0x66 + gt * 0x99) # pulse between dim and bright green - status_str = f"[#00{gv:02x}00]Active[/]" - - if duration_seconds is not None: - remaining = max(0, duration_seconds - uptime_seconds) - remaining_str = format_time(remaining) - else: - remaining_str = "Indefinite" - - sim_str = "[green]On[/green]" if simulate else "[dim]Off[/dim]" - - steam_lines = steam.split("\n") - cup_lines = cup.split("\n") - - # Drip particle rows (below saucer) - drip_lines: list[str] = [] - if not paused: - drips = get_drip_particles(frame) - if drips: - # 2 rows of drip space - drip_grid = [[" "] * _STEAM_WIDTH for _ in range(2)] - drip_color_grid: list[list[str | None]] = [[None] * _STEAM_WIDTH for _ in range(2)] - for row_off, col, char, color in drips: - if 0 <= row_off < 2 and 0 <= col < _STEAM_WIDTH: - drip_grid[row_off][col] = char - drip_color_grid[row_off][col] = color - for y in range(2): - line = "" - for x in range(_STEAM_WIDTH): - c = drip_grid[y][x] - col = drip_color_grid[y][x] - if c != " " and col: - line += f"[{col}]{c}[/]" - else: - line += c - drip_lines.append(line) - - art_lines = steam_lines + cup_lines + drip_lines - - # Uptime color pulses with each second - ut_color = border_color if (frame % FPS) < 2 else "#AAAAAA" - uptime_str = f"[{ut_color}]{format_time(uptime_seconds)}[/]" - - status_fields = [ - f"Status: {status_str}", - f"Mode: {MODE_DISPLAY.get(mode, str(mode))}", - f"Uptime: {uptime_str}", - f"Time remaining: {remaining_str}", - f"Interval: {interval}s", - f"Simulate: {sim_str}", - ] - - if duration_seconds is not None: - status_fields.append("") - status_fields.append( - _build_progress_bar(uptime_seconds, duration_seconds, frame) - ) - - # Vertically center status beside the art - status_offset = (len(art_lines) - len(status_fields)) // 2 - status_offset = max(0, status_offset) - - art_width = 28 - combined_lines: list[str] = [] - total_rows = max(len(art_lines), len(status_fields) + status_offset) - for i in range(total_rows): - left = art_lines[i] if i < len(art_lines) else "" - si = i - status_offset - right = "" - if 0 <= si < len(status_fields): - right = status_fields[si] - combined_lines.append(f" {_pad_to(left, art_width)} {right}") - - combined_lines.append("") - if paused: - combined_lines.append( - f" [yellow dim italic]{quip}[/yellow dim italic]" - ) - else: - # Quip text gets a warm tint from the surface glow - quip_color = _surface_color(frame) - combined_lines.append(f" [{quip_color} italic]{quip}[/]") - - combined_lines.append("") - # Breathing footer - opacity pulses via gray value - footer_t = (math.sin(frame / FPS * math.pi) + 1) / 2 # ~2s cycle - fv = int(60 + footer_t * 50) # #3C3C3C to #6E6E6E - footer_color = f"#{fv:02x}{fv:02x}{fv:02x}" - combined_lines.append(f" [{footer_color}]Press Ctrl+C to stop[/]") +from __future__ import annotations - content = "\n".join(combined_lines) +FPS = 2 - # Title matches the border color - title_str = f"[bold {border_color}]:coffee: Digital Caffeine[/]" - return Panel( - content, - title=title_str, - border_style=Style(color=border_color), - padding=(1, 1), - expand=False, - ) +def format_elapsed(seconds: int) -> str: + """Format seconds as 'Xh Ym Zs' with leading zero segments dropped.""" + if seconds < 0: + seconds = 0 + hours, rem = divmod(seconds, 3600) + minutes, secs = divmod(rem, 60) + if hours > 0: + return f"{hours}h {minutes}m {secs}s" + if minutes > 0: + return f"{minutes}m {secs}s" + return f"{secs}s" diff --git a/tests/test_animations.py b/tests/test_animations.py index 7f96549..18dfea8 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -1,293 +1,29 @@ -"""Tests for the Digital Caffeine CLI animations module.""" +"""Tests for the minimal Digital Caffeine display.""" from __future__ import annotations -from io import StringIO +import pytest -from rich.console import Console -from rich.panel import Panel - -from digital_caffeine.animations import ( - BORDER_COLORS, - FPS, - PAUSED_QUIP, - QUIPS, - STEAM_FRAMES, - build_animated_display, - get_border_color, - get_cup_art, - get_drip_particles, - get_quip, - get_steam_frame, -) -from digital_caffeine.constants import Mode - -# -- Steam frame tests -- - - -def test_steam_frames_are_generated() -> None: - assert len(STEAM_FRAMES) == 48 - - -def test_get_steam_frame_cycles() -> None: - first = get_steam_frame(frame=0, paused=False) - # Steam advances every 3 frames, so full cycle is len * 3 frames - wrapped = get_steam_frame(frame=len(STEAM_FRAMES) * 3, paused=False) - assert first == wrapped - - -def test_get_steam_frame_has_seven_lines() -> None: - result = get_steam_frame(frame=0, paused=False) - assert len(result.split("\n")) == 7 - - -def test_get_steam_frame_paused_returns_blank_lines() -> None: - result = get_steam_frame(frame=0, paused=True) - lines = result.split("\n") - assert len(lines) == 7 - assert all(line.strip() == "" for line in lines) - - -# -- Cup art tests -- - - -def test_get_cup_art_active_has_fill() -> None: - result = get_cup_art(frame=0, paused=False) - assert "\u2593" in result - - -def test_get_cup_art_paused_has_dim_fill() -> None: - result = get_cup_art(frame=0, paused=True) - assert "\u2591" in result - - -def test_get_cup_art_active_has_twelve_lines() -> None: - result = get_cup_art(frame=0, paused=False) - assert len(result.split("\n")) == 12 - - -def test_get_cup_art_surface_animates() -> None: - art_0 = get_cup_art(frame=0, paused=False) - art_2 = get_cup_art(frame=2, paused=False) - assert art_0 != art_2 - - -def test_get_cup_art_surface_glow_shifts() -> None: - """Surface color oscillates over time, so distant frames differ.""" - art_0 = get_cup_art(frame=0, paused=False) - art_48 = get_cup_art(frame=48, paused=False) - assert art_0 != art_48 - - -def test_get_cup_art_has_half_block_transitions() -> None: - """PC-98 style half-block dithering between coffee layers.""" - result = get_cup_art(frame=0, paused=False) - assert "\u2584" in result # lower half block used in transitions - - -def test_get_cup_art_has_double_line_box() -> None: - """Cup uses double-line box drawing characters.""" - result = get_cup_art(frame=0, paused=False) - assert "\u2550" in result # ═ - assert "\u2551" in result # ║ - - -# -- Border color tests -- - - -def test_border_colors_has_smooth_steps() -> None: - assert len(BORDER_COLORS) == 72 - assert all(c.startswith("#") for c in BORDER_COLORS) - - -def test_get_border_color_cycles() -> None: - # Border advances every 2 frames - colors = [get_border_color(frame=i * 2, paused=False) for i in range(72)] - assert colors == BORDER_COLORS - assert get_border_color(frame=144, paused=False) == BORDER_COLORS[0] - - -def test_get_border_color_paused_returns_yellow() -> None: - assert get_border_color(frame=0, paused=True) == "yellow" - assert get_border_color(frame=99, paused=True) == "yellow" +from digital_caffeine.animations import format_elapsed -# -- Quip tests -- - - -def test_quips_has_many_entries() -> None: - assert len(QUIPS) >= 100 - - -def test_get_quip_rotates() -> None: - frames_per_quip = 12 * FPS # 12 seconds per quip at 24 FPS - quip_a = get_quip(frame=frames_per_quip - 1, paused=False) - quip_b = get_quip(frame=2 * frames_per_quip - 1, paused=False) - assert quip_a != quip_b - - -def test_get_quip_typewriter_effect() -> None: - partial = get_quip(frame=0, paused=False) - full = get_quip(frame=12 * FPS - 1, paused=False) - assert len(partial) < len(full) - assert full.startswith(partial[:1]) - - -def test_get_quip_wraps_around() -> None: - cycle_length = len(QUIPS) * 12 * FPS - end = 12 * FPS - 1 - assert get_quip(frame=end, paused=False) == get_quip( - frame=cycle_length + end, paused=False - ) - - -def test_get_quip_paused_returns_paused_quip() -> None: - assert get_quip(frame=0, paused=True) == PAUSED_QUIP - assert get_quip(frame=99, paused=True) == PAUSED_QUIP - - -# -- Drip particle tests -- - - -def test_get_drip_particles_returns_list() -> None: - drips = get_drip_particles(frame=0) - assert isinstance(drips, list) - - -def test_get_drip_particles_max_two() -> None: - # Check across many frames that we never exceed 2 active drips - for f in range(500): - drips = get_drip_particles(frame=f) - assert len(drips) <= 2 - - -def test_get_drip_particles_deterministic() -> None: - """Same frame number always produces the same drips.""" - a = get_drip_particles(frame=100) - b = get_drip_particles(frame=100) - assert a == b - - -# -- build_animated_display tests -- - - -def test_build_animated_display_returns_panel() -> None: - result = build_animated_display( - frame=0, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=0, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - assert isinstance(result, Panel) - - -def test_build_animated_display_contains_status_info() -> None: - buf = StringIO() - console = Console(file=buf, force_terminal=True, width=90) - panel = build_animated_display( - frame=0, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=65, - duration_seconds=3600, - interval=60, - paused=False, - simulate=True, - ) - console.print(panel) - output = buf.getvalue() - - assert "Active" in output - assert "Display + System" in output - assert "00:01:05" in output - assert "00:58:55" in output - assert "60s" in output - - -def test_build_animated_display_paused_state() -> None: - buf = StringIO() - console = Console(file=buf, force_terminal=True, width=90) - panel = build_animated_display( - frame=0, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=10, - duration_seconds=None, - interval=60, - paused=True, - simulate=False, - ) - console.print(panel) - output = buf.getvalue() - - assert "Paused" in output - assert "Gone cold" in output - - -def test_build_animated_display_border_color_changes() -> None: - # Border advances every 2 frames, so use frames 0 and 2 to get different colors - panel_0 = build_animated_display( - frame=0, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=0, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - panel_2 = build_animated_display( - frame=2, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=0, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - assert panel_0.border_style != panel_2.border_style - - -def test_build_animated_display_quip_rotates() -> None: - buf_0 = StringIO() - console_0 = Console(file=buf_0, force_terminal=True, width=90) - panel_0 = build_animated_display( - frame=0, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=0, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - console_0.print(panel_0) - - buf_8 = StringIO() - console_8 = Console(file=buf_8, force_terminal=True, width=90) - panel_8 = build_animated_display( - frame=12 * FPS, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=12, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - console_8.print(panel_8) - - assert buf_0.getvalue() != buf_8.getvalue() +@pytest.mark.parametrize( + "seconds, expected", + [ + (0, "0s"), + (5, "5s"), + (59, "59s"), + (60, "1m 0s"), + (61, "1m 1s"), + (3599, "59m 59s"), + (3600, "1h 0m 0s"), + (3661, "1h 1m 1s"), + (7385, "2h 3m 5s"), + ], +) +def test_format_elapsed_boundary_cases(seconds: int, expected: str) -> None: + assert format_elapsed(seconds) == expected -def test_build_animated_display_renders_without_error_at_many_frames() -> None: - """Verify display assembly works at various frame counts without crashing.""" - for f in [0, 1, 24, 48, 100, 288, 500]: - panel = build_animated_display( - frame=f, - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=f // FPS, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - assert isinstance(panel, Panel) +def test_format_elapsed_negative_clamps_to_zero() -> None: + assert format_elapsed(-10) == "0s" From ef6354d5f3d6dbff78262ccd9ea897a3172f383b Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:37:37 -0500 Subject: [PATCH 04/15] feat(animations): add _format_duration for duration suffix --- src/digital_caffeine/animations.py | 11 +++++++++++ tests/test_animations.py | 18 +++++++++++++++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 8828238..262d865 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -21,3 +21,14 @@ def format_elapsed(seconds: int) -> str: if minutes > 0: return f"{minutes}m {secs}s" return f"{secs}s" + + +def _format_duration(seconds: int) -> str: + """Format seconds as 'Xh Ym' (no seconds). Clamps negatives to zero.""" + if seconds < 0: + seconds = 0 + hours, rem = divmod(seconds, 3600) + minutes = rem // 60 + if hours > 0: + return f"{hours}h {minutes}m" + return f"{minutes}m" diff --git a/tests/test_animations.py b/tests/test_animations.py index 18dfea8..9bef417 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -4,7 +4,7 @@ import pytest -from digital_caffeine.animations import format_elapsed +from digital_caffeine.animations import _format_duration, format_elapsed @pytest.mark.parametrize( @@ -27,3 +27,19 @@ def test_format_elapsed_boundary_cases(seconds: int, expected: str) -> None: def test_format_elapsed_negative_clamps_to_zero() -> None: assert format_elapsed(-10) == "0s" + + +@pytest.mark.parametrize( + "seconds, expected", + [ + (0, "0m"), + (60, "1m"), + (1800, "30m"), + (3600, "1h 0m"), + (5400, "1h 30m"), + (7200, "2h 0m"), + (9000, "2h 30m"), + ], +) +def test_format_duration_omits_seconds(seconds: int, expected: str) -> None: + assert _format_duration(seconds) == expected From 7437a50b90f97ba4a685d2f8018677a31a43da33 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:42:04 -0500 Subject: [PATCH 05/15] feat(animations): add _mode_phrase with paused override --- src/digital_caffeine/animations.py | 17 +++++++++++++++++ tests/test_animations.py | 24 +++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 262d865..af96443 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -7,8 +7,18 @@ from __future__ import annotations +from digital_caffeine.constants import Mode + FPS = 2 +_MODE_PHRASES: dict[Mode, str] = { + Mode.DISPLAY_ONLY: "keeping display awake", + Mode.SYSTEM_ONLY: "keeping system awake", + Mode.DISPLAY_AND_SYSTEM: "keeping display + system awake", +} + +_PAUSED_PHRASE = "paused" + def format_elapsed(seconds: int) -> str: """Format seconds as 'Xh Ym Zs' with leading zero segments dropped.""" @@ -32,3 +42,10 @@ def _format_duration(seconds: int) -> str: if hours > 0: return f"{hours}h {minutes}m" return f"{minutes}m" + + +def _mode_phrase(mode: Mode, paused: bool) -> str: + """Return the descriptive phrase for the current engine state.""" + if paused: + return _PAUSED_PHRASE + return _MODE_PHRASES[mode] diff --git a/tests/test_animations.py b/tests/test_animations.py index 9bef417..7edaee2 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -4,7 +4,8 @@ import pytest -from digital_caffeine.animations import _format_duration, format_elapsed +from digital_caffeine.animations import _format_duration, _mode_phrase, format_elapsed +from digital_caffeine.constants import Mode @pytest.mark.parametrize( @@ -43,3 +44,24 @@ def test_format_elapsed_negative_clamps_to_zero() -> None: ) def test_format_duration_omits_seconds(seconds: int, expected: str) -> None: assert _format_duration(seconds) == expected + + +def test_mode_phrase_display_only() -> None: + assert _mode_phrase(Mode.DISPLAY_ONLY, paused=False) == "keeping display awake" + + +def test_mode_phrase_system_only() -> None: + assert _mode_phrase(Mode.SYSTEM_ONLY, paused=False) == "keeping system awake" + + +def test_mode_phrase_display_and_system() -> None: + assert ( + _mode_phrase(Mode.DISPLAY_AND_SYSTEM, paused=False) + == "keeping display + system awake" + ) + + +def test_mode_phrase_paused_overrides_mode() -> None: + assert _mode_phrase(Mode.DISPLAY_AND_SYSTEM, paused=True) == "paused" + assert _mode_phrase(Mode.DISPLAY_ONLY, paused=True) == "paused" + assert _mode_phrase(Mode.SYSTEM_ONLY, paused=True) == "paused" From 97842f731d1bf797f64f07de945d64866a02ef30 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:46:26 -0500 Subject: [PATCH 06/15] feat(animations): import 120-quip pool and add _pick_quip --- src/digital_caffeine/animations.py | 152 +++++++++++++++++++++++++++++ tests/test_animations.py | 37 ++++++- 2 files changed, 188 insertions(+), 1 deletion(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index af96443..06f7d6a 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -7,10 +7,16 @@ from __future__ import annotations +import os +import random + from digital_caffeine.constants import Mode FPS = 2 +_QUIP_ROTATION_SECONDS = 90 +_STARTUP_QUIET_SECONDS = 5 + _MODE_PHRASES: dict[Mode, str] = { Mode.DISPLAY_ONLY: "keeping display awake", Mode.SYSTEM_ONLY: "keeping system awake", @@ -19,6 +25,135 @@ _PAUSED_PHRASE = "paused" +QUIPS: list[str] = [ + # -- coffee puns -- + "Brewing productivity...", + "Espresso yourself freely", + "A latte work getting done today", + "Grounds for staying awake", + "Bean there, done that", + "Mocha your day productive", + "Don't lose your tamper", + "Brew-tally efficient", + "Affogato what sleep feels like", + "Pour decisions? Never heard of 'em", + "Words cannot espresso how awake this PC is", + "Better latte than never", + "You mocha me crazy", + "Sip happens", + "Thanks a latte", + "I like big mugs and I cannot lie", + "Rise and grind", + "Life begins after coffee", + "Deja brew: you've had this coffee before", + "Brew can do it", + "What's brewin', good lookin'?", + "Mugs and kisses", + "The daily grind, literally", + "Java the Hutt would be proud", + "No filter needed", + "Keep calm and drink coffee", + "Frappe-ning right now: productivity", + "Cold brew? Never. Hot and alert.", + "Percolating at maximum efficiency", + "Another shot? Don't mind if I do", + "Brewtiful day to stay awake", + "I've bean thinking about staying awake", + "Instant coffee is an oxymoron, like instant sleep", + "Drip, drip, drip... staying awake", + # -- sleep/awake -- + "Sleep is for the weak (and not this PC)", + "Your PC refuses to sleep", + "This machine has a no-nap policy", + "Insomnia, but make it productive", + "Your PC is more awake than you are", + "Counting sheep? This PC doesn't know what sheep are", + "The only thing sleeping here is your screensaver", + "This PC runs on pure spite and caffeine", + "Sleep.exe has been permanently uninstalled", + "Who needs sleep when you have caffeine?", + "Power nap? More like power no", + "This machine hasn't blinked in hours", + "ZZZ? Not on my watch", + "Your PC is an insomniac and it's proud", + "The sandman was denied entry", + "Wide awake and slightly jittery", + "No rest for the wicked (or this PC)", + "Yawning is contagious. Good thing PCs can't yawn.", + "This PC pulled an all-nighter", + "Your PC's alarm clock is unnecessary", + "Lullabies have no power here", + "Naptime? We don't do that here", + "Your PC passed the vibe check: awake", + # -- tech humor -- + "SetThreadExecutionState goes brrr", + "Keeping the electrons flowing", + "sudo keep-awake --force --forever", + "while(true) { stayAwake(); }", + "Your screensaver is filing a complaint", + "Power management has left the chat", + "The screen shall not dim", + "Task Manager can't stop what it can't see", + "Your IT admin would not approve", + "404: Sleep Not Found", + "Have you tried turning it off and... no. Absolutely not.", + "This violates at least three energy policies", + "Connection to sleep server: REFUSED", + "The power settings have been politely overruled", + "Kernel panic? More like kernel party", + "Running hot, staying cool", + "This process has elevated privileges (to party)", + "Uptime is the only metric that matters", + "ping localhost: awake, awake, awake", + "Thread status: caffeinated", + "Garbage collection? Not collecting this process", + "Exception: SleepNotAllowedException", + "Runtime: forever. Or until Ctrl+C.", + "Memory leak? No, memory feature", + # -- workplace -- + "Look busy, stay caffeinated", + "Your Teams status: permanently green", + "Productivity level: caffeinated", + "HR can't prove you weren't at your desk", + "Working hard or hardly sleeping?", + "Annual review: never falls asleep on the job", + "If anyone asks, you've been here the whole time", + "Meeting in 5. Good thing the screen's still on.", + "The screensaver is on unpaid leave", + "This PC is doing the bare minimum... perfectly", + "Corporate wants you to keep working. PC agrees.", + "PTO stands for PC Turned On", + # -- absurd -- + "Somewhere, a bear is jealous of your lack of hibernation", + "Running on vibes and voltage", + "The void stares back, but at least the screen is on", + "Your PC has evolved beyond the need for rest", + "Powered by caffeine and questionable decisions", + "The coffee is a metaphor. The wakefulness is literal.", + "One does not simply let Windows sleep", + "Instructions unclear, PC now runs on coffee", + "Your PC's spirit animal is an owl on espresso", + "In a parallel universe, this PC is napping", + "This is fine. Everything is fine.", + "Schr\u00f6dinger's PC: asleep and awake. JK, it's awake.", + "The mitochondria is the powerhouse. Caffeine is the keep-awake.", + "Time is an illusion. Uptime doubly so.", + "Your PC has transcended the sleep-wake cycle", + # -- self-referential -- + "This animation runs at 24fps. You're welcome.", + "Handcrafted artisan wakefulness", + "Small program, big dreams", + "Still here. Still awake. Still caffeinated.", + "Keeping it real (and awake)", + "Just doing my job over here", + "Your PC is caffeinated", + "Freshly brewed and wide awake", + "No decaf allowed here", + "This machine runs on caffeine", + "Another cup? Don't mind if I do", + "Keeping things percolating...", +] + def format_elapsed(seconds: int) -> str: """Format seconds as 'Xh Ym Zs' with leading zero segments dropped.""" @@ -49,3 +184,20 @@ def _mode_phrase(mode: Mode, paused: bool) -> str: if paused: return _PAUSED_PHRASE return _MODE_PHRASES[mode] + + +def _pick_quip(elapsed_seconds: int, seed: int | None = None) -> str: + """Return the quip for this elapsed time, or empty during startup. + + The pool is shuffled with `seed` (defaults to the current process id so + each session feels different). The active quip changes every + _QUIP_ROTATION_SECONDS. + """ + if elapsed_seconds < _STARTUP_QUIET_SECONDS: + return "" + rng = random.Random(seed if seed is not None else os.getpid()) + shuffled = rng.sample(QUIPS, len(QUIPS)) + idx = ((elapsed_seconds - _STARTUP_QUIET_SECONDS) // _QUIP_ROTATION_SECONDS) % len( + shuffled + ) + return shuffled[idx] diff --git a/tests/test_animations.py b/tests/test_animations.py index 7edaee2..37fd712 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -4,7 +4,13 @@ import pytest -from digital_caffeine.animations import _format_duration, _mode_phrase, format_elapsed +from digital_caffeine.animations import ( + QUIPS, + _format_duration, + _mode_phrase, + _pick_quip, + format_elapsed, +) from digital_caffeine.constants import Mode @@ -65,3 +71,32 @@ def test_mode_phrase_paused_overrides_mode() -> None: assert _mode_phrase(Mode.DISPLAY_AND_SYSTEM, paused=True) == "paused" assert _mode_phrase(Mode.DISPLAY_ONLY, paused=True) == "paused" assert _mode_phrase(Mode.SYSTEM_ONLY, paused=True) == "paused" + + +def test_quips_pool_has_at_least_one_hundred() -> None: + assert len(QUIPS) >= 100 + + +def test_pick_quip_is_empty_during_startup_window() -> None: + for elapsed in range(5): + assert _pick_quip(elapsed_seconds=elapsed, seed=42) == "" + + +def test_pick_quip_returns_a_pool_member_after_startup() -> None: + quip = _pick_quip(elapsed_seconds=5, seed=42) + assert quip in QUIPS + + +def test_pick_quip_changes_after_rotation_interval() -> None: + # Rotation is 90 seconds. Two elapsed values that straddle the boundary + # should (with overwhelming probability) yield different quips. Using + # a fixed seed makes this deterministic. + a = _pick_quip(elapsed_seconds=5, seed=42) + b = _pick_quip(elapsed_seconds=5 + 90, seed=42) + assert a != b + + +def test_pick_quip_same_within_rotation_window() -> None: + a = _pick_quip(elapsed_seconds=5, seed=42) + b = _pick_quip(elapsed_seconds=5 + 89, seed=42) + assert a == b From f70f746419d9228601d2bc71928f02b61ca59e8e Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:50:24 -0500 Subject: [PATCH 07/15] feat(animations): add _build_status_text with narrow reflow and NO_COLOR --- src/digital_caffeine/animations.py | 48 +++++++++++++ tests/test_animations.py | 108 +++++++++++++++++++++++++++++ 2 files changed, 156 insertions(+) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 06f7d6a..4c3d53a 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -10,12 +10,17 @@ import os import random +from rich.text import Text + from digital_caffeine.constants import Mode FPS = 2 _QUIP_ROTATION_SECONDS = 90 _STARTUP_QUIET_SECONDS = 5 +_NARROW_TERMINAL_COLS = 50 +_ACCENT_STYLE = "cyan" +_DIM_STYLE = "dim" _MODE_PHRASES: dict[Mode, str] = { Mode.DISPLAY_ONLY: "keeping display awake", @@ -186,6 +191,49 @@ def _mode_phrase(mode: Mode, paused: bool) -> str: return _MODE_PHRASES[mode] +def _build_status_text( + *, + spinner_frame: str, + mode: Mode, + elapsed_seconds: int, + duration_seconds: int | None, + paused: bool, + width: int, + show_quit_hint: bool, + use_color: bool, +) -> Text: + """Build the single-line status Text for a given snapshot of state. + + When `width` is below the narrow threshold, drops the quit hint and the + duration-remaining suffix. When `use_color` is False, no styles are applied + (NO_COLOR env). + """ + narrow = width < _NARROW_TERMINAL_COLS + phrase = _mode_phrase(mode, paused) + elapsed_str = format_elapsed(elapsed_seconds) + + accent = _ACCENT_STYLE if use_color else None + dim = _DIM_STYLE if use_color else None + + text = Text() + text.append(f"{spinner_frame} ") + text.append("caffeine", style=accent) + text.append(f" \u00b7 {phrase} \u00b7 {elapsed_str}") + + if duration_seconds is not None and not narrow: + remaining = max(0, duration_seconds - elapsed_seconds) + suffix = ( + f" \u00b7 {_format_duration(remaining)} / " + f"{_format_duration(duration_seconds)} left" + ) + text.append(suffix, style=dim) + + if show_quit_hint and not narrow: + text.append(" \u00b7 q to quit", style=dim) + + return text + + def _pick_quip(elapsed_seconds: int, seed: int | None = None) -> str: """Return the quip for this elapsed time, or empty during startup. diff --git a/tests/test_animations.py b/tests/test_animations.py index 37fd712..c6b0aa3 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -2,10 +2,14 @@ from __future__ import annotations +from io import StringIO + import pytest +from rich.console import Console from digital_caffeine.animations import ( QUIPS, + _build_status_text, _format_duration, _mode_phrase, _pick_quip, @@ -100,3 +104,107 @@ def test_pick_quip_same_within_rotation_window() -> None: a = _pick_quip(elapsed_seconds=5, seed=42) b = _pick_quip(elapsed_seconds=5 + 89, seed=42) assert a == b + + +def _render(text, width: int = 100) -> str: + buf = StringIO() + console = Console(file=buf, force_terminal=True, width=width, no_color=False) + console.print(text) + return buf.getvalue() + + +def test_build_status_text_includes_mode_phrase_and_elapsed() -> None: + text = _build_status_text( + spinner_frame="\u280b", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=65, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text) + assert "caffeine" in rendered + assert "keeping display awake" in rendered + assert "1m 5s" in rendered + + +def test_build_status_text_duration_suffix_when_duration_set() -> None: + text = _build_status_text( + spinner_frame="\u280b", + mode=Mode.DISPLAY_AND_SYSTEM, + elapsed_seconds=60 * 38, + duration_seconds=60 * 120, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text) + assert "1h 22m / 2h 0m left" in rendered + + +def test_build_status_text_quit_hint_when_requested() -> None: + text = _build_status_text( + spinner_frame="\u280b", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=True, + ) + assert "q to quit" in _render(text) + + +def test_build_status_text_narrow_terminal_drops_suffixes() -> None: + text = _build_status_text( + spinner_frame="\u280b", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=3600, + paused=False, + width=40, + show_quit_hint=True, + use_color=True, + ) + rendered = _render(text, width=40) + assert "q to quit" not in rendered + assert "left" not in rendered + # But mode phrase and elapsed are still there + assert "keeping display awake" in rendered + assert "30s" in rendered + + +def test_build_status_text_no_color_omits_ansi_codes() -> None: + text = _build_status_text( + spinner_frame="\u280b", + mode=Mode.DISPLAY_ONLY, + elapsed_seconds=30, + duration_seconds=None, + paused=False, + width=100, + show_quit_hint=True, + use_color=False, + ) + buf = StringIO() + # no_color=False on the console itself so it WOULD emit ANSI if the Text + # carried styles; we're asserting the Text has no styles to emit. + Console(file=buf, force_terminal=True, width=100, no_color=False).print(text) + assert "\x1b[" not in buf.getvalue() + + +def test_build_status_text_paused_uses_paused_phrase() -> None: + text = _build_status_text( + spinner_frame="\u2022", # static frame for paused state + mode=Mode.DISPLAY_AND_SYSTEM, + elapsed_seconds=30, + duration_seconds=None, + paused=True, + width=100, + show_quit_hint=True, + use_color=True, + ) + assert "paused" in _render(text) From 33c185f45aea7d3a45c374330a07fc4d20aea63a Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 07:55:26 -0500 Subject: [PATCH 08/15] feat(animations): implement non-TTY fallback for run_display --- src/digital_caffeine/animations.py | 32 ++++++++++++++++++++++ tests/test_animations.py | 43 ++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 4c3d53a..50ed350 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -9,7 +9,10 @@ import os import random +import sys +import time +from rich.console import Console from rich.text import Text from digital_caffeine.constants import Mode @@ -249,3 +252,32 @@ def _pick_quip(elapsed_seconds: int, seed: int | None = None) -> str: shuffled ) return shuffled[idx] + + +def run_display( + engine, + mode: Mode, + duration_seconds: int | None, + *, + console: Console | None = None, +) -> None: + """Blocking display loop. Returns when the engine stops or user quits. + + - TTY: Rich Live redraw at FPS, reflows to terminal width. + - Non-TTY (piped/redirected): prints one status line, then sleeps until + the engine stops or Ctrl+C. + """ + console = console or Console() + + if not sys.stdout.isatty(): + phrase = _MODE_PHRASES[mode] + console.print(f"caffeine: {phrase} (press Ctrl+C to stop)") + try: + while engine.is_active: + time.sleep(1) + except KeyboardInterrupt: + pass + return + + # TTY path implemented in Task 7. + raise NotImplementedError("TTY path not yet implemented") diff --git a/tests/test_animations.py b/tests/test_animations.py index c6b0aa3..18e56df 100644 --- a/tests/test_animations.py +++ b/tests/test_animations.py @@ -14,6 +14,7 @@ _mode_phrase, _pick_quip, format_elapsed, + run_display, ) from digital_caffeine.constants import Mode @@ -208,3 +209,45 @@ def test_build_status_text_paused_uses_paused_phrase() -> None: use_color=True, ) assert "paused" in _render(text) + + +class _FakeEngine: + """Minimal stand-in for CaffeineEngine in tests.""" + + def __init__(self, *, paused: bool = False, stop_after: float | None = None) -> None: + self._paused = paused + self._active = True + self._stop_after = stop_after + + @property + def is_paused(self) -> bool: + return self._paused + + @property + def is_active(self) -> bool: + return self._active + + def stop_now(self) -> None: + self._active = False + + +def test_run_display_non_tty_prints_one_line_and_waits(monkeypatch) -> None: + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + buf = StringIO() + console = Console(file=buf, force_terminal=False, width=100) + + engine = _FakeEngine() + + # Stop the engine after a short delay on a background thread. + import threading + threading.Timer(0.1, engine.stop_now).start() + + run_display(engine=engine, mode=Mode.DISPLAY_ONLY, duration_seconds=None, + console=console) + + output = buf.getvalue() + assert "caffeine: keeping display awake" in output + assert "Ctrl+C" in output + # One-line fallback, not a multiline live block + assert output.count("\n") <= 2 From 2a6b29924dc6a39f7140eca4824e65346040e5db Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:01:47 -0500 Subject: [PATCH 09/15] feat(animations): implement TTY redraw loop with spinner and q-to-quit --- src/digital_caffeine/animations.py | 82 +++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 50ed350..3a40509 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -13,6 +13,7 @@ import time from rich.console import Console +from rich.live import Live from rich.text import Text from digital_caffeine.constants import Mode @@ -279,5 +280,82 @@ def run_display( pass return - # TTY path implemented in Task 7. - raise NotImplementedError("TTY path not yet implemented") + # TTY path + use_color = os.environ.get("NO_COLOR") is None + spinner_frames = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", + "\u2826", "\u2827", "\u2807", "\u280F"] + paused_frame = "\u2022" + + start = time.monotonic() + spinner_idx = 0 + + # Drain any keystrokes the user made before run_display started (e.g. + # Enter after 'caffeine start'). Prevents stale 'q' from quitting instantly. + try: + import msvcrt + while msvcrt.kbhit(): + msvcrt.getch() + except ImportError: + pass + + try: + with Live(console=console, refresh_per_second=FPS, transient=False) as live: + while engine.is_active: + elapsed = int(time.monotonic() - start) + if duration_seconds is not None and elapsed >= duration_seconds: + break + + paused = engine.is_paused + frame = paused_frame if paused else spinner_frames[ + spinner_idx % len(spinner_frames) + ] + width = console.size.width + + status = _build_status_text( + spinner_frame=frame, + mode=mode, + elapsed_seconds=elapsed, + duration_seconds=duration_seconds, + paused=paused, + width=width, + show_quit_hint=True, + use_color=use_color, + ) + + quip = _pick_quip(elapsed) + quip_text = Text() + if quip: + quip_text.append(f" {quip}", + style=_DIM_STYLE if use_color else None) + + block = Text() + block.append_text(status) + # Reserve the quip line (even when empty) so layout height + # is stable and the first quip at t=5s doesn't bump the view. + block.append("\n\n") + block.append_text(quip_text) + + live.update(block) + + if _q_pressed(): + break + + spinner_idx += 1 + time.sleep(1 / FPS) + except KeyboardInterrupt: + pass + + +def _q_pressed() -> bool: + """Return True if 'q' or 'Q' was pressed since the last check. + + Uses msvcrt (Windows stdlib). On non-Windows platforms this returns False. + """ + try: + import msvcrt + except ImportError: + return False + if msvcrt.kbhit(): + ch = msvcrt.getch() + return ch in (b"q", b"Q") + return False From 87b3f06c20855507b261e7aa6a56d284e77279f4 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:08:11 -0500 Subject: [PATCH 10/15] refactor(cli): replace PC-98/Rich dispatch with run_display --- src/digital_caffeine/cli.py | 115 ++---------------------------------- 1 file changed, 5 insertions(+), 110 deletions(-) diff --git a/src/digital_caffeine/cli.py b/src/digital_caffeine/cli.py index e61ce5e..18ca23a 100644 --- a/src/digital_caffeine/cli.py +++ b/src/digital_caffeine/cli.py @@ -8,11 +8,9 @@ import click from rich.console import Console -from rich.live import Live -from rich.panel import Panel from digital_caffeine import __version__ -from digital_caffeine.animations import FPS, MODE_DISPLAY, build_animated_display, format_time +from digital_caffeine.animations import format_elapsed, run_display from digital_caffeine.config import get_config_path, load_config from digital_caffeine.constants import Mode @@ -67,77 +65,6 @@ def parse_duration(s: str) -> int: -def build_display( - *, - frame: int = 0, - mode: Mode, - uptime_seconds: int, - duration_seconds: int | None, - interval: int, - paused: bool, - simulate: bool, -) -> Panel: - """Build a Rich Panel showing the current keep-awake status with animations. - - Delegates to the animations module for coffee-themed display. - """ - return build_animated_display( - frame=frame, - mode=mode, - uptime_seconds=uptime_seconds, - duration_seconds=duration_seconds, - interval=interval, - paused=paused, - simulate=simulate, - ) - - -def _can_use_pc98() -> bool: - """Check if the terminal supports the PC-98 Textual display.""" - try: - from digital_caffeine.pc98 import PC98App # noqa: F401 - return True - except ImportError: - return False - - -def _run_rich_display( - *, - engine: object, - mode: Mode, - duration_seconds: int | None, - interval: int, - simulate: bool, - start_time: float, -) -> None: - """Run the original Rich Live display as a fallback.""" - console.print( - f"[green]Digital Caffeine started[/green] - mode={MODE_DISPLAY[mode]}, interval={interval}s" - ) - frame = 0 - with Live( - build_display( - frame=0, mode=mode, uptime_seconds=0, - duration_seconds=duration_seconds, interval=interval, - paused=False, simulate=simulate, - ), - console=console, refresh_per_second=FPS, transient=False, - ) as live: - while True: - elapsed = int(time.monotonic() - start_time) - if duration_seconds is not None and elapsed >= duration_seconds: - break - live.update( - build_display( - frame=frame, mode=mode, uptime_seconds=elapsed, - duration_seconds=duration_seconds, interval=interval, - paused=False, simulate=simulate, - ) - ) - frame += 1 - time.sleep(1 / FPS) - - @click.group() def cli() -> None: """Digital Caffeine - Keep your Windows machine awake. @@ -239,47 +166,15 @@ def start( start_time = time.monotonic() try: - if _can_use_pc98(): - try: - from digital_caffeine.pc98 import PC98App - - app = PC98App( - engine=engine, - mode=mode, - duration_seconds=duration_seconds, - interval=interval, - simulate=simulate, - ) - app.run() - except Exception as exc: - # Textual failed - show error and fall back to Rich display - console.print(f"[yellow]PC-98 display failed: {exc}[/yellow]") - console.print("[dim]Falling back to classic display...[/dim]") - _run_rich_display( - engine=engine, mode=mode, - duration_seconds=duration_seconds, - interval=interval, simulate=simulate, - start_time=start_time, - ) - else: - _run_rich_display( - engine=engine, mode=mode, - duration_seconds=duration_seconds, - interval=interval, simulate=simulate, - start_time=start_time, - ) + run_display(engine=engine, mode=mode, duration_seconds=duration_seconds) except KeyboardInterrupt: pass finally: engine.stop() total_uptime = int(time.monotonic() - start_time) - console.print() - console.print("[bold cyan]Session Summary[/bold cyan]") - console.print(f" Total uptime: {format_time(total_uptime)}") - console.print(f" Mode: {MODE_DISPLAY[mode]}") - if simulate: - console.print(" Simulate: On") - console.print("[green]Digital Caffeine stopped. Sweet dreams![/green]") + console.print( + f"caffeine stopped \u00b7 kept awake for {format_elapsed(total_uptime)}" + ) @cli.command() From 941f0aedd166e3e16947175f6d6eca780297b9ff Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:12:15 -0500 Subject: [PATCH 11/15] test(cli): drop tests for removed build_display and format_time --- tests/test_cli.py | 42 +----------------------------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 2ecca48..bb29693 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,17 +2,13 @@ from __future__ import annotations -from io import StringIO from unittest.mock import patch import click import pytest from click.testing import CliRunner -from rich.console import Console -from rich.panel import Panel -from digital_caffeine.cli import build_display, cli, format_time, parse_duration -from digital_caffeine.constants import Mode +from digital_caffeine.cli import cli, parse_duration # -- parse_duration tests -- @@ -50,21 +46,6 @@ def test_parse_duration_zero_raises() -> None: parse_duration("0s") -# -- format_time tests -- - - -def test_format_time_zero() -> None: - assert format_time(0) == "00:00:00" - - -def test_format_time_minutes_seconds() -> None: - assert format_time(61) == "00:01:01" - - -def test_format_time_hours() -> None: - assert format_time(3661) == "01:01:01" - - # -- CLI command tests -- @@ -99,24 +80,3 @@ def test_start_help() -> None: result = runner.invoke(cli, ["start", "--help"]) assert result.exit_code == 0 assert "Start keeping your machine awake" in result.output - - -def test_build_display_returns_animated_panel() -> None: - """build_display should delegate to the animated display builder.""" - panel = build_display( - mode=Mode.DISPLAY_AND_SYSTEM, - uptime_seconds=0, - duration_seconds=None, - interval=60, - paused=False, - simulate=False, - ) - assert isinstance(panel, Panel) - - # Verify it has animated content by checking for coffee cup character - buf = StringIO() - console = Console(file=buf, force_terminal=True, width=80) - console.print(panel) - output = buf.getvalue() - # The cup should contain box-drawing characters from the animated display - assert "\u2502" in output # vertical box line from cup art From fbe4ef9e6e89eddbc412cbbe369798bc9a8c327c Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:14:14 -0500 Subject: [PATCH 12/15] refactor: delete pc98 visual novel package and tests --- src/digital_caffeine/pc98/__init__.py | 5 - src/digital_caffeine/pc98/app.py | 246 ------------------ src/digital_caffeine/pc98/canvas.py | 99 -------- src/digital_caffeine/pc98/dialogue.py | 46 ---- src/digital_caffeine/pc98/palette.py | 127 ---------- src/digital_caffeine/pc98/particles.py | 157 ------------ src/digital_caffeine/pc98/scene.py | 122 --------- src/digital_caffeine/pc98/sprites.py | 329 ------------------------- src/digital_caffeine/pc98/status.py | 99 -------- tests/test_pc98_canvas.py | 90 ------- tests/test_pc98_palette.py | 109 -------- tests/test_pc98_particles.py | 83 ------- tests/test_pc98_scene.py | 49 ---- tests/test_pc98_sprites.py | 118 --------- tests/test_pc98_widgets.py | 79 ------ 15 files changed, 1758 deletions(-) delete mode 100644 src/digital_caffeine/pc98/__init__.py delete mode 100644 src/digital_caffeine/pc98/app.py delete mode 100644 src/digital_caffeine/pc98/canvas.py delete mode 100644 src/digital_caffeine/pc98/dialogue.py delete mode 100644 src/digital_caffeine/pc98/palette.py delete mode 100644 src/digital_caffeine/pc98/particles.py delete mode 100644 src/digital_caffeine/pc98/scene.py delete mode 100644 src/digital_caffeine/pc98/sprites.py delete mode 100644 src/digital_caffeine/pc98/status.py delete mode 100644 tests/test_pc98_canvas.py delete mode 100644 tests/test_pc98_palette.py delete mode 100644 tests/test_pc98_particles.py delete mode 100644 tests/test_pc98_scene.py delete mode 100644 tests/test_pc98_sprites.py delete mode 100644 tests/test_pc98_widgets.py diff --git a/src/digital_caffeine/pc98/__init__.py b/src/digital_caffeine/pc98/__init__.py deleted file mode 100644 index eebb893..0000000 --- a/src/digital_caffeine/pc98/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -"""PC-98 style visual novel display for Digital Caffeine.""" - -from digital_caffeine.pc98.app import PC98App - -__all__ = ["PC98App"] diff --git a/src/digital_caffeine/pc98/app.py b/src/digital_caffeine/pc98/app.py deleted file mode 100644 index b25ac55..0000000 --- a/src/digital_caffeine/pc98/app.py +++ /dev/null @@ -1,246 +0,0 @@ -"""PC-98 Textual application - main display driver.""" - -from __future__ import annotations - -import time - -from textual.app import App, ComposeResult -from textual.binding import Binding -from textual.containers import Horizontal -from textual.widgets import Static - -from digital_caffeine.animations import MODE_DISPLAY, format_time -from digital_caffeine.constants import Mode -from digital_caffeine.engine import CaffeineEngine -from digital_caffeine.pc98.dialogue import format_dialogue_box -from digital_caffeine.pc98.palette import ( - DEEP_NAVY, - GOLD, - MAGENTA, - OFF_WHITE, - PALETTE, - STEEL_BLUE, -) -from digital_caffeine.pc98.scene import CoffeeScene -from digital_caffeine.pc98.status import format_status_text - -_FPS = 24 - -# Box drawing for title bar -_H = "\u2550" -_DIAMOND = "\u25c6" - - -class SceneWidget(Static): - """Displays the Pillow-rendered pixel art scene as half-block characters.""" - - DEFAULT_CSS = """ - SceneWidget { - width: auto; - height: auto; - padding: 0 1; - } - """ - - -class StatusWidget(Static): - """Displays the right-side status panel.""" - - DEFAULT_CSS = """ - StatusWidget { - width: 30; - height: 100%; - padding: 0 0; - } - """ - - -class DialogueWidget(Static): - """Displays the bottom VN dialogue box.""" - - DEFAULT_CSS = """ - DialogueWidget { - width: 100%; - height: 4; - padding: 0 1; - } - """ - - -class TitleWidget(Static): - """Startup title card shown briefly on launch.""" - - DEFAULT_CSS = f""" - TitleWidget {{ - width: 100%; - height: 100%; - background: {PALETTE[DEEP_NAVY]}; - color: {PALETTE[GOLD]}; - content-align: center middle; - text-align: center; - }} - """ - - -class PC98App(App): - """PC-98 visual novel style keep-awake display.""" - - TITLE = "Digital Caffeine" - CSS = f""" - Screen {{ - background: {PALETTE[DEEP_NAVY]}; - }} - #title-bar {{ - width: 100%; - height: 1; - background: {PALETTE[STEEL_BLUE]}; - color: {PALETTE[GOLD]}; - padding: 0 1; - }} - #main-area {{ - width: 100%; - height: 1fr; - }} - #scene-area {{ - width: 1fr; - height: 100%; - }} - #status-area {{ - width: 30; - height: 100%; - border-left: solid {PALETTE[STEEL_BLUE]}; - }} - #dialogue-area {{ - width: 100%; - height: 4; - border-top: solid {PALETTE[STEEL_BLUE]}; - padding: 0 1; - }} - #title-screen {{ - width: 100%; - height: 100%; - background: {PALETTE[DEEP_NAVY]}; - color: {PALETTE[GOLD]}; - content-align: center middle; - text-align: center; - }} - """ - - BINDINGS = [ - Binding("q", "quit", "Quit", show=False), - Binding("space", "toggle_pause", "Pause/Resume", show=False), - Binding("ctrl+c", "quit", "Exit"), - ] - - def __init__( - self, - engine: CaffeineEngine, - mode: Mode, - duration_seconds: int | None, - interval: int, - simulate: bool, - ) -> None: - super().__init__() - self._engine = engine - self._mode = mode - self._duration_seconds = duration_seconds - self._interval = interval - self._simulate = simulate - self._scene = CoffeeScene() - self._frame = 0 - self._start_time = time.monotonic() - self._title_done = False - - def compose(self) -> ComposeResult: - gold = PALETTE[GOLD] - blue = PALETTE[STEEL_BLUE] - mag = PALETTE[MAGENTA] - white = PALETTE[OFF_WHITE] - - # Title card with PC-98 drama - title_text = ( - f"\n\n\n" - f"[{blue}]{_H * 20}[/]\n" - f"\n" - f"[bold {gold}]{_DIAMOND} DIGITAL CAFFEINE {_DIAMOND}[/]\n" - f"\n" - f"[{blue}]{_H * 20}[/]\n" - f"\n" - f"[{mag}]PC-98 ver.[/] [{white} dim]press Q to quit, SPACE to pause[/]" - ) - yield TitleWidget(title_text, id="title-screen") - - # Title bar - bar_text = ( - f" [{gold}]{_DIAMOND} Digital Caffeine[/]" - f"{'':>30}" - f"[dim]Q=quit SPACE=pause[/]" - ) - yield Static(bar_text, id="title-bar") - - with Horizontal(id="main-area"): - yield SceneWidget("", id="scene-area") - yield StatusWidget("", id="status-area") - yield DialogueWidget("", id="dialogue-area") - - def on_mount(self) -> None: - self.query_one("#title-bar").display = False - self.query_one("#main-area").display = False - self.query_one("#dialogue-area").display = False - self.set_timer(2.0, self.dismiss_title) - - def dismiss_title(self) -> None: - """Hide title card and show main UI.""" - self.query_one("#title-screen").display = False - self.query_one("#title-bar").display = True - self.query_one("#main-area").display = True - self.query_one("#dialogue-area").display = True - self._title_done = True - self.set_interval(1.0 / _FPS, self.animate_frame) - - def animate_frame(self) -> None: - self._frame += 1 - elapsed = int(time.monotonic() - self._start_time) - paused = self._engine.is_paused - - if self._duration_seconds is not None and elapsed >= self._duration_seconds: - self._engine.stop() - self.exit() - return - - if not paused: - self._scene.update() - - scene_text = self._scene.render() - self.query_one("#scene-area", Static).update(scene_text) - - if self._duration_seconds is not None: - remaining = max(0, self._duration_seconds - elapsed) - remaining_str = format_time(remaining) - progress_pct = min(100, int(elapsed / self._duration_seconds * 100)) - else: - remaining_str = "Indefinite" - progress_pct = None - - status_text = format_status_text( - active=self._engine.is_active, - paused=paused, - mode_label=MODE_DISPLAY.get(self._mode, str(self._mode)), - uptime_seconds=elapsed, - remaining_str=remaining_str, - interval=self._interval, - simulate=self._simulate, - frame=self._frame, - progress_pct=progress_pct, - ) - self.query_one("#status-area", Static).update(status_text) - - dialogue_text = format_dialogue_box(self._frame, paused=paused) - self.query_one("#dialogue-area", Static).update(dialogue_text) - - def action_toggle_pause(self) -> None: - self._engine.toggle_pause() - - def action_quit(self) -> None: - self._engine.stop() - self.exit() diff --git a/src/digital_caffeine/pc98/canvas.py b/src/digital_caffeine/pc98/canvas.py deleted file mode 100644 index 05c7c42..0000000 --- a/src/digital_caffeine/pc98/canvas.py +++ /dev/null @@ -1,99 +0,0 @@ -"""Half-block pixel canvas: renders a Pillow palette Image as Rich Text.""" - -from __future__ import annotations - -from PIL import Image -from rich.style import Style -from rich.text import Text - -from digital_caffeine.pc98.palette import PALETTE_RGB, CyclePalette - -_UPPER_HALF = "\u2580" -_FULL_BLOCK = "\u2588" -_SPACE = " " - -_SCANLINE_FACTOR = 0.92 - - -class PixelCanvas: - """A pixel framebuffer that renders to Rich Text via half-block characters. - - Each terminal cell represents 2 vertical pixels. The image uses Pillow's - palette mode ('P') where each pixel stores a 0-15 palette index. - """ - - def __init__( - self, - width: int, - height: int, - fill: int = 0, - scanlines: bool = False, - ) -> None: - self.width = width - self.height = height - self._fill = fill - self.scanlines = scanlines - self.image = self._create_image() - - def _create_image(self) -> Image.Image: - img = Image.new("P", (self.width, self.height), self._fill) - pal = [0] * 768 - for i, (r, g, b) in enumerate(PALETTE_RGB): - pal[i * 3] = r - pal[i * 3 + 1] = g - pal[i * 3 + 2] = b - img.putpalette(pal) - return img - - def clear(self) -> None: - """Reset all pixels to the fill color.""" - self.image = self._create_image() - - def render(self, cycle: CyclePalette) -> Text: - """Convert the pixel buffer to a Rich Text using half-block characters. - - Processes pixel rows in pairs (top, bottom). Each pair becomes one - terminal row. Uses CyclePalette for current color mapping. - """ - px = self.image.load() - text = Text() - rows = (self.height + 1) // 2 - - for row in range(rows): - y_top = row * 2 - y_bot = row * 2 + 1 - has_bot = y_bot < self.height - - for x in range(self.width): - top_idx = px[x, y_top] - bot_idx = px[x, y_bot] if has_bot else top_idx - - if self.scanlines: - top_rgb = ( - cycle.get_rgb_darkened(top_idx, _SCANLINE_FACTOR) - if y_top % 2 == 1 - else cycle.get_rgb(top_idx) - ) - bot_rgb = ( - cycle.get_rgb_darkened(bot_idx, _SCANLINE_FACTOR) - if y_bot % 2 == 1 - else cycle.get_rgb(bot_idx) - ) - else: - top_rgb = cycle.get_rgb(top_idx) - bot_rgb = cycle.get_rgb(bot_idx) - - top_hex = f"#{top_rgb[0]:02x}{top_rgb[1]:02x}{top_rgb[2]:02x}" - bot_hex = f"#{bot_rgb[0]:02x}{bot_rgb[1]:02x}{bot_rgb[2]:02x}" - - if top_hex == bot_hex: - style = Style(bgcolor=top_hex) - text.append(_SPACE, style=style) - else: - style = Style(color=top_hex, bgcolor=bot_hex) - text.append(_UPPER_HALF, style=style) - - if row < rows - 1: - text.append("\n") - - return text diff --git a/src/digital_caffeine/pc98/dialogue.py b/src/digital_caffeine/pc98/dialogue.py deleted file mode 100644 index 3702ae1..0000000 --- a/src/digital_caffeine/pc98/dialogue.py +++ /dev/null @@ -1,46 +0,0 @@ -"""PC-98 visual novel dialogue box with typewriter effect.""" - -from __future__ import annotations - -from digital_caffeine.animations import PAUSED_QUIP, QUIPS -from digital_caffeine.pc98.palette import GOLD, OFF_WHITE, PALETTE, STEEL_BLUE - -FPS = 24 -_QUIP_INTERVAL = 12 - -_H = "\u2550" # double horizontal -_V = "\u2551" # double vertical -_TL = "\u2554" # double top-left -_TR = "\u2557" # double top-right -_BL = "\u255a" # double bottom-left -_BR = "\u255d" # double bottom-right - - -def typewriter_text(quip: str, frame_in_quip: int) -> str: - chars_to_show = min(len(quip), (frame_in_quip // 3) + 1) - if chars_to_show < len(quip): - cursor = "\u2588" if (frame_in_quip % 18) < 9 else " " - return quip[:chars_to_show] + cursor - return quip - - -def get_current_quip(frame: int, *, paused: bool) -> str: - if paused: - return PAUSED_QUIP - frames_per_quip = _QUIP_INTERVAL * FPS - quip_idx = (frame // frames_per_quip) % len(QUIPS) - quip = QUIPS[quip_idx] - frame_in_quip = frame % frames_per_quip - return typewriter_text(quip, frame_in_quip) - - -def format_dialogue_box(frame: int, *, paused: bool) -> str: - """Build the dialogue box with PC-98 double-line border.""" - gold = PALETTE[GOLD] - blue = PALETTE[STEEL_BLUE] - white = PALETTE[OFF_WHITE] - - quip = get_current_quip(frame, paused=paused) - label = f"[{gold}]CAFFEINE[/]" - - return f" [{blue}]{_V}[/] {label} [{blue}]{_V}[/] [{white}]{quip}[/]" diff --git a/src/digital_caffeine/pc98/palette.py b/src/digital_caffeine/pc98/palette.py deleted file mode 100644 index 2bc627f..0000000 --- a/src/digital_caffeine/pc98/palette.py +++ /dev/null @@ -1,127 +0,0 @@ -"""PC-98 16-color palette with cycling and ordered dithering.""" - -from __future__ import annotations - -# -- 16-color warm PC-98 palette --------------------------------------------- - -PALETTE: list[str] = [ - "#000000", # 0 Black - "#1A0A2E", # 1 Deep Navy - "#3A1A06", # 2 Dark Brown - "#8B4513", # 3 Warm Brown - "#B8520A", # 4 Chocolate - "#D2691E", # 5 Amber - "#FFB347", # 6 Gold - "#FFDEAD", # 7 Cream - "#8B0000", # 8 Deep Red - "#AA3377", # 9 Magenta - "#CC8899", # 10 Dusty Rose - "#4477AA", # 11 Steel Blue - "#556677", # 12 Slate - "#887766", # 13 Warm Gray - "#CCBBAA", # 14 Light Gray - "#EEDDCC", # 15 Off-White -] - -PALETTE_RGB: list[tuple[int, int, int]] = [ - (int(c[1:3], 16), int(c[3:5], 16), int(c[5:7], 16)) for c in PALETTE -] - -# Palette index aliases for readability in sprite code -BLACK = 0 -DEEP_NAVY = 1 -DARK_BROWN = 2 -WARM_BROWN = 3 -CHOCOLATE = 4 -AMBER = 5 -GOLD = 6 -CREAM = 7 -DEEP_RED = 8 -MAGENTA = 9 -DUSTY_ROSE = 10 -STEEL_BLUE = 11 -SLATE = 12 -WARM_GRAY = 13 -LIGHT_GRAY = 14 -OFF_WHITE = 15 - -# -- 2x2 Bayer ordered dither matrix ----------------------------------------- - -_BAYER_2X2 = [ - [0, 2], - [3, 1], -] - - -def dither_pick(color_a: int, color_b: int, x: int, y: int) -> int: - """Pick between two palette indices using 2x2 ordered dithering. - - Returns color_a or color_b based on pixel position in the Bayer matrix. - Produces a ~50/50 checkerboard blend at the 2x2 scale. - """ - if color_a == color_b: - return color_a - threshold = _BAYER_2X2[y % 2][x % 2] - return color_a if threshold < 2 else color_b - - -# -- Palette cycling ---------------------------------------------------------- - -# Cycling groups: tuples of palette indices that rotate together -# Only cycle colors NOT used in large static areas (background, table, cup walls) -_COFFEE_CYCLE = (5, 6, 7) # Amber, Gold, Cream - liquid shimmer - -_COFFEE_RATE = 4 # Rotate every N frames - - -class CyclePalette: - """Manages palette cycling for animation. - - Maintains frame counter and rotates cycling groups at their - configured rates. Non-cycling indices always return base palette. - """ - - def __init__(self) -> None: - self._frame = 0 - self._coffee_offset = 0 - - def advance(self) -> None: - """Advance one frame. Rotates cycling groups at their rates.""" - self._frame += 1 - if self._frame % _COFFEE_RATE == 0: - self._coffee_offset = (self._coffee_offset + 1) % len(_COFFEE_CYCLE) - - def get_rgb(self, index: int) -> tuple[int, int, int]: - """Return the current RGB for a palette index, accounting for cycling.""" - mapped = self._map_index(index) - return PALETTE_RGB[mapped] - - def get_hex(self, index: int) -> str: - """Return the current hex color for a palette index.""" - mapped = self._map_index(index) - return PALETTE[mapped] - - def get_rgb_darkened(self, index: int, factor: float) -> tuple[int, int, int]: - """Return darkened RGB for scanline effect.""" - r, g, b = self.get_rgb(index) - return (int(r * factor), int(g * factor), int(b * factor)) - - def build_pillow_palette(self) -> list[int]: - """Build a flat RGB palette list for Pillow Image.putpalette(). - - Returns a 768-element list (256 entries x 3 channels). - """ - pal = [0] * 768 - for i in range(16): - r, g, b = self.get_rgb(i) - pal[i * 3] = r - pal[i * 3 + 1] = g - pal[i * 3 + 2] = b - return pal - - def _map_index(self, index: int) -> int: - """Map a palette index through any active cycling.""" - if index in _COFFEE_CYCLE: - pos = _COFFEE_CYCLE.index(index) - return _COFFEE_CYCLE[(pos + self._coffee_offset) % len(_COFFEE_CYCLE)] - return index diff --git a/src/digital_caffeine/pc98/particles.py b/src/digital_caffeine/pc98/particles.py deleted file mode 100644 index 9c70f0b..0000000 --- a/src/digital_caffeine/pc98/particles.py +++ /dev/null @@ -1,157 +0,0 @@ -"""Steam and drip particle systems for PC-98 coffee scene.""" - -from __future__ import annotations - -import math -from dataclasses import dataclass - -from digital_caffeine.pc98.palette import ( - CHOCOLATE, - CREAM, - DEEP_NAVY, - LIGHT_GRAY, - OFF_WHITE, - SLATE, - WARM_BROWN, - WARM_GRAY, -) -from digital_caffeine.pc98.sprites import SCENE_H, SCENE_W - - -@dataclass -class SteamParticle: - """A single rising steam wisp.""" - - x: float - y: float - amplitude: float - frequency: float - speed: float - phase: float - age: int = 0 - - def reset(self, spawn_y: float, x_center: int) -> None: - self.y = spawn_y - self.age = 0 - - -# More nuanced color fade: bright near cup, graceful fade upward -_STEAM_COLORS_BY_AGE = [ - OFF_WHITE, # 0-5: bright white wisp fresh off the cup - CREAM, # 6-12: warm cream - LIGHT_GRAY, # 13-20: cooling - WARM_GRAY, # 21-30: fading - SLATE, # 31-38: almost gone - DEEP_NAVY, # 39+: invisible (matches background) -] - - -class SteamSystem: - """Pool of steam particles that rise from the cup mouth.""" - - def __init__(self, count: int, spawn_y: float, x_center: int) -> None: - self.spawn_y = spawn_y - self.x_center = x_center - self.frame = 0 - self.particles: list[SteamParticle] = [] - self._max_age = 45 - for i in range(count): - p = SteamParticle( - x=x_center + (i % 7 - 3) * 2.5, - y=spawn_y - (i * self._max_age / count), - amplitude=1.2 + (i % 4) * 0.7, - frequency=0.06 + (i % 5) * 0.015, - speed=0.4 + (i % 3) * 0.12, - phase=i * 1.1, - ) - self.particles.append(p) - - def update(self) -> None: - self.frame += 1 - for p in self.particles: - p.y -= p.speed - drift = math.sin(self.frame * p.frequency + p.phase) * p.amplitude - p.x = self.x_center + drift + (p.phase % 7 - 3) * 1.5 - p.age += 1 - if p.age >= self._max_age or p.y < -2: - p.x = self.x_center + (p.phase % 7 - 3) * 2.5 - p.reset(self.spawn_y, self.x_center) - - def get_draw_list(self) -> list[tuple[int, int, int]]: - draws = [] - for p in self.particles: - ix = int(round(p.x)) - iy = int(round(p.y)) - if 0 <= ix < SCENE_W and 0 <= iy < SCENE_H: - ci = self._color_for_age(p.age) - draws.append((ix, iy, ci)) - # Wider wisps when young - if p.age < 25 and 0 <= ix - 1 < SCENE_W: - draws.append((ix - 1, iy, ci)) - if p.age < 15 and 0 <= ix + 1 < SCENE_W: - draws.append((ix + 1, iy, ci)) - # Extra wide when very fresh - if p.age < 6: - if 0 <= ix - 2 < SCENE_W: - draws.append((ix - 2, iy, CREAM)) - if 0 <= ix + 2 < SCENE_W: - draws.append((ix + 2, iy, CREAM)) - return draws - - @staticmethod - def _color_for_age(age: int) -> int: - if age < 6: - return _STEAM_COLORS_BY_AGE[0] - if age < 13: - return _STEAM_COLORS_BY_AGE[1] - if age < 21: - return _STEAM_COLORS_BY_AGE[2] - if age < 31: - return _STEAM_COLORS_BY_AGE[3] - if age < 39: - return _STEAM_COLORS_BY_AGE[4] - return _STEAM_COLORS_BY_AGE[5] - - -@dataclass -class DripParticle: - x: int - y: float - age: int = 0 - active: bool = True - - -class DripSystem: - """Spawns occasional drip particles below the cup.""" - - def __init__(self, spawn_y: int, x_min: int, x_max: int) -> None: - self.spawn_y = spawn_y - self.x_min = x_min - self.x_max = x_max - self.frame = 0 - self._drips: list[DripParticle] = [] - self._spawn_interval = 60 - self._max_age = 10 - - def update(self) -> None: - self.frame += 1 - for d in self._drips: - if d.active: - d.y += 1.0 - d.age += 1 - if d.age >= self._max_age: - d.active = False - self._drips = [d for d in self._drips if d.active] - if self.frame % self._spawn_interval == 0 and len(self._drips) < 2: - if (self.frame // 60) % 5 < 2: # ~40% chance - seed = self.frame * 31 + 17 - x = self.x_min + (seed % (self.x_max - self.x_min)) - self._drips.append(DripParticle(x=x, y=float(self.spawn_y))) - - def get_draw_list(self) -> list[tuple[int, int, int]]: - draws = [] - for d in self._drips: - if d.active: - color = CHOCOLATE if d.age < 5 else WARM_BROWN - draws.append((d.x, int(d.y), color)) - return draws diff --git a/src/digital_caffeine/pc98/scene.py b/src/digital_caffeine/pc98/scene.py deleted file mode 100644 index 46dbc1e..0000000 --- a/src/digital_caffeine/pc98/scene.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Scene compositor: assembles all PC-98 pixel art layers into a renderable frame.""" - -from __future__ import annotations - -from rich.text import Text - -from digital_caffeine.pc98.canvas import PixelCanvas -from digital_caffeine.pc98.palette import AMBER, CREAM, GOLD, OFF_WHITE, CyclePalette -from digital_caffeine.pc98.particles import DripSystem, SteamSystem -from digital_caffeine.pc98.sprites import ( - _CUP_BOTTOM, - _CUP_RIM_LEFT, - _CUP_RIM_RIGHT, - _CUP_TOP, - _INT_LEFT, - _INT_RIGHT, - _PILLAR_W, - SCENE_H, - SCENE_W, - draw_background, - draw_cup, - draw_handle, - draw_ornate_pillars, - draw_saucer, - draw_table, - draw_window, -) - -_SURFACE_Y_TOP = _CUP_TOP + 2 -_SURFACE_Y_BOT = _CUP_TOP + 5 - -# Colors that steam can draw over (anything that's not the cup, table, or pillars) -_STEAM_BG = frozenset({ - 0, # BLACK (background) - 1, # DEEP_NAVY (background) - 8, # DEEP_RED (curtains) - 9, # MAGENTA (curtains) - 11, # STEEL_BLUE (window pane) - 12, # SLATE (ambient highlights) - 13, # WARM_GRAY (window frame) - 14, # LIGHT_GRAY (window frame highlight) -}) - - -class CoffeeScene: - """Manages the full pixel art scene with all animated elements.""" - - def __init__(self) -> None: - self.canvas = PixelCanvas(SCENE_W, SCENE_H, fill=0, scanlines=True) - self.cycle = CyclePalette() - self.frame = 0 - cup_cx = (_CUP_RIM_LEFT + _CUP_RIM_RIGHT) // 2 - self._steam = SteamSystem( - count=10, - spawn_y=float(_CUP_TOP - 1), - x_center=cup_cx, - ) - self._drips = DripSystem( - spawn_y=_CUP_BOTTOM + 3, - x_min=_INT_LEFT, - x_max=_INT_RIGHT, - ) - self._draw_static() - - def _draw_static(self) -> None: - draw_background(self.canvas.image) - draw_window(self.canvas.image) - draw_table(self.canvas.image) - draw_cup(self.canvas.image) - draw_handle(self.canvas.image) - draw_saucer(self.canvas.image) - draw_ornate_pillars(self.canvas.image) - self._static = self.canvas.image.copy() - - def update(self) -> None: - self.frame += 1 - self.cycle.advance() - self._steam.update() - self._drips.update() - - self.canvas.image.paste(self._static) - px = self.canvas.image.load() - - # Animate coffee surface - clean ripple - surface_colors = [AMBER, GOLD, CREAM] - for y in range(_SURFACE_Y_TOP, _SURFACE_Y_BOT): - # Compute tapered width for this row - cup_h = _CUP_BOTTOM - _CUP_TOP - t = (y - _CUP_TOP) / max(1, cup_h) - from digital_caffeine.pc98.sprites import ( - _CUP_BASE_LEFT, - _CUP_BASE_RIGHT, - ) - left = int(_CUP_RIM_LEFT + t * (_CUP_BASE_LEFT - _CUP_RIM_LEFT)) + 2 - right = int(_CUP_RIM_RIGHT - t * (_CUP_RIM_RIGHT - _CUP_BASE_RIGHT)) - 2 - for x in range(left, right + 1): - phase = (x + self.frame // 3) % 3 - px[x, y] = surface_colors[phase] - - # Shimmer highlight - seed = self.frame * 17 + 7 - for i in range(3): - sx = _INT_LEFT + (seed + i * 7) % max(1, _INT_RIGHT - _INT_LEFT) - if _SURFACE_Y_TOP <= _SURFACE_Y_TOP + (i % 3) < _SURFACE_Y_BOT: - if _INT_LEFT <= sx <= _INT_RIGHT: - px[sx, _SURFACE_Y_TOP + (i % 3)] = OFF_WHITE - - # Steam: draws over background and window, not over cup/table/pillars - pr = _PILLAR_W - pl = SCENE_W - _PILLAR_W - for x, y, ci in self._steam.get_draw_list(): - if pr <= x < pl and 0 <= y < SCENE_H: - if px[x, y] in _STEAM_BG: - px[x, y] = ci - - # Drips - for x, y, ci in self._drips.get_draw_list(): - if pr <= x < pl and 0 <= y < SCENE_H: - px[x, y] = ci - - def render(self) -> Text: - return self.canvas.render(self.cycle) diff --git a/src/digital_caffeine/pc98/sprites.py b/src/digital_caffeine/pc98/sprites.py deleted file mode 100644 index 9aefde5..0000000 --- a/src/digital_caffeine/pc98/sprites.py +++ /dev/null @@ -1,329 +0,0 @@ -"""PC-98 pixel art sprite drawing functions. - -All functions draw onto a Pillow palette-mode Image using palette indices. -Every pixel is placed with purpose - clean outlines, smooth gradients, -readable silhouettes. Light source: upper-left. -""" - -from __future__ import annotations - -from PIL import Image, ImageDraw - -from digital_caffeine.pc98.palette import ( - AMBER, - BLACK, - CHOCOLATE, - CREAM, - DARK_BROWN, - DEEP_NAVY, - DEEP_RED, - DUSTY_ROSE, - GOLD, - LIGHT_GRAY, - MAGENTA, - OFF_WHITE, - SLATE, - STEEL_BLUE, - WARM_BROWN, - WARM_GRAY, - dither_pick, -) - -SCENE_W = 56 -SCENE_H = 68 - -# -- Layout ------------------------------------------------------------------- - -_PILLAR_W = 4 - -# Cup: slightly tapered (wider rim, narrower base) -_CUP_RIM_LEFT = 14 -_CUP_RIM_RIGHT = 40 -_CUP_BASE_LEFT = 16 -_CUP_BASE_RIGHT = 38 -_CUP_TOP = 25 -_CUP_BOTTOM = 50 - -# Interior (inside the 1px walls) -_INT_LEFT = _CUP_RIM_LEFT + 2 -_INT_RIGHT = _CUP_RIM_RIGHT - 2 -_INT_TOP = _CUP_TOP + 2 - -# Handle: C-shape extending right -_HDL_OUTER_X = 44 -_HDL_TOP = 30 -_HDL_BOT = 46 -_HDL_MID_TOP = 33 -_HDL_MID_BOT = 43 - -# Saucer -_SAU_LEFT = 10 -_SAU_RIGHT = 46 -_SAU_TOP = 51 -_SAU_BOT = 54 - -# Table -_TABLE_TOP = 55 - -# Window -_WIN_LEFT = _PILLAR_W + 3 -_WIN_RIGHT = SCENE_W - _PILLAR_W - 3 -_WIN_TOP = 2 -_WIN_BOT = 19 - - -def draw_background(img: Image.Image) -> None: - """Solid deep navy background. Calm and quiet.""" - draw = ImageDraw.Draw(img) - draw.rectangle([0, 0, SCENE_W - 1, SCENE_H - 1], fill=DEEP_NAVY) - - -def draw_ornate_pillars(img: Image.Image) -> None: - """Decorative pillar borders on left and right edges.""" - px = img.load() - - for side in (0, 1): - x0 = 0 if side == 0 else SCENE_W - _PILLAR_W - - for y in range(SCENE_H): - # Outer edge: black - px[x0 if side == 0 else x0 + _PILLAR_W - 1, y] = BLACK - # Inner edge: warm highlight - px[x0 + _PILLAR_W - 1 if side == 0 else x0, y] = WARM_GRAY - # Body: clean dither - for x in range(x0 + 1, x0 + _PILLAR_W - 1): - px[x, y] = dither_pick(WARM_BROWN, CHOCOLATE, x, y) - - # Gem accents - mid = x0 + _PILLAR_W // 2 - for gy in range(5, SCENE_H - 3, 10): - px[mid, gy] = MAGENTA - if gy - 1 >= 0: - px[mid, gy - 1] = DUSTY_ROSE - if gy + 1 < SCENE_H: - px[mid, gy + 1] = DUSTY_ROSE - - # Horizontal bands - for by in range(0, SCENE_H, 14): - for x in range(x0 + 1, x0 + _PILLAR_W - 1): - px[x, by] = DEEP_RED - if by + 1 < SCENE_H: - px[x, by + 1] = MAGENTA - - -def draw_window(img: Image.Image) -> None: - """Window with curtains and night sky.""" - draw = ImageDraw.Draw(img) - px = img.load() - - # Frame - draw.rectangle([_WIN_LEFT, _WIN_TOP, _WIN_RIGHT, _WIN_BOT], outline=WARM_GRAY) - draw.rectangle([_WIN_LEFT + 1, _WIN_TOP + 1, _WIN_RIGHT - 1, _WIN_BOT - 1], - outline=LIGHT_GRAY) - - # Sky pane - pl, pt = _WIN_LEFT + 2, _WIN_TOP + 2 - pr, pb = _WIN_RIGHT - 2, _WIN_BOT - 2 - draw.rectangle([pl, pt, pr, pb], fill=STEEL_BLUE) - - # Crossbars - mx = (pl + pr) // 2 - my = (pt + pb) // 2 - for x in range(pl, pr + 1): - px[x, my] = WARM_GRAY - for y in range(pt, pb + 1): - px[mx, y] = WARM_GRAY - - # Stars - for sx, sy in [(pl + 3, pt + 2), (pr - 4, pt + 3), (mx + 4, pb - 3), - (pl + 6, pb - 2), (pr - 6, pt + 5)]: - if pl < sx < pr and pt < sy < pb and px[sx, sy] == STEEL_BLUE: - px[sx, sy] = OFF_WHITE - - # Curtains: 2px wide on each side, clean vertical stripes - for y in range(pt, pb + 1): - # Left curtain - px[pl, y] = DEEP_RED - px[pl + 1, y] = MAGENTA if y % 2 == 0 else DEEP_RED - # Right curtain - px[pr, y] = DEEP_RED - px[pr - 1, y] = MAGENTA if y % 2 == 0 else DEEP_RED - - # Sill - draw.line([(_WIN_LEFT, _WIN_BOT + 1), (_WIN_RIGHT, _WIN_BOT + 1)], - fill=LIGHT_GRAY) - - -def draw_table(img: Image.Image) -> None: - """Wooden table surface with clean grain.""" - draw = ImageDraw.Draw(img) - px = img.load() - - tl = _PILLAR_W - tr = SCENE_W - _PILLAR_W - 1 - - # Edge highlight - draw.line([(tl, _TABLE_TOP), (tr, _TABLE_TOP)], fill=CREAM) - draw.line([(tl, _TABLE_TOP + 1), (tr, _TABLE_TOP + 1)], fill=LIGHT_GRAY) - - # Body: alternating warm bands - for y in range(_TABLE_TOP + 2, SCENE_H): - band = (y - _TABLE_TOP) // 3 - color = WARM_GRAY if band % 2 == 0 else WARM_BROWN - for x in range(tl, tr + 1): - px[x, y] = color - - # Clean grain lines - for y in range(_TABLE_TOP + 4, SCENE_H, 4): - for x in range(tl, tr + 1): - px[x, y] = LIGHT_GRAY - - -def draw_cup(img: Image.Image) -> None: - """Coffee cup with tapered shape, smooth gradient, clean shading.""" - px = img.load() - cup_h = _CUP_BOTTOM - _CUP_TOP - - # -- Build the tapered cup shape pixel by pixel -- - for y in range(_CUP_TOP, _CUP_BOTTOM + 1): - t = (y - _CUP_TOP) / max(1, cup_h) # 0 at top, 1 at bottom - # Interpolate between rim width and base width - left = int(_CUP_RIM_LEFT + t * (_CUP_BASE_LEFT - _CUP_RIM_LEFT)) - right = int(_CUP_RIM_RIGHT - t * (_CUP_RIM_RIGHT - _CUP_BASE_RIGHT)) - - # Outline - px[left, y] = BLACK - px[right, y] = BLACK - - # Walls - if left + 1 < right: - px[left + 1, y] = OFF_WHITE # lit wall - if right - 1 > left: - px[right - 1, y] = WARM_GRAY # shadow wall - - # Top and bottom edges - for x in range(_CUP_RIM_LEFT, _CUP_RIM_RIGHT + 1): - px[x, _CUP_TOP] = BLACK - for x in range(_CUP_BASE_LEFT, _CUP_BASE_RIGHT + 1): - px[x, _CUP_BOTTOM] = BLACK - - # -- Rim: bright with rose accents (PC-98 signature) -- - for x in range(_CUP_RIM_LEFT + 1, _CUP_RIM_RIGHT): - px[x, _CUP_TOP + 1] = OFF_WHITE - if x % 3 == 0: - px[x, _CUP_TOP + 1] = DUSTY_ROSE - - # -- Coffee fill: smooth horizontal bands -- - fill_top = _CUP_TOP + 2 - fill_bot = _CUP_BOTTOM - 1 - fill_h = fill_bot - fill_top - if fill_h <= 0: - return - - # Layers: (solid_color, start_pct, end_pct) - # Dithering only at boundaries between layers - solid_layers = [ - (CREAM, 0.00, 0.10), # crema - (AMBER, 0.12, 0.18), # warm transition - (CHOCOLATE, 0.20, 0.38), # light coffee - (WARM_BROWN, 0.40, 0.58), # medium - (DARK_BROWN, 0.60, 0.80), # dark - (DARK_BROWN, 0.82, 1.00), # deepest - ] - # Transition rows between layers (dithered) - transitions = [ - (CREAM, AMBER, 0.10, 0.12), - (AMBER, CHOCOLATE, 0.18, 0.20), - (CHOCOLATE, WARM_BROWN, 0.38, 0.40), - (WARM_BROWN, DARK_BROWN, 0.58, 0.60), - (DARK_BROWN, DARK_BROWN, 0.80, 0.82), - ] - - for y in range(fill_top, fill_bot): - pct = (y - fill_top) / max(1, fill_h) - t = (y - _CUP_TOP) / max(1, cup_h) - left = int(_CUP_RIM_LEFT + t * (_CUP_BASE_LEFT - _CUP_RIM_LEFT)) + 2 - right = int(_CUP_RIM_RIGHT - t * (_CUP_RIM_RIGHT - _CUP_BASE_RIGHT)) - 2 - - # Check if this row is a transition - is_trans = False - for ca, cb, start, end in transitions: - if start <= pct < end: - for x in range(left, right + 1): - px[x, y] = dither_pick(ca, cb, x, y) - is_trans = True - break - - if not is_trans: - # Solid fill for the layer body - color = DARK_BROWN # default - for sc, start, end in solid_layers: - if start <= pct < end: - color = sc - break - for x in range(left, right + 1): - px[x, y] = color - - # -- Specular highlight on crema (bright spot upper-left) -- - for dy in range(3): - y = fill_top + dy - if y < fill_bot: - t = (y - _CUP_TOP) / max(1, cup_h) - left = int(_CUP_RIM_LEFT + t * (_CUP_BASE_LEFT - _CUP_RIM_LEFT)) + 2 - px[left, y] = OFF_WHITE - if left + 1 <= _INT_RIGHT: - px[left + 1, y] = GOLD - - -def draw_handle(img: Image.Image) -> None: - """C-shaped handle extending from the right side of the cup.""" - px = img.load() - - # The handle is a C-shape: top bar, outer vertical, bottom bar - # connecting to the cup at _CUP_RIM_RIGHT area - - # Top horizontal bar - for x in range(_CUP_RIM_RIGHT, _HDL_OUTER_X + 1): - px[x, _HDL_TOP] = BLACK - px[x, _HDL_TOP + 1] = OFF_WHITE # highlight - px[x, _HDL_TOP + 2] = WARM_GRAY - - # Bottom horizontal bar - for x in range(_CUP_BASE_RIGHT, _HDL_OUTER_X + 1): - px[x, _HDL_BOT] = BLACK - px[x, _HDL_BOT - 1] = WARM_GRAY - px[x, _HDL_BOT - 2] = LIGHT_GRAY - - # Outer vertical bar - for y in range(_HDL_TOP, _HDL_BOT + 1): - px[_HDL_OUTER_X, y] = BLACK - px[_HDL_OUTER_X - 1, y] = WARM_GRAY - if _HDL_OUTER_X - 2 > _CUP_RIM_RIGHT: - px[_HDL_OUTER_X - 2, y] = LIGHT_GRAY - - # Interior of handle (cutout) - for y in range(_HDL_MID_TOP, _HDL_MID_BOT + 1): - for x in range(_CUP_RIM_RIGHT + 1, _HDL_OUTER_X - 2): - if x > 0 and y > 0: - px[x, y] = DEEP_NAVY - - -def draw_saucer(img: Image.Image) -> None: - """Wide saucer plate under the cup with highlights.""" - draw = ImageDraw.Draw(img) - px = img.load() - - # Main body - draw.rectangle([_SAU_LEFT, _SAU_TOP, _SAU_RIGHT, _SAU_BOT], fill=SLATE) - draw.rectangle([_SAU_LEFT, _SAU_TOP, _SAU_RIGHT, _SAU_BOT], outline=BLACK) - - # Top highlight - for x in range(_SAU_LEFT + 1, _SAU_RIGHT): - px[x, _SAU_TOP + 1] = LIGHT_GRAY - if x % 5 == 0: - px[x, _SAU_TOP + 1] = DUSTY_ROSE - - # Shadow below - for x in range(_SAU_LEFT + 2, _SAU_RIGHT - 1): - if _SAU_BOT + 1 < SCENE_H: - px[x, _SAU_BOT + 1] = DARK_BROWN diff --git a/src/digital_caffeine/pc98/status.py b/src/digital_caffeine/pc98/status.py deleted file mode 100644 index cdf7a52..0000000 --- a/src/digital_caffeine/pc98/status.py +++ /dev/null @@ -1,99 +0,0 @@ -"""PC-98 styled status panel for the right side of the display.""" - -from __future__ import annotations - -from digital_caffeine.pc98.palette import ( - DUSTY_ROSE, - GOLD, - MAGENTA, - OFF_WHITE, - PALETTE, - STEEL_BLUE, -) - -_H = "\u2500" # horizontal line -_V = "\u2502" # vertical line -_TL = "\u250c" # top-left corner -_TR = "\u2510" # top-right corner -_BL = "\u2514" # bottom-left corner -_BR = "\u2518" # bottom-right corner -_DIAMOND = "\u25c6" - - -def _format_time(seconds: int) -> str: - h = seconds // 3600 - m = (seconds % 3600) // 60 - s = seconds % 60 - return f"{h:02d}:{m:02d}:{s:02d}" - - -def format_status_text( - *, - active: bool, - paused: bool, - mode_label: str, - uptime_seconds: int, - remaining_str: str, - interval: int, - simulate: bool, - frame: int, - progress_pct: int | None = None, -) -> str: - gold = PALETTE[GOLD] - blue = PALETTE[STEEL_BLUE] - white = PALETTE[OFF_WHITE] - rose = PALETTE[DUSTY_ROSE] - mag = PALETTE[MAGENTA] - - if paused: - state_str = f"[{gold}]{_DIAMOND} Paused[/]" - elif active: - bright = frame % 60 < 30 - color = "#44cc44" if bright else "#228822" - state_str = f"[{color}]{_DIAMOND} Active[/]" - else: - state_str = f"[dim]{_DIAMOND} Stopped[/]" - - uptime_str = _format_time(uptime_seconds) - sim_str = f"[{gold}]On[/]" if simulate else "[dim]Off[/]" - - # Short mode label to fit panel - mode_short = mode_label.replace("Display + System", "Disp+Sys") - mode_short = mode_short.replace("Display Only", "Display") - mode_short = mode_short.replace("System Only", "System") - - w = 24 - b = f"[{blue}]" - top = f" {b}{_TL}{_H * w}{_TR}[/]" - bot = f" {b}{_BL}{_H * w}{_BR}[/]" - sep = f" {b}{_V}{_H * w}{_V}[/]" - - lines = [ - "", - top, - f" {b}{_V}[/][{gold} bold] {_DIAMOND} STATUS[/]", - sep, - f" {b}{_V}[/] [{white}]State:[/] {state_str}", - f" {b}{_V}[/] [{white}]Mode:[/] [{rose}]{mode_short}[/]", - f" {b}{_V}[/] [{white}]Uptime:[/] [{white}]{uptime_str}[/]", - f" {b}{_V}[/] [{white}]Left:[/] {remaining_str}", - f" {b}{_V}[/] [{white}]Every:[/] {interval}s", - f" {b}{_V}[/] [{white}]Jiggle:[/] {sim_str}", - bot, - ] - - if progress_pct is not None: - bar_w = 18 - filled = int(progress_pct / 100 * bar_w) - bar = "" - for i in range(bar_w): - if i < filled: - bar += f"[{mag}]\u2593[/]" - else: - bar += f"[{blue}]\u2591[/]" - lines.append("") - lines.append(f" {b}{_TL}{_H * w}{_TR}[/]") - lines.append(f" {b}{_V}[/] {bar} [{white}]{progress_pct}%[/]") - lines.append(f" {b}{_BL}{_H * w}{_BR}[/]") - - return "\n".join(lines) diff --git a/tests/test_pc98_canvas.py b/tests/test_pc98_canvas.py deleted file mode 100644 index 890f70c..0000000 --- a/tests/test_pc98_canvas.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Tests for the half-block pixel canvas renderer.""" - -from PIL import Image -from rich.text import Text - -from digital_caffeine.pc98.canvas import PixelCanvas -from digital_caffeine.pc98.palette import PALETTE_RGB, CyclePalette - - -def _make_test_image(width: int, height: int, fill: int = 0) -> Image.Image: - """Create a small palette-mode test image.""" - img = Image.new("P", (width, height), fill) - pal = [0] * 768 - for i, (r, g, b) in enumerate(PALETTE_RGB): - pal[i * 3] = r - pal[i * 3 + 1] = g - pal[i * 3 + 2] = b - img.putpalette(pal) - return img - - -class TestPixelCanvas: - def test_dimensions(self): - canvas = PixelCanvas(8, 12) - assert canvas.width == 8 - assert canvas.height == 12 - - def test_image_is_palette_mode(self): - canvas = PixelCanvas(8, 12) - assert canvas.image.mode == "P" - - def test_render_produces_rich_text(self): - canvas = PixelCanvas(4, 4) - result = canvas.render(CyclePalette()) - assert isinstance(result, Text) - - def test_render_height_is_half_pixel_height(self): - canvas = PixelCanvas(4, 8) - result = canvas.render(CyclePalette()) - lines = str(result).split("\n") - assert len(lines) == 4 # 8 pixels / 2 - - def test_render_width_matches_pixel_width(self): - canvas = PixelCanvas(6, 4) - result = canvas.render(CyclePalette()) - lines = str(result).split("\n") - assert len(lines[0]) == 6 - - def test_same_color_pair_uses_full_block(self): - canvas = PixelCanvas(2, 2) - px = canvas.image.load() - px[0, 0] = 5 - px[0, 1] = 5 - px[1, 0] = 5 - px[1, 1] = 5 - result = canvas.render(CyclePalette()) - plain = str(result) - assert "\u2580" not in plain - - def test_different_color_pair_uses_half_block(self): - canvas = PixelCanvas(1, 2) - px = canvas.image.load() - px[0, 0] = 5 - px[0, 1] = 11 - result = canvas.render(CyclePalette()) - plain = str(result) - assert "\u2580" in plain or "\u2584" in plain - - def test_scanlines_darken_odd_rows(self): - canvas = PixelCanvas(2, 4, scanlines=True) - px = canvas.image.load() - for x in range(2): - for y in range(4): - px[x, y] = 15 - cp = CyclePalette() - result = canvas.render(cp) - assert isinstance(result, Text) - - def test_odd_height_handled(self): - canvas = PixelCanvas(2, 5) - result = canvas.render(CyclePalette()) - lines = str(result).split("\n") - assert len(lines) == 3 # ceil(5/2) - - def test_clear_resets_to_fill(self): - canvas = PixelCanvas(4, 4, fill=1) - px = canvas.image.load() - px[0, 0] = 15 - canvas.clear() - assert canvas.image.load()[0, 0] == 1 diff --git a/tests/test_pc98_palette.py b/tests/test_pc98_palette.py deleted file mode 100644 index c9e6cc3..0000000 --- a/tests/test_pc98_palette.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Tests for the PC-98 16-color palette and cycling logic.""" - -from digital_caffeine.pc98.palette import ( - PALETTE, - PALETTE_RGB, - CyclePalette, - dither_pick, -) - - -class TestPaletteConstants: - def test_palette_has_16_colors(self): - assert len(PALETTE) == 16 - - def test_palette_entries_are_hex(self): - for color in PALETTE: - assert color.startswith("#") - assert len(color) == 7 - - def test_palette_rgb_has_16_entries(self): - assert len(PALETTE_RGB) == 16 - - def test_palette_rgb_values_in_range(self): - for r, g, b in PALETTE_RGB: - assert 0 <= r <= 255 - assert 0 <= g <= 255 - assert 0 <= b <= 255 - - def test_palette_rgb_matches_hex(self): - for hex_color, (r, g, b) in zip(PALETTE, PALETTE_RGB): - assert r == int(hex_color[1:3], 16) - assert g == int(hex_color[3:5], 16) - assert b == int(hex_color[5:7], 16) - - -class TestCyclePalette: - def test_initial_state_matches_base(self): - cp = CyclePalette() - for i in range(16): - assert cp.get_rgb(i) == PALETTE_RGB[i] - - def test_advance_changes_cycling_indices(self): - cp = CyclePalette() - # Advance coffee cycle (indices 5,6,7 rotate every 4 frames) - for _ in range(4): - cp.advance() - # After one rotation step, index 5 should now map to what was 6 - assert cp.get_rgb(5) == PALETTE_RGB[6] - - def test_non_cycling_indices_unchanged(self): - cp = CyclePalette() - original_0 = cp.get_rgb(0) - original_3 = cp.get_rgb(3) - for _ in range(100): - cp.advance() - assert cp.get_rgb(0) == original_0 - assert cp.get_rgb(3) == original_3 - - def test_coffee_cycle_wraps(self): - cp = CyclePalette() - # 3 steps of 4 frames = 12 frames, coffee cycle has 3 indices - for _ in range(12): - cp.advance() - # Should be back to original - assert cp.get_rgb(5) == PALETTE_RGB[5] - - def test_get_hex(self): - cp = CyclePalette() - hex_color = cp.get_hex(0) - assert hex_color == "#000000" - - def test_build_pillow_palette(self): - cp = CyclePalette() - pal = cp.build_pillow_palette() - assert len(pal) == 768 # 256 * 3 - # First color is black - assert pal[0:3] == [0, 0, 0] - - def test_darken_for_scanlines(self): - cp = CyclePalette() - normal = cp.get_rgb(15) # off-white - dark = cp.get_rgb_darkened(15, 0.8) - assert dark[0] < normal[0] - assert dark[1] < normal[1] - assert dark[2] < normal[2] - - -class TestDitherPick: - def test_same_color_returns_that_color(self): - assert dither_pick(3, 3, 0, 0) == 3 - - def test_dither_is_deterministic(self): - a = dither_pick(2, 3, 5, 7) - b = dither_pick(2, 3, 5, 7) - assert a == b - - def test_dither_returns_one_of_two_colors(self): - results = set() - for x in range(4): - for y in range(4): - results.add(dither_pick(2, 3, x, y)) - assert results == {2, 3} - - def test_dither_roughly_balanced(self): - count_a = sum( - 1 for x in range(8) for y in range(8) if dither_pick(0, 1, x, y) == 0 - ) - # 2x2 ordered dither should give ~50% split - assert 20 <= count_a <= 44 diff --git a/tests/test_pc98_particles.py b/tests/test_pc98_particles.py deleted file mode 100644 index 449c2f0..0000000 --- a/tests/test_pc98_particles.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Tests for steam and drip particle systems.""" - - -from digital_caffeine.pc98.particles import DripSystem, SteamSystem - - -class TestSteamSystem: - def test_initial_particle_count(self): - steam = SteamSystem(count=8, spawn_y=50.0, x_center=32) - assert len(steam.particles) == 8 - - def test_particles_rise_over_time(self): - steam = SteamSystem(count=4, spawn_y=50.0, x_center=32) - for _ in range(10): - steam.update() - for i, p in enumerate(steam.particles): - assert isinstance(p.y, float) - - def test_particles_reset_when_above_scene(self): - steam = SteamSystem(count=2, spawn_y=50.0, x_center=32) - for _ in range(200): - steam.update() - for p in steam.particles: - assert p.y >= -5 - - def test_update_advances_frame(self): - steam = SteamSystem(count=2, spawn_y=50.0, x_center=32) - assert steam.frame == 0 - steam.update() - assert steam.frame == 1 - - def test_get_draw_list_returns_tuples(self): - steam = SteamSystem(count=4, spawn_y=50.0, x_center=32) - draws = steam.get_draw_list() - for x, y, color_idx in draws: - assert isinstance(x, int) - assert isinstance(y, int) - assert isinstance(color_idx, int) - - -class TestDripSystem: - def test_no_drips_initially(self): - drips = DripSystem(spawn_y=58, x_min=19, x_max=44) - draws = drips.get_draw_list() - assert len(draws) == 0 - - def test_drips_spawn_over_time(self): - drips = DripSystem(spawn_y=58, x_min=19, x_max=44) - spawned = False - for _ in range(200): - drips.update() - if drips.get_draw_list(): - spawned = True - break - assert spawned - - def test_max_two_active_drips(self): - drips = DripSystem(spawn_y=58, x_min=19, x_max=44) - for _ in range(500): - drips.update() - assert len(drips.get_draw_list()) <= 2 - - def test_drips_fall_downward(self): - drips = DripSystem(spawn_y=58, x_min=19, x_max=44) - for _ in range(500): - drips.update() - draws = drips.get_draw_list() - if draws: - first_y = draws[0][1] - assert first_y >= 58 - break - - def test_drips_expire(self): - drips = DripSystem(spawn_y=58, x_min=19, x_max=44) - had_drip = False - for _ in range(500): - drips.update() - draws = drips.get_draw_list() - if draws: - had_drip = True - elif had_drip: - break - assert had_drip diff --git a/tests/test_pc98_scene.py b/tests/test_pc98_scene.py deleted file mode 100644 index 7b50693..0000000 --- a/tests/test_pc98_scene.py +++ /dev/null @@ -1,49 +0,0 @@ -"""Tests for the PC-98 coffee scene compositor.""" - -from rich.text import Text - -from digital_caffeine.pc98.scene import CoffeeScene -from digital_caffeine.pc98.sprites import SCENE_H, SCENE_W - - -class TestCoffeeScene: - def test_initial_dimensions(self): - scene = CoffeeScene() - assert scene.canvas.width == SCENE_W - assert scene.canvas.height == SCENE_H - - def test_update_increments_frame(self): - scene = CoffeeScene() - assert scene.frame == 0 - scene.update() - assert scene.frame == 1 - - def test_render_returns_rich_text(self): - scene = CoffeeScene() - scene.update() - result = scene.render() - assert isinstance(result, Text) - - def test_render_has_correct_line_count(self): - scene = CoffeeScene() - scene.update() - result = scene.render() - lines = str(result).split("\n") - expected_rows = (SCENE_H + 1) // 2 - assert len(lines) == expected_rows - - def test_multiple_updates_dont_crash(self): - scene = CoffeeScene() - for _ in range(100): - scene.update() - result = scene.render() - assert isinstance(result, Text) - - def test_animated_surface_changes(self): - scene = CoffeeScene() - scene.update() - scene.render() - for _ in range(20): - scene.update() - r2 = scene.render() - assert isinstance(r2, Text) diff --git a/tests/test_pc98_sprites.py b/tests/test_pc98_sprites.py deleted file mode 100644 index d23dd54..0000000 --- a/tests/test_pc98_sprites.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Tests for PC-98 pixel art sprite drawing functions.""" - -from PIL import Image - -from digital_caffeine.pc98.palette import ( - BLACK, - DARK_BROWN, - DEEP_NAVY, - LIGHT_GRAY, - PALETTE_RGB, - SLATE, - WARM_BROWN, - WARM_GRAY, -) -from digital_caffeine.pc98.sprites import ( - _CUP_BOTTOM, - _CUP_RIM_LEFT, - _CUP_RIM_RIGHT, - _CUP_TOP, - _HDL_OUTER_X, - _HDL_TOP, - _SAU_LEFT, - _SAU_TOP, - SCENE_H, - SCENE_W, - draw_background, - draw_cup, - draw_handle, - draw_saucer, - draw_table, -) - - -def _make_scene() -> Image.Image: - img = Image.new("P", (SCENE_W, SCENE_H), 0) - pal = [0] * 768 - for i, (r, g, b) in enumerate(PALETTE_RGB): - pal[i * 3] = r - pal[i * 3 + 1] = g - pal[i * 3 + 2] = b - img.putpalette(pal) - return img - - -class TestDrawBackground: - def test_fills_entire_image(self): - img = _make_scene() - draw_background(img) - px = img.load() - for y in range(SCENE_H): - for x in range(SCENE_W): - assert px[x, y] in (BLACK, DEEP_NAVY, SLATE) - - def test_mostly_deep_navy(self): - img = _make_scene() - draw_background(img) - px = img.load() - navy = sum(1 for y in range(SCENE_H) for x in range(SCENE_W) - if px[x, y] == DEEP_NAVY) - assert navy > (SCENE_W * SCENE_H * 0.9) - - -class TestDrawTable: - def test_table_in_lower_region(self): - img = _make_scene() - draw_background(img) - draw_table(img) - px = img.load() - table_colors = (WARM_GRAY, WARM_BROWN, DARK_BROWN, LIGHT_GRAY) - assert px[SCENE_W // 2, SCENE_H - 1] in table_colors - - -class TestDrawCup: - def test_cup_has_outline(self): - img = _make_scene() - draw_background(img) - draw_cup(img) - px = img.load() - has_black = any( - px[x, _CUP_TOP] == BLACK - for x in range(_CUP_RIM_LEFT, _CUP_RIM_RIGHT + 1) - ) - assert has_black - - def test_cup_has_coffee_fill(self): - img = _make_scene() - draw_background(img) - draw_cup(img) - px = img.load() - coffee_colors = {2, 3, 4, 5, 6, 7} - mid_y = (_CUP_TOP + _CUP_BOTTOM) // 2 - found = any( - px[x, mid_y] in coffee_colors - for x in range(_CUP_RIM_LEFT + 3, _CUP_RIM_RIGHT - 3) - ) - assert found - - -class TestDrawSaucer: - def test_saucer_present(self): - img = _make_scene() - draw_background(img) - draw_saucer(img) - px = img.load() - from digital_caffeine.pc98.palette import OFF_WHITE - saucer_colors = {SLATE, LIGHT_GRAY, BLACK, OFF_WHITE} - assert px[_SAU_LEFT + 1, _SAU_TOP + 1] in saucer_colors - - -class TestDrawHandle: - def test_handle_present(self): - img = _make_scene() - draw_background(img) - draw_handle(img) - px = img.load() - from digital_caffeine.pc98.palette import OFF_WHITE - handle_colors = {WARM_GRAY, LIGHT_GRAY, OFF_WHITE, BLACK} - assert px[_HDL_OUTER_X, (_HDL_TOP + 40) // 2] in handle_colors diff --git a/tests/test_pc98_widgets.py b/tests/test_pc98_widgets.py deleted file mode 100644 index 914dc0d..0000000 --- a/tests/test_pc98_widgets.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Tests for PC-98 status panel and dialogue box widgets.""" - -from digital_caffeine.pc98.dialogue import ( - get_current_quip, - typewriter_text, -) -from digital_caffeine.pc98.status import format_status_text - - -class TestFormatStatusText: - def test_returns_string(self): - result = format_status_text( - active=True, paused=False, mode_label="Display + System", - uptime_seconds=60, remaining_str="Indefinite", - interval=60, simulate=False, frame=0, - ) - assert isinstance(result, str) - - def test_contains_status_fields(self): - result = format_status_text( - active=True, paused=False, mode_label="Display + System", - uptime_seconds=3661, remaining_str="Indefinite", - interval=60, simulate=False, frame=0, - ) - assert "STATUS" in result - assert "Disp+Sys" in result - assert "01:01:01" in result - assert "Indefinite" in result - - def test_paused_shows_paused(self): - result = format_status_text( - active=True, paused=True, mode_label="Display + System", - uptime_seconds=0, remaining_str="Indefinite", - interval=60, simulate=False, frame=0, - ) - assert "Paused" in result - - def test_simulate_on(self): - result = format_status_text( - active=True, paused=False, mode_label="Display + System", - uptime_seconds=0, remaining_str="Indefinite", - interval=60, simulate=True, frame=0, - ) - assert "On" in result - - -class TestTypewriterText: - def test_reveals_characters_over_frames(self): - t0 = typewriter_text("Hello World", frame_in_quip=0) - t9 = typewriter_text("Hello World", frame_in_quip=30) - assert len(t0) < len(t9) or t0 == t9 - - def test_first_frame_shows_first_char(self): - result = typewriter_text("Hello", frame_in_quip=0) - assert result.startswith("H") - - def test_fully_revealed(self): - result = typewriter_text("Hi", frame_in_quip=100) - assert "Hi" in result - - def test_cursor_present_while_typing(self): - result = typewriter_text("Hello World Test", frame_in_quip=3) - assert len(result) > 1 - - -class TestGetCurrentQuip: - def test_returns_string(self): - result = get_current_quip(frame=0, paused=False) - assert isinstance(result, str) - - def test_paused_returns_paused_quip(self): - result = get_current_quip(frame=0, paused=True) - assert "cold" in result.lower() or "resume" in result.lower() - - def test_different_frames_eventually_differ(self): - quips = set() - for f in range(0, 10000, 288): - quips.add(get_current_quip(frame=f, paused=False)) - assert len(quips) > 1 From 2e25f77ff6eeb88c17828d155b9f946c4af1347d Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:16:24 -0500 Subject: [PATCH 13/15] chore(deps): drop textual, no longer needed after pc98 removal --- pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 87a18f6..c45329f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,6 @@ dependencies = [ "rich>=13.0", "pystray>=0.19", "Pillow>=10.0", - "textual>=8.0", ] [project.optional-dependencies] From f1f25720e1796ac8aec119256530033c948b9f37 Mon Sep 17 00:00:00 2001 From: Blake Date: Mon, 20 Apr 2026 08:20:46 -0500 Subject: [PATCH 14/15] fix(animations): bump FPS from 2 to 10 for snappier spinner Full spinner rotation now takes 1s instead of 5s, matching the feel of modern CLI tools. Also drops q-to-quit reaction time from 500ms to 100ms. Elapsed counter still only changes text at 1Hz so no visual flicker. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/digital_caffeine/animations.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/digital_caffeine/animations.py b/src/digital_caffeine/animations.py index 3a40509..3487335 100644 --- a/src/digital_caffeine/animations.py +++ b/src/digital_caffeine/animations.py @@ -18,7 +18,7 @@ from digital_caffeine.constants import Mode -FPS = 2 +FPS = 10 _QUIP_ROTATION_SECONDS = 90 _STARTUP_QUIET_SECONDS = 5 From 91749d87120a08c818a401d261797e5bfdb42c60 Mon Sep 17 00:00:00 2001 From: Blake Date: Tue, 21 Apr 2026 08:29:26 -0500 Subject: [PATCH 15/15] docs: update CLAUDE.md and README for minimal display rollback Reflect the switch from the animated coffee-cup dashboard to the single-line run_display (spinner, mode phrase, elapsed, optional duration suffix) with TTY vs non-TTY split and 10 FPS refresh. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 5 +++-- README.md | 35 +++++++++++++---------------------- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0c42a6a..f355b5a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ caffeine version # Print version - `src/digital_caffeine/__main__.py` - Enables `python -m digital_caffeine` - `src/digital_caffeine/engine.py` - Core keep-awake logic (CaffeineEngine), thread-safe with pause/resume - `src/digital_caffeine/cli.py` - Click CLI group with `start`, `config`, `version` subcommands -- `src/digital_caffeine/animations.py` - Animated coffee cup display, procedural steam, quips, progress bar +- `src/digital_caffeine/animations.py` - Minimal single-line status display via `run_display` (spinner + mode phrase + elapsed + optional duration suffix), rotating quip pool, TTY vs non-TTY split - `src/digital_caffeine/tray.py` - pystray system tray mode (TrayApp class) - `src/digital_caffeine/config.py` - TOML config at `~/.digital-caffeine/config.toml` - `src/digital_caffeine/icons.py` - Programmatic icon generation with Pillow (active/paused/stopped states) @@ -61,5 +61,6 @@ Defaults: `mode = "all"`, `interval = 60`, `duration = None`, `simulate = false` - **Windows-only**: Engine uses `ctypes.windll.kernel32.SetThreadExecutionState`. Use `--simulate` on the CLI, but note the engine itself will still call the API. Tests mock it automatically. - **`--simulate` is NOT a dry run**: It enables a 1px mouse jiggle (right then left) via `SendInput` to fool presence detection in Teams/Slack/Zoom. The engine still calls `SetThreadExecutionState` either way. - **Python 3.10 needs `tomli`**: `config.py` falls back from `tomllib` (3.11+) to `tomli`. Not in `dependencies` - users on 3.10 must install it manually or config loading will fail. -- **Animation at 8 FPS**: `animations.py` drives `Rich.Live` at 8 FPS. The `FPS` constant controls refresh rate. +- **Animation at 10 FPS**: `animations.py` drives `rich.live.Live` at 10 FPS. The `FPS` constant controls refresh rate. +- **TTY vs non-TTY in `run_display`**: On a TTY it runs a Rich `Live` redraw loop with `q`-to-quit (via `msvcrt`). When stdout is piped/redirected it prints one status line and sleeps until the engine stops, so log output stays clean. - **Duration expiry in tray mode**: Engine fires an `on_stop` callback on a separate daemon thread to avoid self-join deadlock. diff --git a/README.md b/README.md index 87b3d07..792d57f 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Digital Caffeine tells your PC to knock it off. It uses the Windows `SetThreadEx ## What you get -The CLI mode gives you a live dashboard running at 8fps with procedurally generated steam wisps rising from a color-gradient coffee cup, a rippling liquid surface, a border that breathes through cyan shades, and coffee puns that type themselves out with a blinking cursor. Timed sessions get a progress bar. Is any of this necessary? No. Did we do it anyway? Obviously. +The CLI mode gives you a single-line status: a braille spinner, what it's doing, how long it's been doing it, and a rotating coffee quip underneath. No dashboards, no ASCII art, no breathing borders. It reflows on narrow terminals, drops styling when `NO_COLOR` is set, and quits on `q` or Ctrl+C. There's also a system tray mode if you'd rather it just sit in the corner and do its job quietly. Coffee cup icon, right-click menu, notifications when a timed session ends. @@ -76,29 +76,20 @@ caffeine start --simulate --duration 8h --mode all When you run `caffeine start`, you get this: ``` - +-----------------------------------------------+ - | Digital Caffeine | - | | - | . . | - | . . · | - | ' '· Status: Active | - | ' ~' Mode: Display | - | ) ~ ) Uptime: 00:05:23 | - | +-----------+ Time remaining: 01:54:37 | - | | ~.~.~.~.~ |--\ Interval: 60s | - | | ......... | | Simulate: On | - | | ......... | | | - | | ......... |--/ ####............... 25% | - | +-----------+ | - | ================= | - | | - | Brewing producti_ | - | | - | Press Ctrl+C to stop | - +-----------------------------------------------+ +⠋ caffeine · keeping display + system awake · 1m 23s · q to quit + + Brewing productivity... +``` + +For a timed session (`caffeine start --duration 1h`), the status line picks up a remaining/total suffix: + +``` +⠙ caffeine · keeping display + system awake · 1m 23s · 58m / 1h left · q to quit + + Espresso yourself freely ``` -The steam is procedurally generated - 10 wisps rising with sine-wave drift, fading from `)` near the cup to `·` at the top. The coffee has a three-tone brown gradient. The liquid surface ripples. The quips type themselves out letter by letter with a blinking cursor. If you set a duration, a progress bar fills up next to the cup. The border breathes through cyan shades at 2Hz. All of it runs at 8fps and the entire animation state is a pure function of the frame counter. Nobody asked for this. +The spinner is a 10-frame braille cycle ticking at 10 FPS. The quip is held back for the first 5 seconds so startup isn't noisy, then rotates every 90 seconds from a pool of ~120 puns seeded per-session so each run feels different. Narrow terminals (under 50 columns) drop the "left" suffix and quit hint. `NO_COLOR` disables the cyan accent and dim styling. Piped or redirected output skips the live redraw and prints one line. ### Options