Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
resolver = "2"
members = [
"positron-core",
"positron-ratatui",
"examples/counter-cli",
]

Expand All @@ -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"
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 8 additions & 6 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
13 changes: 13 additions & 0 deletions positron-ratatui/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 }
77 changes: 77 additions & 0 deletions positron-ratatui/examples/counter_tui.rs
Original file line number Diff line number Diff line change
@@ -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<u64> {
Some(self.revision)
}
}

struct CounterRenderer;
impl Renderer<Counter> 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')),
)
}
222 changes: 222 additions & 0 deletions positron-ratatui/src/driver.rs
Original file line number Diff line number Diff line change
@@ -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<B, S, R, C>(terminal: &mut Terminal<B>, host: &TerminalHost<S, R, C>) -> io::Result<()>
where
B: Backend,
S: ViewState,
R: Renderer<S>,
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<S, R, C>(
host: &mut TerminalHost<S, R, C>,
key: KeyEvent,
apply: &mut impl FnMut(C) -> Option<S>,
) where
S: ViewState,
R: Renderer<S>,
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<B, S, R, C>(
terminal: &mut Terminal<B>,
host: &mut TerminalHost<S, R, C>,
events: impl IntoIterator<Item = KeyEvent>,
mut apply: impl FnMut(C) -> Option<S>,
) -> io::Result<()>
where
B: Backend,
S: ViewState,
R: Renderer<S>,
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<S, R, C>(
host: &mut TerminalHost<S, R, C>,
initial: S,
mut apply: impl FnMut(C) -> Option<S>,
quit_on: impl Fn(&KeyEvent) -> bool,
) -> io::Result<()>
where
S: ViewState,
R: Renderer<S>,
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<B, S, R, C>(
terminal: &mut Terminal<B>,
host: &mut TerminalHost<S, R, C>,
apply: &mut impl FnMut(C) -> Option<S>,
quit_on: &impl Fn(&KeyEvent) -> bool,
) -> io::Result<()>
where
B: Backend,
S: ViewState,
R: Renderer<S>,
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);
}
}
Loading
Loading