Skip to main content

max / quasi

Generate a terminal host beside the two webview ones A fourth member in the template, so the app `quasi new` writes serves the same routes from a terminal. The two hosts it already had are both webview hosts, so nothing generated had ever pushed back on a description. Always rather than behind a --tui flag on `quasi new`. A flag is a second code path through the scaffolder, and what the template exists to show is that one description serves three hosts; an app that does not want the binary deletes a manifest. Runtime::say goes with it: a host has things to tell the user that no handler knows about, and stderr on a terminal app is under the alternate screen and therefore nowhere.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 17:20 UTC
Signed with PGP, not checked
Commit: f6d9e0976435f407a902c5e39f9216c7064807b2
Parent: 6d26f1d
5 files changed, +311 insertions, -1 deletion
@@ -168,6 +168,20 @@
168 168 tui.screen(&self.screen, &self.view, area, buf);
169 169 }
170 170
171 + /// Put a message on the screen, from the host rather than from a route.
172 + ///
173 + /// The host has things to say that no handler knows about: a route that
174 + /// failed, an address it will not open, a device that is not there. Without
175 + /// this they would go to stderr, which on a terminal app is underneath the
176 + /// alternate screen and therefore nowhere.
177 + pub fn say(&mut self, text: impl Into<String>) {
178 + self.screen.notices.push(Node::Notice {
179 + kind: layout::Notice::Banner,
180 + tone: layout::Tone::Danger,
181 + text: text.into(),
182 + });
183 + }
184 +
171 185 /// What is under the caret.
172 186 #[must_use]
173 187 pub fn focused(&self) -> Option<Spot> {
@@ -157,6 +157,20 @@
157 157 path: "crates/{{name}}-server/src/main.rs",
158 158 body: Body::Text(include_str!("../template/crates/server/src/main.rs")),
159 159 },
160 + // The terminal host, generated always rather than behind a flag on
161 + // `quasi new`. A flag would be a second code path through the scaffolder,
162 + // and what the template exists to demonstrate is that one description
163 + // serves three hosts; an app that does not want the binary deletes eleven
164 + // lines of manifest, which is cheaper than the branch would be to keep
165 + // correct.
166 + Entry {
167 + path: "crates/{{name}}-tui/Cargo.toml",
168 + body: Body::Text(include_str!("../template/crates/tui/Cargo.toml.tmpl")),
169 + },
170 + Entry {
171 + path: "crates/{{name}}-tui/src/main.rs",
172 + body: Body::Text(include_str!("../template/crates/tui/src/main.rs")),
173 + },
160 174 ];
161 175
162 176 impl Entry {
@@ -247,7 +261,7 @@
247 261 let Body::Text(source) = workspace.body else {
248 262 panic!("the manifest is text");
249 263 };
250 - for member in ["core", "desktop", "server"] {
264 + for member in ["core", "desktop", "server", "tui"] {
251 265 let declared = format!("crates/{{{{name}}}}-{member}");
252 266 assert!(source.contains(&declared), "{member} is not a member");
253 267 assert!(
@@ -4,6 +4,7 @@
4 4 "crates/{{name}}-core",
5 5 "crates/{{name}}-desktop",
6 6 "crates/{{name}}-server",
7 + "crates/{{name}}-tui",
7 8 ]
8 9
9 10 [workspace.package]
@@ -29,6 +30,10 @@
29 30 quasi-router = { git = "https://makenot.work/git/max/quasi.git" }
30 31 quasi-http = { git = "https://makenot.work/git/max/quasi.git" }
31 32 quasi-webview = { git = "https://makenot.work/git/max/quasi.git" }
33 + # The terminal renderer. Here beside the webview one because a host picks a
34 + # renderer and the core names none: that is the property the third host exists
35 + # to demonstrate.
36 + quasi-tui = { git = "https://makenot.work/git/max/quasi.git" }
32 37 # Default features off here, and each side asks for the half it wants. A
33 38 # workspace dependency's `default-features` cannot be overridden by a member —
34 39 # cargo refuses it outright — so the crate that is on both sides of the
@@ -1,0 +1,28 @@
1 + [package]
2 + name = "{{name}}-tui"
3 + description = "{{title}}, in a terminal: the third host over the same described screens"
4 + version.workspace = true
5 + edition.workspace = true
6 + rust-version.workspace = true
7 + authors.workspace = true
8 + license.workspace = true
9 + publish = false
10 +
11 + [lints]
12 + workspace = true
13 +
14 + [dependencies]
15 + {{name}}-core = { path = "../{{name}}-core" }
16 + quasi-router = { workspace = true }
17 + quasi-tui = { workspace = true }
18 +
19 + # Default features on here, unlike quasi-tui's own dependency on it: the
20 + # renderer fills a buffer and needs no backend, and this is the binary that
21 + # has to talk to a real terminal.
22 + ratatui = "0.30"
23 + # The theme, loaded at runtime rather than compiled in. The webview hosts get
24 + # theirs as CSS from `build.rs`; a terminal reads the same theme file and
25 + # resolves it to sixteen colours or to truecolor depending on what the terminal
26 + # admits to.
27 + makeover = "2.5"
28 + makeover-tui = { version = "0.15.0", features = ["theme"] }
@@ -1,0 +1,249 @@
1 + //! {{title}}, in a terminal.
2 + //!
3 + //! The third host over the same router, and the one that proves the claim the
4 + //! other two cannot. `{{name}}-server` and `{{name}}-desktop` are both webview
5 + //! hosts: one serves the markup over HTTP and the other over a custom protocol,
6 + //! and a webview can express anything, so neither of them ever pushes back on a
7 + //! description. This one draws cells. What it cannot honour is the useful part.
8 + //!
9 + //! Nothing here describes a screen. The router is the same table, the state is
10 + //! the same state, and the difference is the renderer and the loop around it.
11 + //!
12 + //! # The loop
13 + //!
14 + //! `quasi_tui::Runtime` holds the screen, what the user has typed into it, and
15 + //! how they got here; it turns a key into a `Step` and never calls a handler.
16 + //! The calling is here, which is what keeps the runtime free of this app's
17 + //! state type:
18 + //!
19 + //! ```text
20 + //! key ──► runtime.key ──► Step::Call(request) ──► router.handle ──► response
21 + //! │
22 + //! runtime.apply ◄─────────┘
23 + //! ```
24 + //!
25 + //! # Authentication
26 + //!
27 + //! None. See the module docs of `{{name}}_core`. For this host the answer is
28 + //! whatever the machine's user already is, the same as the desktop binary: a
29 + //! terminal has no session and no cookie to put one in.
30 +
31 + use std::io;
32 + use std::path::PathBuf;
33 + use std::sync::Arc;
34 +
35 + use quasi_router::{Request, RouteError};
36 + use quasi_tui::{Key, Runtime, Step, Tui};
37 + use ratatui::crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
38 + use ratatui::layout::Rect;
39 + use ratatui::style::Style;
40 +
41 + use {{snake}}_core::{AppState, router};
42 +
43 + /// Where the database lives, unless `{{SCREAM}}_DB` says otherwise.
44 + const DEFAULT_DB: &str = "{{name}}.db";
45 +
46 + /// The screen the app opens on.
47 + const HOME: &str = "/";
48 +
49 + /// Which theme to draw in, unless `{{SCREAM}}_THEME` says otherwise.
50 + const DEFAULT_THEME: &str = "goingson";
51 +
52 + fn main() -> io::Result<()> {
53 + let db = std::env::var("{{SCREAM}}_DB").unwrap_or_else(|_| DEFAULT_DB.to_owned());
54 + let state = Arc::new(
55 + AppState::open(&PathBuf::from(&db)).unwrap_or_else(|error| {
56 + // Before there is a screen to put a message on, so this goes to
57 + // whoever launched the binary.
58 + eprintln!("{{name}}: {error}");
59 + std::process::exit(1);
60 + }),
61 + );
62 +
63 + let router = router();
64 + let tui = renderer();
65 +
66 + // The first screen, before the terminal is taken over, so that a router
67 + // that cannot answer its own home route says so on an ordinary terminal
68 + // rather than inside an alternate screen that is about to be torn down.
69 + let screen = match router.handle(&state, Request::get(HOME)) {
70 + Ok(response) => match response.outcome {
71 + quasi_router::Outcome::Screen(screen) => screen,
72 + other => {
73 + eprintln!("{{name}}: the home route answered {other:?} rather than a screen");
74 + std::process::exit(1);
75 + }
76 + },
77 + Err(error) => {
78 + eprintln!("{{name}}: {error}");
79 + std::process::exit(1);
80 + }
81 + };
82 +
83 + let mut runtime = Runtime::new(screen);
84 + let mut terminal = ratatui::init();
85 + let outcome = run(&mut terminal, &tui, &mut runtime, &router, &state);
86 + ratatui::restore();
87 + outcome
88 + }
89 +
90 + /// Draw, wait for a key, act on it, repeat.
91 + fn run(
92 + terminal: &mut ratatui::DefaultTerminal,
93 + tui: &Tui,
94 + runtime: &mut Runtime,
95 + router: &quasi_router::Router<AppState>,
96 + state: &AppState,
97 + ) -> io::Result<()> {
98 + // What a control asked before doing itself, held while the answer is typed.
99 + let mut question: Option<String> = None;
100 +
101 + loop {
102 + terminal.draw(|frame| {
103 + let area = frame.area();
104 + let (screen, prompt) = split_bottom(area, question.is_some());
105 + runtime.draw(tui, screen, frame.buffer_mut());
106 + if let Some(text) = &question {
107 + // `Act::confirm` is a question asked after the control is
108 + // pressed, which is why the drawing declined it: there is
109 + // nothing to draw until it has been. A terminal asks it on the
110 + // last row, which is where a terminal asks everything.
111 + frame.buffer_mut().set_stringn(
112 + prompt.x,
113 + prompt.y,
114 + format!("{text} [y/n]"),
115 + prompt.width as usize,
116 + Style::default().add_modifier(ratatui::style::Modifier::REVERSED),
117 + );
118 + }
119 + })?;
120 +
121 + let Event::Key(pressed) = event::read()? else {
122 + continue;
123 + };
124 + if pressed.kind != KeyEventKind::Press {
125 + continue;
126 + }
127 +
128 + // Quitting is the app's and not the runtime's: a key that closes the
129 + // binary is a fact about the binary rather than about any screen. It is
130 + // asked first, and only when the caret is not in a box, or `q` would be
131 + // a letter nobody could type.
132 + if !runtime.editing()
133 + && !runtime.asking()
134 + && (matches!(pressed.code, KeyCode::Char('q'))
135 + || (pressed.modifiers.contains(KeyModifiers::CONTROL)
136 + && matches!(pressed.code, KeyCode::Char('c'))))
137 + {
138 + return Ok(());
139 + }
140 +
141 + let Some(key) = translate(pressed.code, pressed.modifiers) else {
142 + continue;
143 + };
144 +
145 + let mut step = runtime.key(key);
146 + question = None;
147 + loop {
148 + match step {
149 + Step::Idle => break,
150 + Step::Ask(prompt) => {
151 + question = Some(prompt);
152 + break;
153 + }
154 + // Nothing comes back from an external address, and a terminal
155 + // has no browser to hand it to. Saying so beats opening
156 + // something the user did not ask for.
157 + Step::Open(address) => {
158 + runtime.say(format!("this goes outside the app: {address}"));
159 + break;
160 + }
161 + Step::Call(request) => {
162 + match router.handle(state, request.clone()) {
163 + Ok(response) => match runtime.apply(&request, response) {
164 + // A response that says to go somewhere else is a
165 + // second request, performed the same way.
166 + Some(next) => step = Step::Call(next),
167 + None => break,
168 + },
169 + Err(error) => {
170 + runtime.say(said(&error));
171 + break;
172 + }
173 + }
174 + }
175 + }
176 + }
177 + }
178 + }
179 +
180 + /// The renderer, in this terminal's colours.
181 + ///
182 + /// Constructed here rather than in `{{name}}-core`, the same way the other two
183 + /// hosts construct theirs. The core names no renderer, which is the whole
184 + /// reason a third one could be added without touching a screen.
185 + fn renderer() -> Tui {
186 + let fidelity = makeover_tui::Fidelity::detect();
187 + let wanted = std::env::var("{{SCREAM}}_THEME").unwrap_or_else(|_| DEFAULT_THEME.to_owned());
188 +
189 + let theme = makeover::bundled_themes_dir()
190 + .ok()
191 + .and_then(|dir| makeover::load_theme(&[(dir, false)], &wanted).ok())
192 + .and_then(|colours| makeover_tui::Theme::from_theme(&colours).ok());
193 +
194 + match theme {
195 + Some(theme) => Tui::new(theme, fidelity),
196 + None => {
197 + eprintln!("{{name}}: no theme called `{wanted}`");
198 + std::process::exit(1);
199 + }
200 + }
201 + }
202 +
203 + /// A route's failure as a sentence for the screen.
204 + fn said(error: &RouteError) -> String {
205 + error.to_string()
206 + }
207 +
208 + /// The screen, and the row a question is asked on.
209 + fn split_bottom(area: Rect, asking: bool) -> (Rect, Rect) {
210 + if !asking || area.height == 0 {
211 + return (area, Rect::new(area.x, area.y, area.width, 0));
212 + }
213 + (
214 + Rect {
215 + height: area.height - 1,
216 + ..area
217 + },
218 + Rect {
219 + y: area.y + area.height - 1,
220 + height: 1,
221 + ..area
222 + },
223 + )
224 + }
225 +
226 + /// This terminal's keys, as the ones the runtime names.
227 + ///
228 + /// The whole of what a host owes the runtime, and the reason `quasi_tui::Key`
229 + /// is its own type rather than crossterm's: the bindings stay testable without
230 + /// a terminal, and a host on another backend writes this function instead of
231 + /// taking a dependency it is not using.
232 + fn translate(code: KeyCode, modifiers: KeyModifiers) -> Option<Key> {
233 + Some(match code {
234 + KeyCode::Char(ch) => Key::Char(ch),
235 + KeyCode::Enter => Key::Enter,
236 + // Shift+Tab arrives as BackTab on most terminals and as Tab carrying
237 + // the modifier on others, so both reach the same binding.
238 + KeyCode::Tab if modifiers.contains(KeyModifiers::SHIFT) => Key::BackTab,
239 + KeyCode::Tab => Key::Tab,
240 + KeyCode::BackTab => Key::BackTab,
241 + KeyCode::Backspace => Key::Backspace,
242 + KeyCode::Esc => Key::Escape,
243 + KeyCode::Up => Key::Up,
244 + KeyCode::Down => Key::Down,
245 + KeyCode::PageUp => Key::PageUp,
246 + KeyCode::PageDown => Key::PageDown,
247 + _ => return None,
248 + })
249 + }