diff --git a/Cargo.toml b/Cargo.toml index a3485f0..1db7929 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ resolver = "2" members = [ "positron-core", + "positron-ratatui", "examples/counter-cli", ] @@ -20,3 +21,7 @@ serde = { version = "1", features = ["derive"] } ts-rs = { version = "10", features = ["uuid-impl", "serde-json-impl"] } uuid = { version = "1", features = ["v4", "serde"] } serde_json = "1" +# Terminal renderer surface (positron-ratatui, outlier A). ratatui re-exports +# its matching crossterm, so consumers use `ratatui::crossterm` and never +# risk a version skew against a separately-pinned crossterm. +ratatui = "0.29" diff --git a/README.md b/README.md index 90fb8d5..5524c0a 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,12 @@ standing on its own. Run the proof: `cargo run -p counter-cli`. -Next (see `docs/ARCHITECTURE.md` § roadmap O3–O6): -- `positron-ratatui` — terminal renderer + `Host` event loop (O3, outlier A) +Landed since: `positron-ratatui` — terminal renderer + `Host` event loop (O3, +outlier A: a genuinely different `type Output` — terminal cells, not a `String`; +headless-testable `drive` loop + live-TTY `run_crossterm`). Run it: +`cargo run -p positron-ratatui --example counter_tui`. + +Next (see `docs/ARCHITECTURE.md` § roadmap O4–O6): - `positron-wgpu` — one Rust GPU renderer for native (Metal/Vulkan/DX12) + web (WebGPU/WASM) + AR/VR (O4, outlier B — "web ≠ DOM") - `positron-lit` *(optional)* — Lit DOM renderer for a11y / text-reflow (O4b) - `ContinuumHost` (in continuum) — session ↔ Commands/Events, first real `ViewState` (O5) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c34fbce..d0d4fcb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -185,9 +185,11 @@ the view from `ViewState` alone, the state type is incomplete — not the render | **O5** | `ContinuumHost` (in continuum) | positron session ↔ Commands/Events; first real `ViewState` (`ChatViewState`) flows to a positron renderer; reconciles positron's frame output with continuum's existing `RenderBackend`/`RgbaFrame` GPU seam; resolves the two-wire merge question | O3 or O4 | | **O6** | persona `Observer` → RAG/tool bridge (in continuum) | perception into cognition + action as `CommandEnvelope` — closes "AI persona rag/tool integration" | O5 | -O1 and O2 have landed: the boundary is pinned, and `examples/counter-cli` proves -"one `ViewState`, many renderers, plus an observer perceiving the same state" in -a single process, before any transport or substrate. **O3 is the next unit** — -`positron-ratatui`, the first real stateful `Renderer` + `Host` event loop -(outlier A), ahead of the GPU renderer (O4, outlier B) that makes the "web ≠ DOM" -claim real. +O1–O3 have landed: the boundary is pinned; `examples/counter-cli` proves "one +`ViewState`, many renderers, plus an observer perceiving the same state" in a +single process; and `positron-ratatui` proves the first real stateful +`Renderer` + `Host` event loop against a genuinely different `type Output` +(terminal cells, not a `String`) — outlier A. The render/event loop is +headless-testable (`drive` over a `TestBackend`) with a thin live-TTY wrapper +(`run_crossterm`). **O4 is the next unit** — `positron-wgpu`, the run-everywhere +GPU renderer (outlier B) that makes the "web ≠ DOM" claim real. diff --git a/positron-ratatui/Cargo.toml b/positron-ratatui/Cargo.toml new file mode 100644 index 0000000..2ded296 --- /dev/null +++ b/positron-ratatui/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "positron-ratatui" +description = "Terminal (ratatui) reference Renderer + Host event loop for positron — the outlier-A surface: real cells, real state, headless-testable via TestBackend." +version.workspace = true +edition.workspace = true +license.workspace = true +authors.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +positron-core = { path = "../positron-core" } +ratatui = { workspace = true } diff --git a/positron-ratatui/examples/counter_tui.rs b/positron-ratatui/examples/counter_tui.rs new file mode 100644 index 0000000..2402792 --- /dev/null +++ b/positron-ratatui/examples/counter_tui.rs @@ -0,0 +1,77 @@ +//! A live terminal counter, driven through positron's `Host` contract. +//! +//! Run: `cargo run -p positron-ratatui --example counter_tui` +//! Keys: `↑` +1 · `↓` -1 · `r` reset · `Esc` / `q` quit +//! +//! The same `Counter` `ViewState` that the crate's tests render headlessly is +//! here rendered live — the "define once, project many" thesis: nothing about +//! the view changes between a `TestBackend` assertion and a real TTY. + +use positron_core::{Renderer, ViewState}; +use positron_ratatui::{run_crossterm, TerminalHost}; +use ratatui::crossterm::event::{KeyCode, KeyEvent}; +use ratatui::widgets::{Block, Paragraph}; + +#[derive(Debug, Clone)] +struct Counter { + value: i64, + revision: u64, +} + +impl ViewState for Counter { + fn kind(&self) -> &'static str { + "counter" + } + fn revision(&self) -> Option { + Some(self.revision) + } +} + +struct CounterRenderer; +impl Renderer for CounterRenderer { + type Output = Paragraph<'static>; + fn render(&self, state: &Counter) -> Paragraph<'static> { + Paragraph::new(format!( + "\n counter = {} (rev {})\n\n ↑ +1 ↓ -1 r reset Esc quit", + state.value, state.revision + )) + .block(Block::bordered().title(" positron-ratatui ")) + } +} + +enum Cmd { + Increment, + Decrement, + Reset, +} + +fn main() -> std::io::Result<()> { + // The mini-substrate: owns the state, applies commands, emits new state. + let mut value = 0i64; + let mut revision = 0u64; + + let mut host = TerminalHost::new(CounterRenderer, |key: KeyEvent| match key.code { + KeyCode::Up => Some(Cmd::Increment), + KeyCode::Down => Some(Cmd::Decrement), + KeyCode::Char('r') => Some(Cmd::Reset), + _ => None, + }); + + run_crossterm( + &mut host, + Counter { + value: 0, + revision: 0, + }, + |cmd| { + match cmd { + Cmd::Increment => value += 1, + Cmd::Decrement => value -= 1, + Cmd::Reset => value = 0, + } + revision += 1; + Some(Counter { value, revision }) + }, + |key| matches!(key.code, KeyCode::Esc | KeyCode::Char('q')), + ) +} diff --git a/positron-ratatui/src/driver.rs b/positron-ratatui/src/driver.rs new file mode 100644 index 0000000..789255a --- /dev/null +++ b/positron-ratatui/src/driver.rs @@ -0,0 +1,222 @@ +//! The render/event loop: [`drive`] (headless, testable) and +//! [`run_crossterm`] (live TTY). Both share one dispatch [`step`] and one +//! [`redraw`], so the state-down/event-up decision lives in exactly one place. + +use std::io; + +use positron_core::{Host, Renderer, ViewState}; +use ratatui::backend::{Backend, CrosstermBackend}; +use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind}; +use ratatui::crossterm::execute; +use ratatui::crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use ratatui::widgets::Widget; +use ratatui::Terminal; + +use crate::host::TerminalHost; + +/// Draw the host's current state, if any, filling the terminal. +fn redraw(terminal: &mut Terminal, host: &TerminalHost) -> io::Result<()> +where + B: Backend, + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + if let Some(state) = host.state() { + terminal.draw(|frame| { + let area = frame.area(); + frame.render_widget(host.renderer().render(state), area); + })?; + } + Ok(()) +} + +/// The one dispatch decision: map a key to a command, hand it to the +/// substrate (`apply`), and store any state the substrate hands back. +fn step( + host: &mut TerminalHost, + key: KeyEvent, + apply: &mut impl FnMut(C) -> Option, +) where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + if let Some(command) = host.on_event(key) { + if let Some(next) = apply(command) { + host.on_state(next); + } + } +} + +/// Headless render/event loop: draw the current state, then thread each key +/// through the host and re-render. `apply` stands in for the substrate — it +/// consumes a command and optionally produces the next state (in a real +/// deployment this is the `Commands.execute` round-trip; in a self-contained +/// app it owns the state locally). +/// +/// Generic over [`Backend`], so a `TestBackend` drives it with no TTY — this +/// is the loop the crate's tests exercise directly. +pub fn drive( + terminal: &mut Terminal, + host: &mut TerminalHost, + events: impl IntoIterator, + mut apply: impl FnMut(C) -> Option, +) -> io::Result<()> +where + B: Backend, + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + redraw(terminal, host)?; + for key in events { + step(host, key, &mut apply); + redraw(terminal, host)?; + } + Ok(()) +} + +/// What the live loop should do with one terminal event. Extracting this as a +/// pure decision keeps the loop's gating — the live-only part `drive` never +/// sees — testable without a TTY (see [`classify`] and its unit tests). +#[derive(Debug, PartialEq)] +enum LoopAction { + /// A quit key fired — leave the loop. + Quit, + /// A key press to dispatch through [`step`]. + Dispatch(KeyEvent), + /// Something changed the surface size — redraw only. + Redraw, + /// Not our concern (key release, mouse, paste, focus). + Ignore, +} + +/// Classify one crossterm [`Event`] into a [`LoopAction`]. Pure — no I/O, no +/// host — so the Press/quit/resize gating (the live-only surface) is unit-tested +/// headlessly. Only key **presses** dispatch: Windows also emits `Release`, +/// which would otherwise double every key. +fn classify(event: Event, quit_on: &impl Fn(&KeyEvent) -> bool) -> LoopAction { + match event { + Event::Key(key) if key.kind == KeyEventKind::Press => { + if quit_on(&key) { + LoopAction::Quit + } else { + LoopAction::Dispatch(key) + } + } + Event::Resize(_, _) => LoopAction::Redraw, + _ => LoopAction::Ignore, + } +} + +/// Live TTY driver: enter raw mode + the alternate screen, seed `initial` +/// state, then block on real crossterm key events until `quit_on` fires. +/// Always restores the terminal, even on error — and if restoring itself fails, +/// the loop's original (root-cause) error still wins. +/// +/// The wrapper is thin: the dispatch ([`step`]/[`redraw`]) and the event gating +/// ([`classify`]) are both unit-tested headlessly; only the blocking +/// `event::read` I/O here is genuinely TTY-bound. +pub fn run_crossterm( + host: &mut TerminalHost, + initial: S, + mut apply: impl FnMut(C) -> Option, + quit_on: impl Fn(&KeyEvent) -> bool, +) -> io::Result<()> +where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let mut terminal = Terminal::new(CrosstermBackend::new(stdout))?; + + host.on_state(initial); + let result = run_loop(&mut terminal, host, &mut apply, &quit_on); + + // Restore unconditionally — a live loop that leaves the terminal in raw + // mode is worse than the error that got us here. `result.and(restore)` + // surfaces the loop's error first: a restore failure must never mask the + // root cause ("fail loud and NAME the cause"). + let restore = (|| -> io::Result<()> { + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor() + })(); + result.and(restore) +} + +fn run_loop( + terminal: &mut Terminal, + host: &mut TerminalHost, + apply: &mut impl FnMut(C) -> Option, + quit_on: &impl Fn(&KeyEvent) -> bool, +) -> io::Result<()> +where + B: Backend, + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + redraw(terminal, host)?; + loop { + match classify(event::read()?, quit_on) { + LoopAction::Quit => return Ok(()), + LoopAction::Dispatch(key) => { + step(host, key, apply); + redraw(terminal, host)?; + } + LoopAction::Redraw => redraw(terminal, host)?, + LoopAction::Ignore => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + + fn press(code: KeyCode) -> Event { + Event::Key(KeyEvent::new_with_kind( + code, + KeyModifiers::NONE, + KeyEventKind::Press, + )) + } + + fn is_quit(key: &KeyEvent) -> bool { + key.code == KeyCode::Esc + } + + // what this catches: the live-only event gating that `drive` never + // exercises — only key PRESSES dispatch (a Release is ignored, so Windows' + // duplicate events don't double every key), a quit key leaves the loop, a + // resize redraws, and non-key events are ignored. + #[test] + fn classify_gates_press_quit_resize_and_ignores_the_rest() { + match classify(press(KeyCode::Up), &is_quit) { + LoopAction::Dispatch(key) => assert_eq!(key.code, KeyCode::Up), + other => panic!("expected Dispatch(Up), got {other:?}"), + } + assert_eq!(classify(press(KeyCode::Esc), &is_quit), LoopAction::Quit); + + let release = Event::Key(KeyEvent::new_with_kind( + KeyCode::Up, + KeyModifiers::NONE, + KeyEventKind::Release, + )); + assert_eq!(classify(release, &is_quit), LoopAction::Ignore); + + assert_eq!( + classify(Event::Resize(80, 24), &is_quit), + LoopAction::Redraw + ); + assert_eq!(classify(Event::FocusGained, &is_quit), LoopAction::Ignore); + } +} diff --git a/positron-ratatui/src/host.rs b/positron-ratatui/src/host.rs new file mode 100644 index 0000000..0d90b18 --- /dev/null +++ b/positron-ratatui/src/host.rs @@ -0,0 +1,93 @@ +//! [`TerminalHost`] — a positron [`Host`] over a ratatui terminal surface. + +use positron_core::{Host, Renderer, ViewState}; +use ratatui::buffer::Buffer; +use ratatui::crossterm::event::KeyEvent; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +/// A key map lowers a terminal key press to an optional consumer command. +/// Boxed (rather than a generic type parameter) so `TerminalHost` stays a +/// single concrete type per `(S, R, C)` — and aliased here so the field type +/// reads plainly instead of tripping `clippy::type_complexity`. +type KeyMap = Box Option>; + +/// A [`Host`] that renders a [`ViewState`] to a ratatui terminal and maps +/// terminal key presses back to a consumer-defined command type. +/// +/// - **State-down:** [`Host::on_state`] stores the latest state; the driver +/// ([`crate::drive`] / [`crate::run_crossterm`]) re-renders it. +/// - **Event-up:** [`Host::on_event`] runs the key map, yielding an optional +/// command the substrate consumes. +/// +/// The host owns **no** terminal I/O itself — the loop lives in the driver. +/// That keeps the state/event contract pure and unit-testable without a TTY: +/// [`render_current`](TerminalHost::render_current) renders straight into a +/// [`Buffer`] you can assert on. +pub struct TerminalHost +where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + renderer: R, + state: Option, + key_map: KeyMap, +} + +impl TerminalHost +where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + /// Build a host from a renderer and a key map. No state until the first + /// [`Host::on_state`] arrives — the substrate is the source of state. + pub fn new(renderer: R, key_map: impl FnMut(KeyEvent) -> Option + 'static) -> Self { + Self { + renderer, + state: None, + key_map: Box::new(key_map), + } + } + + /// The latest state, or `None` before the first [`Host::on_state`]. + pub fn state(&self) -> Option<&S> { + self.state.as_ref() + } + + /// The renderer this host projects through. The driver borrows it each + /// frame; consumers rarely need it directly. + pub fn renderer(&self) -> &R { + &self.renderer + } + + /// Render the current state into a fresh [`Buffer`] of `area`, or `None` + /// if no state has arrived yet. Headless — this is the seam the crate's + /// tests assert against, no terminal required. + pub fn render_current(&self, area: Rect) -> Option { + self.state + .as_ref() + .map(|state| crate::render_to_buffer(&self.renderer, state, area)) + } +} + +impl Host for TerminalHost +where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + type State = S; + type Renderer = R; + type Command = C; + type Event = KeyEvent; + + fn on_state(&mut self, state: S) { + self.state = Some(state); + } + + fn on_event(&mut self, event: KeyEvent) -> Option { + (self.key_map)(event) + } +} diff --git a/positron-ratatui/src/lib.rs b/positron-ratatui/src/lib.rs new file mode 100644 index 0000000..3ac7a08 --- /dev/null +++ b/positron-ratatui/src/lib.rs @@ -0,0 +1,197 @@ +#![forbid(unsafe_code)] +#![warn(missing_docs)] +#![warn(rust_2018_idioms)] + +//! # positron-ratatui +//! +//! The terminal reference [`Renderer`] and [`Host`](positron_core::Host) event +//! loop for positron — **outlier A** in the contract's outlier-validation: a +//! genuinely different `type Output` from `counter-cli`'s `String`. Here a +//! renderer produces real terminal **cells** (any `ratatui` [`Widget`]), which +//! the loop draws into a `ratatui` [`Buffer`]. +//! +//! Three pieces, each single-purpose: +//! - [`render_to_buffer`] — project a [`ViewState`] into a headless [`Buffer`] +//! (the primitive both loops and all tests build on). +//! - [`TerminalHost`] — the [`Host`](positron_core::Host) impl: state-down via +//! `on_state`, event-up via a key map. +//! - [`drive`] / [`run_crossterm`] — the render/event loop, headless-testable +//! and live-TTY respectively. +//! +//! positron owns the *contract*; this crate owns *one surface projection* of +//! it. It knows nothing of any substrate's state or command vocabulary — it +//! renders whatever `ViewState` it's given and emits whatever command the key +//! map returns. + +mod driver; +mod host; + +pub use driver::{drive, run_crossterm}; +pub use host::TerminalHost; + +use positron_core::{Renderer, ViewState}; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::widgets::Widget; + +/// Render a [`ViewState`] through a [`Renderer`] into a fresh `ratatui` +/// [`Buffer`] sized to `area`. Headless: no terminal, no TTY. This is the seam +/// the crate's tests assert against and the primitive both loops +/// ([`drive`], [`run_crossterm`]) draw with. +pub fn render_to_buffer(renderer: &R, state: &S, area: Rect) -> Buffer +where + S: ViewState, + R: Renderer, + R::Output: Widget, +{ + let mut buffer = Buffer::empty(area); + renderer.render(state).render(area, &mut buffer); + buffer +} + +#[cfg(test)] +mod tests { + use super::*; + use positron_core::Host; + use ratatui::backend::TestBackend; + use ratatui::crossterm::event::{KeyCode, KeyEvent}; + use ratatui::widgets::Paragraph; + use ratatui::Terminal; + + // Minimal outlier-A fixture: a counter rendered as terminal cells. + #[derive(Debug, Clone)] + struct Counter { + value: i64, + revision: u64, + } + + impl ViewState for Counter { + fn kind(&self) -> &'static str { + "counter" + } + fn revision(&self) -> Option { + Some(self.revision) + } + } + + // Output = Paragraph (a real Widget), NOT a String — the point of outlier A. + struct CounterRenderer; + impl Renderer for CounterRenderer { + type Output = Paragraph<'static>; + fn render(&self, state: &Counter) -> Paragraph<'static> { + Paragraph::new(format!( + "counter = {} (rev {})", + state.value, state.revision + )) + } + } + + #[derive(Debug, PartialEq)] + enum CounterCmd { + Increment, + Decrement, + Reset, + } + + fn key_map(key: KeyEvent) -> Option { + match key.code { + KeyCode::Up => Some(CounterCmd::Increment), + KeyCode::Down => Some(CounterCmd::Decrement), + KeyCode::Char('r') => Some(CounterCmd::Reset), + _ => None, + } + } + + // what this catches: a Renderer whose Output is real terminal cells (not a + // String) projects into a Buffer at the expected position — proof the + // contract carries a non-text surface, the whole reason this is outlier A. + #[test] + fn renderer_projects_view_state_into_terminal_cells() { + let area = Rect::new(0, 0, 20, 1); + let buffer = render_to_buffer( + &CounterRenderer, + &Counter { + value: 4, + revision: 2, + }, + area, + ); + // "counter = 4 (rev 2)" is 19 cells; the 20th is Paragraph's space pad. + assert_eq!(buffer, Buffer::with_lines(["counter = 4 (rev 2) "])); + } + + // what this catches: TerminalHost honors state-down (on_state stores; + // render_current reflects it) and event-up (on_event maps keys to the + // consumer command; unmapped keys yield None; no state renders to None). + #[test] + fn host_stores_state_and_maps_keys_to_commands() { + let mut host = TerminalHost::new(CounterRenderer, key_map); + let area = Rect::new(0, 0, 20, 1); + + assert!(host.state().is_none()); + assert!(host.render_current(area).is_none()); + + host.on_state(Counter { + value: 7, + revision: 1, + }); + assert_eq!(host.state().map(|c| c.value), Some(7)); + let buffer = host.render_current(area).expect("state was set"); + assert_eq!(buffer, Buffer::with_lines(["counter = 7 (rev 1) "])); + + assert_eq!( + host.on_event(KeyEvent::from(KeyCode::Up)), + Some(CounterCmd::Increment) + ); + assert_eq!(host.on_event(KeyEvent::from(KeyCode::Char('x'))), None); + } + + // what this catches: the real render/event loop (drive) threads keys + // through the host, applies substrate-returned state back, and re-renders — + // the full state-down/event-up cycle, verified headlessly via TestBackend. + #[test] + fn drive_runs_the_full_state_down_event_up_cycle() { + let mut host = TerminalHost::new(CounterRenderer, key_map); + let mut terminal = Terminal::new(TestBackend::new(20, 1)).expect("test backend"); + + // Mini-substrate: owns the value, applies each command, emits new state. + let mut value = 0i64; + let mut rev = 0u64; + let apply = |cmd: CounterCmd| -> Option { + match cmd { + CounterCmd::Increment => value += 1, + CounterCmd::Decrement => value -= 1, + CounterCmd::Reset => value = 0, + } + rev += 1; + Some(Counter { + value, + revision: rev, + }) + }; + + host.on_state(Counter { + value: 0, + revision: 0, + }); + let keys = [ + KeyCode::Up, + KeyCode::Up, + KeyCode::Up, + KeyCode::Down, + KeyCode::Char('r'), + KeyCode::Up, + ] + .into_iter() + .map(KeyEvent::from); + + drive(&mut terminal, &mut host, keys, apply).expect("drive"); + + // Up*3=3, Down=2, r=0, Up=1 → value 1; 6 mapped keys → 6 applies → rev 6. + assert_eq!(host.state().map(|c| c.value), Some(1)); + assert_eq!( + terminal.backend().buffer(), + &Buffer::with_lines(["counter = 1 (rev 6) "]) + ); + } +}