diff --git a/.agents/evidence/motion.md b/.agents/evidence/motion.md new file mode 100644 index 0000000..6bd4f7f --- /dev/null +++ b/.agents/evidence/motion.md @@ -0,0 +1,11 @@ +# Evidence — Motion + +What proves [motion](../specs/motion.md)'s invariants. Named functions only: the gate +resolves every one against the source, so a renamed or deleted test fails the build here. + + +| Invariant | Proven by | +| --- | --- | +| MOT-1 | `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`, `effort_xhigh_labels_remain_static_without_an_animation_deadline`, `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | +| MOT-2 | Unproven beyond `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden`, which checks the effort markers' width | +| MOT-3 | `effort_animation_changes_only_visible_max_cells_and_stops_when_hidden` | diff --git a/.agents/phases/phase-04-product-polish.md b/.agents/phases/phase-04-product-polish.md index 73f3d60..4fd6c5b 100644 --- a/.agents/phases/phase-04-product-polish.md +++ b/.agents/phases/phase-04-product-polish.md @@ -110,11 +110,12 @@ Visual changes are reviewed against real frames before the contract adopts them. set the Nerd Font already supplies; the block is odd on both axes and its width is chosen from the terminal's measured cell so it comes out square, because no fixed size is square in two fonts. It first appears centred in an empty conversation and leaves with the first message. In - motion the frame breathes between weights and drifts between palette slots, and the centre morphs - through circle, ring, rounded square and square, on the visible-only clock EFF-4 owns. Palette, - pure Ratatui drawing and the clock stay separate responsibilities; static previews at three - widths precede motion; no script reruns per frame. The - [stage plan](../plans/phase-04-stage-10-branding.md) owns the order. + motion the frame breathes between weights and drifts between palette slots, and the centre + morphs through circle, ring, rounded square and square, on the workspace's one motion clock + ([motion](../specs/motion.md)). Palette, pure Ratatui drawing and the clock stay separate + responsibilities; static previews at three widths precede motion; no script reruns per frame. + The [stage plan](../plans/phase-04-stage-10-branding.md) owns the order. + 11. **Composer menu and Drawer — complete.** A Command is a slash command only, typed into the conversation it addresses and run from there with a captured target: `/new`, `/resume` over saved conversations, `/compact` and `/permissions` for the Session, under @@ -396,7 +397,7 @@ Rejected: treating JSONL round trips as independent export/import acceptance. repeating the agent, and the activity line moves while the agent works: the mark's core, the label, elapsed time, effort, and how long the route has been quiet. Both were chosen from rendered candidates beside Claude Code, Codex and Grok, and both change - presentation only; motion shares EFF-4's clock rather than owning one. The + presentation only; motion runs on the workspace's one clock ([motion](../specs/motion.md)). The [stage plan](../plans/phase-04-stage-34-conversation-chrome.md) owns the order. Other roadmap work in performance, math and extensibility receives a stage when its evidence is diff --git a/.agents/plans/phase-04-stage-10-branding.md b/.agents/plans/phase-04-stage-10-branding.md index 4c20537..37fdbfb 100644 --- a/.agents/plans/phase-04-stage-10-branding.md +++ b/.agents/plans/phase-04-stage-10-branding.md @@ -3,9 +3,9 @@ | Field | Value | | --- | --- | | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 10 | -| Contract | [ui-ux](../ui-ux.md) "An empty conversation shows the mark"; MOT-1 to MOT-3 in [stage 34's plan](./phase-04-stage-34-conversation-chrome.md) until promoted; FR-1 in [frame-loop](../specs/frame-loop.md) | +| Contract | [ui-ux](../ui-ux.md) "An empty conversation shows the mark"; MOT-1 to MOT-3 in [motion](../specs/motion.md); FR-1 in [frame-loop](../specs/frame-loop.md) | | Evidence | [Conversation chrome spike](../spikes/conversation-chrome/README.md): mark proportions, motion choreographies, the Nerd Font shape family and the measured cells | -| Status | Slice 0 of 4, unstarted; begins after stage 34's slice 3 lands the motion owner | +| Status | Slice 0 of 4, unstarted; ready to begin: stage 34 landed the motion owner | ## Outcome diff --git a/.agents/plans/phase-04-stage-34-conversation-chrome.md b/.agents/plans/phase-04-stage-34-conversation-chrome.md index b5d27e6..fd8fb85 100644 --- a/.agents/plans/phase-04-stage-34-conversation-chrome.md +++ b/.agents/plans/phase-04-stage-34-conversation-chrome.md @@ -5,7 +5,7 @@ | Phase | [Phase 04](../phases/phase-04-product-polish.md) stage 34 | | Contract | [ui-ux](../ui-ux.md) §transcript grammar and its activity-line rule; EFF-4 in [reasoning-effort](../specs/reasoning-effort.md); FR-1 in [frame-loop](../specs/frame-loop.md); TR-6 in [transcript-layout](../specs/transcript-layout.md); SEL-1 and SEL-2 in [selection-and-copy](../specs/selection-and-copy.md) | | Evidence | [Conversation chrome spike](../spikes/conversation-chrome/README.md): the rendered candidates the user chose from and the survey of three other CLIs | -| Status | Slices 1 and 2 of 5 implemented and locally verified | +| Status | Slices 1–3 of 5 implemented and locally verified | ## Outcome @@ -14,18 +14,9 @@ row that moves and says only what is known: how long the step has run, at what e the route has been quiet. Both are presentation. Removing either changes no journal entry, no request and no replay. -## Contract, inline until code cites it +## Contract -**MOT-1 — One visible-only motion clock.** Every moving cell in the workspace wakes on the deadline -EFF-4 already owns. Absence of any visible moving cell disarms it; late wakes coalesce into the -current phase without queuing missed frames. - -**MOT-2 — A mark frame is one cell.** Every glyph in a motion sequence has display width one under -the width rules the product already applies to its markers, and a test proves it for every -sequence, so a frame can never widen a row. - -**MOT-3 — Motion changes presentation only.** A phase change invalidates the painted frame and never -the semantic revision (FR-1). Replaying a journal reproduces the same rows with the clock stopped. +MOT-1 to MOT-3 live in [motion](../specs/motion.md), promoted when the motion owner cited them. ## Slices @@ -42,10 +33,11 @@ the semantic revision (FR-1). Replaying a journal reproduces the same rows with plain `Message` before a model is named; a collapsed composer and a worker's input keep naming their agent, because there the name does distinguish. Closed by the renamed COM-4 test, refreshed frames, and every PTY smoke waiting on its own model's title. -3. **Motion owner.** The effort animation in `workspace/effort.rs` becomes the workspace's motion - owner: one deadline, any number of visible users, the effort rail its first user with no change - in behaviour. Closes with the existing effort tests passing unchanged and a test that nothing - visible moving means no wake (MOT-1). +3. **Motion owner — implemented.** The effort rail's clock became the workspace's one motion owner: + one deadline, each moving thing answering only whether it is visible, the phase held at the top + of view state where every renderer reads it. The effort rail is its first user with no change in + behaviour. Closed by the effort tests passing unchanged and + `mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce`. 4. **Activity line.** The row opens with the mark's core moving, then the label, then muted `· 11s · high effort`, and after five seconds without a model event `· quiet for 31s`. Elapsed reads `11s`, `2m 11s`, `1h 2m`. The mark wears blue, the server-tool colour while a provider-run search @@ -55,8 +47,8 @@ the semantic revision (FR-1). Replaying a journal reproduces the same rows with their terminal font settles, recorded there. Closes with frames for thinking, responding, running tool, running search, approval, compacting and quiet at three widths; MOT-2 for the cycle; MOT-3 as a test that a tick advances no semantic revision. -5. **Documents.** Evidence tables current; SVG review of the frames attached; MOT-1 to MOT-3 - promoted to a spec when code cites them; this plan deleted; the phase row says done. +5. **Documents.** Evidence tables current; SVG review of the frames attached; this plan deleted; + the phase row says done. ## Order and why diff --git a/.agents/specs/frame-loop.md b/.agents/specs/frame-loop.md index 197ba28..acb673c 100644 --- a/.agents/specs/frame-loop.md +++ b/.agents/specs/frame-loop.md @@ -18,8 +18,8 @@ clock. Producer traffic that alters nothing visible costs no frame. An admitted completion invalidates presentation without changing semantic state (PRE-3). An owned status command may publish a changed footer (STL-4); only changed decoded output advances the view revision. Its optional refresh clock schedules a command, not a frame or transcript scan. -EFF-4 owns a separate visible-only effort animation deadline; its phase invalidates presentation -without advancing semantic revision. The same cell/native commit path publishes every frame. +MOT-1 owns the one visible-only motion deadline; its phase invalidates presentation without +advancing semantic revision (MOT-3). The same cell/native commit path publishes every frame. **FR-2 — A frame's layout work is bounded by its viewport, not by the conversation's length.** What a frame builds is what its viewport reaches (TR-2). Cold/new-width literal heights use count-only diff --git a/.agents/specs/motion.md b/.agents/specs/motion.md new file mode 100644 index 0000000..5bc1bad --- /dev/null +++ b/.agents/specs/motion.md @@ -0,0 +1,30 @@ +# Spec — Motion + +| Field | Value | +| --- | --- | +| Status | MOT-1 and MOT-3 implemented for the effort rail; MOT-2 unproven until the activity line moves | +| Owns | When anything in the workspace moves, what a moving frame may change, and the one clock it runs on | +| Depends on | FR-1 and FR-3 in [frame-loop](./frame-loop.md); EFF-4 in [reasoning-effort](./reasoning-effort.md) as the first thing that moves | +| Proven by | `plexmaton-tui::workspace` effort tests below | + +## Invariants + +**MOT-1 — One visible-only motion clock.** The workspace owns one deadline at fifteen phases a +second over a 32 s cycle, and every moving cell reads its phase; nothing that moves owns a timer of +its own. Each moving thing answers only whether it is visible now. Absence of any visible moving +cell disarms the deadline, so an idle workspace wakes for nothing; a late wake lands on the current +phase and counts the next tick from itself, queuing no missed frames. Rejected: a clock per moving +thing, which is how the effort rail began and would have given the activity line a second one. + +**MOT-2 — A motion frame is one cell.** Every glyph in a motion sequence has display width one, and +a test proves it for each sequence, so a frame can never widen or reflow a row. Unproven: the +effort markers are checked; no other sequence exists yet. + +**MOT-3 — Motion changes presentation only.** A phase change invalidates the painted frame and never +the semantic revision or layout (FR-1), so replaying a journal reproduces the same rows whatever the +clock was doing. + +## Grammar + +The phase is a count, not a time: renderers derive their frame from it, so two moving things on +screen stay in step. The effort rail's marker completes a cycle every 1.6 s (EFF-4). diff --git a/.agents/specs/reasoning-effort.md b/.agents/specs/reasoning-effort.md index c12acf6..b184311 100644 --- a/.agents/specs/reasoning-effort.md +++ b/.agents/specs/reasoning-effort.md @@ -25,8 +25,9 @@ neutral and its vertical-only ticks are colored. Composer rules stay static; the letters and the selector's selected max marker/letters share one color phase. Xhigh stays static. The status script owns its original styling. -**EFF-4 — Motion has a bounded visible owner.** Visible max labels share one 67 ms deadline; -late wakes coalesce and absence of a visible max label disarms it. Animation changes three composer +**EFF-4 — Motion has a bounded visible owner.** A visible max label is one of the moving things +under [MOT-1](./motion.md): it runs on the workspace's one motion clock, and while no max label is +visible it contributes no wake. Animation changes three composer foreground cells and, while selected, four selector cells; its marker cycles triangle, square, hexagon and circle in one cell every 1.6 s. Covered, collapsed, clipped or retry-editing composer labels own no wake. Animation changes neither semantic revision nor layout; FR-3 owns output. diff --git a/.agents/ui-ux.md b/.agents/ui-ux.md index 75d7cb2..38c7682 100644 --- a/.agents/ui-ux.md +++ b/.agents/ui-ux.md @@ -159,12 +159,12 @@ other rule about input follows from this one. moving, then the label, then in muted text how long the step has run and at what effort, and once nothing has arrived for a while, how long it has been quiet. The mark wears blue, the server-tool colour while a provider-run search is the work, and stands still in the action-required colour - while approval is required. Motion runs on the one visible-only clock EFF-4 owns, shared with the - effort rail; it changes presentation only. Show the approval label in the visible primary card; - otherwise in the activity line. Rejected: duplicate labels on adjacent lines; a still row, which - during a thirty-second silent turn read as a hang; a token count estimated from streamed - characters, because the row claims only what the route reported; turning the row red when the - route is quiet, because quiet is not failure. + while approval is required. Motion runs on the one visible-only clock the effort rail shares + ([motion](./specs/motion.md)); it changes presentation only. Show the approval label in the + visible primary card; otherwise in the activity line. Rejected: duplicate labels on adjacent + lines; a still row, which during a thirty-second silent turn read as a hang; a token count + estimated from streamed characters, because the row claims only what the route reported; turning + the row red when the route is quiet, because quiet is not failure. - **An empty conversation shows the mark.** Before the first message the transcript holds the mark centred, the product's name beneath it, drawn in cells at the odd size nearest square for the terminal's measured cell; it moves on the same clock and leaves with the first message. Rejected: diff --git a/crates/plexmaton-cli/src/interaction.rs b/crates/plexmaton-cli/src/interaction.rs index 186a977..2a96111 100644 --- a/crates/plexmaton-cli/src/interaction.rs +++ b/crates/plexmaton-cli/src/interaction.rs @@ -67,7 +67,7 @@ where let note_deadline = workspace.note_deadline(); let drag_deadline = workspace.drag_autoscroll_deadline(); let frame_deadline = frames.deadline(); - let effort_deadline = workspace.effort_animation_deadline(Instant::now()); + let motion_deadline = workspace.motion_deadline(Instant::now()); let collaboration_enabled = collaboration .as_ref() .is_some_and(|collaboration| collaboration.can_poll(runtime)); @@ -112,7 +112,7 @@ where workspace.advance_drag_autoscroll(Instant::now()); } () = wait_for_deadline(frame_deadline) => {} - () = wait_for_deadline(effort_deadline) => { workspace.advance_effort_animation(Instant::now()); } + () = wait_for_deadline(motion_deadline) => { workspace.advance_motion(Instant::now()); } runtime_update = runtime.next_update() => { let update = runtime_update.context("receive live runtime update")?; let finished = apply_runtime_progress( diff --git a/crates/plexmaton-tui/src/render/chrome.rs b/crates/plexmaton-tui/src/render/chrome.rs index 06a13d1..fb3c7a0 100644 --- a/crates/plexmaton-tui/src/render/chrome.rs +++ b/crates/plexmaton-tui/src/render/chrome.rs @@ -293,7 +293,7 @@ pub(super) fn composer_title(state: &ViewState, palette: &Palette) -> Line<'stat line.spans .push(Span::styled(" · ", palette.style(Role::Muted))); line.spans - .extend(super::effort::effort_spans(effort, state.effort_phase())); + .extend(super::effort::effort_spans(effort, state.motion_phase())); line.spans.push(Span::raw(" ")); } line diff --git a/crates/plexmaton-tui/src/render/effort.rs b/crates/plexmaton-tui/src/render/effort.rs index 5442ad0..849f6b1 100644 --- a/crates/plexmaton-tui/src/render/effort.rs +++ b/crates/plexmaton-tui/src/render/effort.rs @@ -64,7 +64,7 @@ pub(crate) fn lines( let x = stop(width, index); let available = state.effort_available(level); let chosen = selected == Some(level); - let phase = if chosen { state.effort_phase() } else { 0 }; + let phase = if chosen { state.motion_phase() } else { 0 }; let glyph = if chosen && level == ReasoningEffort::Max { marker(phase) } else if chosen { diff --git a/crates/plexmaton-tui/src/state/composer_menu.rs b/crates/plexmaton-tui/src/state/composer_menu.rs index a5086f1..b468466 100644 --- a/crates/plexmaton-tui/src/state/composer_menu.rs +++ b/crates/plexmaton-tui/src/state/composer_menu.rs @@ -132,7 +132,6 @@ pub(crate) enum MenuRow { pub(crate) struct ComposerMenu { pub(crate) efforts: Option>, pub(crate) effort_feedback: Option, - pub(crate) effort_phase: u16, skills: Vec, models: models::ModelCatalog, pub(crate) model_feedback: Option, diff --git a/crates/plexmaton-tui/src/state/effort.rs b/crates/plexmaton-tui/src/state/effort.rs index 373c32c..5b377c7 100644 --- a/crates/plexmaton-tui/src/state/effort.rs +++ b/crates/plexmaton-tui/src/state/effort.rs @@ -57,14 +57,6 @@ impl ViewState { } } - pub(crate) fn effort_phase(&self) -> u16 { - self.composer_menu.effort_phase - } - - pub(crate) fn set_effort_phase(&mut self, phase: u16) { - self.composer_menu.effort_phase = phase; - } - pub(crate) fn effort_visible(&self) -> bool { self.menu_listing() == Some(Listing::Effort) && self.composer_menu.is_open() diff --git a/crates/plexmaton-tui/src/state/mod.rs b/crates/plexmaton-tui/src/state/mod.rs index 603a1e4..c8fc4fd 100644 --- a/crates/plexmaton-tui/src/state/mod.rs +++ b/crates/plexmaton-tui/src/state/mod.rs @@ -24,6 +24,7 @@ mod ingest; mod input_queue; mod inspect; mod inspector; +mod motion; mod notices; mod ordered; mod restoration; @@ -135,6 +136,8 @@ pub struct ViewState { inputs: BTreeMap, skill_bindings: composer_menu::SkillBindings, composer_menu: composer_menu::ComposerMenu, + /// The shared motion clock's phase (MOT-1); presentation reads it, semantics never do. + motion_phase: u16, inspector: Inspector, /// Which semantic entries the user opened, plus the one under the pointer. disclosure: DisclosureState, diff --git a/crates/plexmaton-tui/src/state/motion.rs b/crates/plexmaton-tui/src/state/motion.rs new file mode 100644 index 0000000..d303358 --- /dev/null +++ b/crates/plexmaton-tui/src/state/motion.rs @@ -0,0 +1,13 @@ +//! The motion clock's phase, held where every renderer can read it (MOT-1). + +use super::ViewState; + +impl ViewState { + pub(crate) const fn motion_phase(&self) -> u16 { + self.motion_phase + } + + pub(crate) const fn set_motion_phase(&mut self, phase: u16) { + self.motion_phase = phase; + } +} diff --git a/crates/plexmaton-tui/src/workspace.rs b/crates/plexmaton-tui/src/workspace.rs index 99dae08..45b6485 100644 --- a/crates/plexmaton-tui/src/workspace.rs +++ b/crates/plexmaton-tui/src/workspace.rs @@ -56,6 +56,7 @@ mod markdown_tests; mod math_tests; #[cfg(test)] mod model_tests; +mod motion; mod notices; mod paint; #[cfg(test)] @@ -221,7 +222,7 @@ pub struct Workspace { preparation: preparation::Preparation, copy: copy::CopyPreparation, native: crate::math::NativeFrame, - effort_animation: Option, + motion: Option, } impl Workspace { diff --git a/crates/plexmaton-tui/src/workspace/effort.rs b/crates/plexmaton-tui/src/workspace/effort.rs index e96e3ca..1ef172e 100644 --- a/crates/plexmaton-tui/src/workspace/effort.rs +++ b/crates/plexmaton-tui/src/workspace/effort.rs @@ -1,14 +1,7 @@ -//! Visible max labels share one deadline; animation invalidates presentation, never semantic state. +//! Effort's choices and refusals, and whether its max label is one of the workspace's moving things. use super::Workspace; use plexmaton_core::ReasoningEffort; -use std::time::{Duration, Instant}; - -#[derive(Debug)] -pub(super) struct EffortAnimation { - start: Instant, - next: Instant, -} impl Workspace { /// Publish the model's declared choices; absent capabilities are not inferred. @@ -21,7 +14,9 @@ impl Workspace { self.state.report_effort(result); } - fn effort_animation_visible(&self) -> bool { + /// Whether a max label is on screen, which is what makes effort one of the moving things + /// (EFF-4 under MOT-1). + pub(super) fn effort_moving(&self) -> bool { let selector = self.state.effort_visible() && self.state.selected_effort() == Some(ReasoningEffort::Max) && self @@ -30,41 +25,4 @@ impl Workspace { .is_some_and(|surface| surface.bounds.height >= 4 && surface.bounds.width >= 20); selector || crate::render::effort::composer_max_visible(&self.state, &self.surfaces) } - - /// Arm a single shared deadline while the composer or selector has a visible max label. - pub fn effort_animation_deadline(&mut self, now: Instant) -> Option { - if !self.effort_animation_visible() { - self.effort_animation = None; - return None; - } - Some( - self.effort_animation - .get_or_insert(EffortAnimation { - start: now, - next: now + Duration::from_millis(67), - }) - .next, - ) - } - - /// Late wakes coalesce into the current phase, without queuing missed animation frames. - pub fn advance_effort_animation(&mut self, now: Instant) { - if !self.effort_animation_visible() { - self.effort_animation = None; - return; - } - let Some(animation) = &mut self.effort_animation else { - return; - }; - if now < animation.next { - return; - } - let phase = - (now.saturating_duration_since(animation.start).as_millis() * 15 / 1000 % 480) as u16; - animation.next = now + Duration::from_millis(67); - if phase != self.state.effort_phase() { - self.state.set_effort_phase(phase); - self.painted = None; - } - } } diff --git a/crates/plexmaton-tui/src/workspace/effort_tests.rs b/crates/plexmaton-tui/src/workspace/effort_tests.rs index 6937b49..0383ee9 100644 --- a/crates/plexmaton-tui/src/workspace/effort_tests.rs +++ b/crates/plexmaton-tui/src/workspace/effort_tests.rs @@ -119,7 +119,8 @@ fn effort_provider_default_does_not_preselect_an_explicit_level() { assert_eq!(workspace.state.reasoning_effort(), Some(Effort::Default)); } -/// EFF-3/EFF-4/FR-4: real frames keep transcript/rules/status static while visible max cells animate. +/// EFF-3/EFF-4/FR-4/MOT-3: real frames keep transcript/rules/status static while visible max cells +/// animate, and a tick advances no semantic revision. #[test] fn effort_animation_changes_only_visible_max_cells_and_stops_when_hidden() { use unicode_width::UnicodeWidthStr; @@ -131,10 +132,8 @@ fn effort_animation_changes_only_visible_max_cells_and_stops_when_hidden() { let before = terminal.backend().buffer().clone(); let revision = workspace.state.revision(); let now = Instant::now(); - workspace - .effort_animation_deadline(now) - .expect("visible timer"); - workspace.advance_effort_animation(now + std::time::Duration::from_millis(1700)); + workspace.motion_deadline(now).expect("visible timer"); + workspace.advance_motion(now + std::time::Duration::from_millis(1700)); assert_eq!(workspace.state.revision(), revision); let work = workspace .draw(&mut terminal) @@ -165,11 +164,11 @@ fn effort_animation_changes_only_visible_max_cells_and_stops_when_hidden() { workspace.handle(&key(KeyCode::Esc)); workspace.draw(&mut terminal).expect("close"); assert!( - workspace.effort_animation_deadline(now).is_some(), + workspace.motion_deadline(now).is_some(), "composer max remains visible" ); let before = terminal.backend().buffer().clone(); - workspace.advance_effort_animation(now + std::time::Duration::from_millis(2000)); + workspace.advance_motion(now + std::time::Duration::from_millis(2000)); workspace.draw(&mut terminal).expect("composer animation"); let diff = before.diff(terminal.backend().buffer()); assert_eq!( @@ -187,19 +186,47 @@ fn effort_animation_changes_only_visible_max_cells_and_stops_when_hidden() { ratatui::crossterm::event::KeyModifiers::CONTROL, ))); workspace.draw(&mut terminal).expect("drawer"); - assert!(workspace.effort_animation_deadline(now).is_none()); - workspace.advance_effort_animation(now + std::time::Duration::from_secs(10)); + assert!(workspace.motion_deadline(now).is_none()); + workspace.advance_motion(now + std::time::Duration::from_secs(10)); assert!(!workspace.needs_draw()); } } +/// MOT-1: while anything visible moves there is one deadline, and asking again is the same wake; +/// a late wake lands on the current phase and counts the next tick from itself, queuing nothing. +#[test] +fn mot_1_one_deadline_serves_every_mover_and_late_wakes_coalesce() { + let (mut workspace, _) = open(88, &Effort::EXPLICIT, Effort::Max); + let now = Instant::now(); + let first = workspace + .motion_deadline(now) + .expect("a visible max label moves"); + assert_eq!( + workspace.motion_deadline(now + std::time::Duration::from_millis(5)), + Some(first), + "asking again does not re-arm" + ); + let late = now + std::time::Duration::from_secs(3); + workspace.advance_motion(late); + assert_eq!( + workspace.state.motion_phase(), + 45, + "three seconds at fifteen phases a second" + ); + assert_eq!( + workspace.motion_deadline(late), + Some(late + std::time::Duration::from_millis(67)), + "the next wake counts from the late one" + ); +} + /// EFF-3/EFF-4: static xhigh in both places owns no ambient wake. #[test] fn effort_xhigh_labels_remain_static_without_an_animation_deadline() { let (mut workspace, _) = open(88, &Effort::EXPLICIT, Effort::Xhigh); let now = Instant::now(); - assert!(workspace.effort_animation_deadline(now).is_none()); - workspace.advance_effort_animation(now + std::time::Duration::from_secs(10)); + assert!(workspace.motion_deadline(now).is_none()); + workspace.advance_motion(now + std::time::Duration::from_secs(10)); assert!(!workspace.needs_draw()); } diff --git a/crates/plexmaton-tui/src/workspace/motion.rs b/crates/plexmaton-tui/src/workspace/motion.rs new file mode 100644 index 0000000..b862651 --- /dev/null +++ b/crates/plexmaton-tui/src/workspace/motion.rs @@ -0,0 +1,63 @@ +//! MOT-1: one visible-only motion clock for the whole workspace. +//! +//! Anything that moves reads this clock's phase and adds its own visibility to [`Workspace::moving`]; +//! nothing that moves owns a timer of its own. A phase change invalidates presentation, never +//! semantic state (MOT-3). + +use super::Workspace; +use std::time::{Duration, Instant}; + +/// Fifteen phases a second, the rate the effort rail was designed at. +const TICK: Duration = Duration::from_millis(67); +/// Phases in one cycle, 32 s; every motion sequence divides it. +const CYCLE: u128 = 480; + +#[derive(Debug)] +pub(super) struct Motion { + start: Instant, + next: Instant, +} + +impl Workspace { + /// Whether any visible cell moves. Each moving thing answers for its own visibility. + fn moving(&self) -> bool { + self.effort_moving() + } + + /// Arm the one shared deadline while anything visible moves; nothing moving owns no wake. + pub fn motion_deadline(&mut self, now: Instant) -> Option { + if !self.moving() { + self.motion = None; + return None; + } + Some( + self.motion + .get_or_insert(Motion { + start: now, + next: now + TICK, + }) + .next, + ) + } + + /// Late wakes coalesce into the current phase, without queuing missed frames. + pub fn advance_motion(&mut self, now: Instant) { + if !self.moving() { + self.motion = None; + return; + } + let Some(motion) = &mut self.motion else { + return; + }; + if now < motion.next { + return; + } + let phase = + (now.saturating_duration_since(motion.start).as_millis() * 15 / 1000 % CYCLE) as u16; + motion.next = now + TICK; + if phase != self.state.motion_phase() { + self.state.set_motion_phase(phase); + self.painted = None; + } + } +}