Skip to main content

footers/
footers.rs

1//! Composer footer studies: split band + air gap, with a session title.
2//!
3//! ```sh
4//! cargo run -p omp-tui --example footers
5//! ```
6//!
7//! The direction is settled — a split status band (brand caps left,
8//! session caps right) with breathing room under the working narration.
9//! What's still open is where the *session title* lives: the named task
10//! ("Immutable Commit Placement…"), which is not the same thing as the
11//! current working narration. Each study places that title somewhere
12//! else, pi's border-fused layout included as the reference point.
13//!
14//! Every study animates live — spinner, session timer, and the narration
15//! shimmer all run — so spacing can be judged in motion.
16//!
17//! `↑`/`↓`/`j`/`k` scroll a row, PageUp/PageDown a viewport, Home/End
18//! jump, the mouse wheel scrolls, and `q`, Escape, or Ctrl-C quits.
19
20use std::{
21	io,
22	time::{Duration, Instant},
23};
24
25use omp_core::{Str, fmts};
26use omp_tui::{
27	AltScreenUse, Charset, Color, Frame, Icon, InputEvent, Key, Mouse, Rect, Renderer, Size, Style,
28	Terminal, TerminalEvent, TerminalOptions, TtyOut, UiContext, anim::Shimmer, detect,
29};
30
31// The chat demo's chrome palette, so studies read like the real composer.
32const TEXT: Color = Color::Rgb(194, 198, 204);
33const MUTED: Color = Color::Rgb(110, 116, 124);
34const FAINT: Color = Color::Rgb(72, 78, 86);
35const GREEN: Color = Color::Rgb(81, 196, 112);
36const CYAN: Color = Color::Rgb(62, 190, 203);
37const PURPLE: Color = Color::Rgb(171, 119, 230);
38const GOLD: Color = Color::Rgb(210, 167, 86);
39const BAND_BG: Color = Color::Rgb(18, 18, 18);
40
41const WORKING: &str = "Implementing immutable seam commits";
42const TITLE: &str = "Immutable Commit Placement & Status Bar Layout Options";
43const MODEL: &str = "Fable 5++";
44const GIT: &str = "main *5 +9";
45const CONTEXT: &str = "39.1%/1M";
46const COST: &str = "$60.07 (sub) + $8.65 (adv)";
47const COST_SHORT: &str = "$60.07";
48
49const FRAME_INTERVAL: Duration = Duration::from_millis(33);
50const SHIMMER_PERIOD: Duration = Duration::from_millis(1900);
51
52#[tokio::main(flavor = "current_thread")]
53async fn main() -> io::Result<()> {
54	let caps = detect();
55	let charset = UiContext::default().with_terminal_caps(&caps).charset;
56	let mut terminal = Terminal::enter(TerminalOptions::new(caps).mouse(true))?;
57	let mut renderer = Renderer::new(TtyOut::new()?);
58	renderer.apply_caps(&caps)?;
59	match run(&mut terminal, &mut renderer, charset).await {
60		Ok(()) => terminal.leave_alt(),
61		Err(error) => {
62			let _ = terminal.leave_alt();
63			Err(error)
64		},
65	}
66}
67
68async fn run<'a>(
69	terminal: &'a mut Terminal,
70	renderer: &'a mut Renderer<TtyOut>,
71	charset: Charset,
72) -> io::Result<()> {
73	let started = Instant::now();
74	let mut viewport = terminal.size()?;
75	let mut scroll: u16 = 0;
76	let mut alt_enter = terminal.stage_alt_enter(AltScreenUse::Interactive);
77	loop {
78		tokio::select! {
79			event = terminal.next() => match event? {
80				TerminalEvent::Input(event) => {
81					match event {
82						InputEvent::Key(key) => match key {
83							Key::Char('q') | Key::Esc | Key::Ctrl('c') => return Ok(()),
84							Key::Up | Key::Char('k') => scroll = scroll.saturating_sub(1),
85							Key::Down | Key::Char('j') => scroll = scroll.saturating_add(1),
86							Key::PageUp => scroll = scroll.saturating_sub(viewport.height),
87							Key::PageDown => scroll = scroll.saturating_add(viewport.height),
88							Key::Home => scroll = 0,
89							Key::End => scroll = u16::MAX,
90							_ => {},
91						},
92						InputEvent::Mouse(report) => match report.kind {
93							Mouse::WheelUp => scroll = scroll.saturating_sub(2),
94							Mouse::WheelDown => scroll = scroll.saturating_add(2),
95							_ => {},
96						},
97						InputEvent::Paste(_) | InputEvent::Focus(_) | InputEvent::Response(_) => {},
98					}
99					terminal.sync_renderer(renderer)?;
100				},
101				TerminalEvent::Resize => {
102					if let Some(size) = terminal.take_resize()? {
103						viewport = size;
104					}
105				},
106				TerminalEvent::Debug(_) => {},
107				TerminalEvent::Closed => return Ok(()),
108			},
109			() = tokio::time::sleep(FRAME_INTERVAL) => {},
110		}
111		if viewport.width == 0 || viewport.height == 0 {
112			continue;
113		}
114		let scene = Scene { charset, width: viewport.width, elapsed: started.elapsed() };
115		let document = compose(&scene);
116		scroll = scroll.min(document.size().height.saturating_sub(viewport.height));
117		let mut screen = Frame::new(viewport);
118		screen.fill(Rect::new(0, 0, viewport.width, viewport.height), ink(TEXT));
119		screen.blit(&document, scroll, viewport.height, 0, 0);
120		renderer.preview(&screen, viewport.height, alt_enter.take().as_deref().unwrap_or(""))?;
121	}
122}
123
124/// Per-frame paint inputs shared by every study.
125struct Scene {
126	charset: Charset,
127	width:   u16,
128	elapsed: Duration,
129}
130
131impl Scene {
132	const fn spinner(&self) -> &'static str {
133		self.charset.spinner().at(self.elapsed)
134	}
135
136	fn timer(&self) -> Str {
137		let seconds = self.elapsed.as_secs();
138		if seconds < 60 {
139			fmts!("{seconds}s")
140		} else {
141			fmts!("{}m", seconds / 60)
142		}
143	}
144
145	const fn right_edge(&self) -> u16 {
146		self.width.saturating_sub(1)
147	}
148}
149
150/// One study: a header line and a `rows`-tall live mock beneath it.
151struct Study {
152	title: &'static str,
153	note:  &'static str,
154	rows:  u16,
155	draw:  fn(&mut Frame, u16, &Scene),
156}
157
158const STUDIES: [Study; 6] = [
159	Study {
160		title: "pi parity",
161		note:  "border carries the band left and the title right; the intent rides its own spinner \
162		        row",
163		rows:  4,
164		draw:  study_pi_parity,
165	},
166	Study {
167		title: "gap title",
168		note:  "the air row earns its keep — session title idles right-aligned in the gap",
169		rows:  4,
170		draw:  study_gap_title,
171	},
172	Study {
173		title: "band title",
174		note:  "title as the left band's second segment; session facts dock right",
175		rows:  4,
176		draw:  study_band_title,
177	},
178	Study {
179		title: "prompt title",
180		note:  "split + air untouched; the title rests on the prompt row and yields while typing",
181		rows:  4,
182		draw:  study_prompt_title,
183	},
184	Study {
185		title: "crown",
186		note:  "title crowns the whole block; narration, gap, and split bands breathe below it",
187		rows:  5,
188		draw:  study_crown,
189	},
190	Study {
191		title: "hem",
192		note:  "title stitched into the top border, band into the bottom hem",
193		rows:  4,
194		draw:  study_hem,
195	},
196];
197
198/// Renders the full document — title block plus every study — at width.
199fn compose(scene: &Scene) -> Frame {
200	let height = STUDIES
201		.iter()
202		.map(|study| study.rows + 3)
203		.fold(3_u16, u16::saturating_add);
204	let mut frame = Frame::new(Size::new(scene.width, height));
205	frame.fill(Rect::new(0, 0, scene.width, height), ink(TEXT));
206
207	let column = frame.put(1, 0, "composer footer studies", ink(TEXT).bold());
208	frame.put(
209		column.saturating_add(2),
210		0,
211		"split + air gap, six session-title placements",
212		ink(MUTED),
213	);
214	frame.put(1, 1, "↑/↓ scroll · PgUp/PgDn page · Home/End jump · q quits", ink(FAINT));
215
216	let mut y = 3_u16;
217	for (index, study) in STUDIES.iter().enumerate() {
218		let number = fmts!("{:>2} ", index + 1);
219		let mut column = frame.put(1, y, &number, ink(GOLD).bold());
220		column = frame.put(column, y, study.title, ink(TEXT).bold());
221		column = frame.put(column, y, "  ", ink(FAINT));
222		frame.put(column, y, study.note, ink(MUTED));
223		(study.draw)(&mut frame, y + 1, scene);
224		y = y.saturating_add(study.rows + 3);
225	}
226	frame
227}
228
229// ── studies ─────────────────────────────────────────────────────────────────
230
231/// 1: the pi layout verbatim — a bordered composer whose top border
232/// carries the session band on the left and the title on the right, with
233/// the spinner narrating intent on its own row above the box.
234fn study_pi_parity(frame: &mut Frame, y: u16, scene: &Scene) {
235	draw_working_spin(frame, 1, y, scene);
236	let (tl, tr, bl, br, horizontal, vertical) = border_glyphs(scene.charset);
237	let right = scene.right_edge();
238	draw_border_row(frame, y + 1, scene, tl, tr, horizontal);
239	let segments = [model(scene), omp_brand(scene), git(scene), context(scene), cost()];
240	draw_band(frame, 2, y + 1, scene, &segments);
241	let band_end = 2_u16.saturating_add(band_width(scene, &segments));
242	draw_border_title(frame, y + 1, scene, band_end.saturating_add(2));
243	frame.put(0, y + 2, vertical, ink(FAINT));
244	frame.put(2, y + 2, beam(scene.charset), ink(TEXT));
245	frame.put(right, y + 2, vertical, ink(FAINT));
246	draw_border_row(frame, y + 3, scene, bl, br, horizontal);
247}
248
249/// 2: split + air gap as picked, with the title moving into the air row
250/// so the breathing space doubles as identity.
251fn study_gap_title(frame: &mut Frame, y: u16, scene: &Scene) {
252	draw_working(frame, 1, y, scene);
253	let title = fit_title(scene, scene.width.saturating_sub(2));
254	let x = scene
255		.width
256		.saturating_sub(width_of(&title).saturating_add(1));
257	frame.put(x, y + 1, &title, ink(FAINT).italic());
258	draw_split_bands(frame, y + 2, scene);
259	draw_input(frame, 0, y + 3, scene);
260}
261
262/// 3: the title joins the left band beside the brand segment, so the
263/// band row itself answers "what session is this"; session facts keep
264/// the right cap.
265fn study_band_title(frame: &mut Frame, y: u16, scene: &Scene) {
266	draw_working(frame, 1, y, scene);
267	let right = [model(scene), git(scene), context(scene), Seg::new(COST_SHORT, PURPLE)];
268	let right_width = band_width(scene, &right);
269	let brand_seg = brand(scene);
270	let (_, separator, _) = band_chrome(scene.charset);
271	let fixed = band_width(scene, std::slice::from_ref(&brand_seg))
272		.saturating_add(width_of(separator).saturating_add(2));
273	let budget = scene
274		.width
275		.saturating_sub(right_width.saturating_add(2))
276		.saturating_sub(fixed);
277	let left = [brand_seg, Seg::new(fit_title(scene, budget), TEXT)];
278	draw_band(frame, 0, y + 2, scene, &left);
279	draw_band(frame, scene.width.saturating_sub(right_width), y + 2, scene, &right);
280	draw_input(frame, 0, y + 3, scene);
281}
282
283/// 4: split + air untouched; the title borrows the prompt row's right
284/// edge and would yield to long input lines.
285fn study_prompt_title(frame: &mut Frame, y: u16, scene: &Scene) {
286	draw_working(frame, 1, y, scene);
287	draw_split_bands(frame, y + 2, scene);
288	draw_input(frame, 0, y + 3, scene);
289	let title = fit_title(scene, scene.width.saturating_sub(8));
290	let x = scene
291		.width
292		.saturating_sub(width_of(&title).saturating_add(1));
293	frame.put(x, y + 3, &title, ink(FAINT).italic());
294}
295
296/// 5: the title gets its own dim row above the narration, heading the
297/// whole live block like a section title.
298fn study_crown(frame: &mut Frame, y: u16, scene: &Scene) {
299	let title = fit_title(scene, scene.width.saturating_sub(2));
300	frame.put(1, y, &title, ink(MUTED).bold());
301	draw_working(frame, 1, y + 1, scene);
302	draw_split_bands(frame, y + 3, scene);
303	draw_input(frame, 0, y + 4, scene);
304}
305
306/// 6: a bordered composer again, but the title takes the top border and
307/// the band moves into the bottom hem, so chrome frames the input from
308/// both sides.
309fn study_hem(frame: &mut Frame, y: u16, scene: &Scene) {
310	draw_working(frame, 1, y, scene);
311	let (tl, tr, bl, br, horizontal, vertical) = border_glyphs(scene.charset);
312	let right = scene.right_edge();
313	draw_border_row(frame, y + 1, scene, tl, tr, horizontal);
314	draw_border_title(frame, y + 1, scene, 4);
315	frame.put(0, y + 2, vertical, ink(FAINT));
316	frame.put(2, y + 2, beam(scene.charset), ink(TEXT));
317	frame.put(right, y + 2, vertical, ink(FAINT));
318	draw_border_row(frame, y + 3, scene, bl, br, horizontal);
319	draw_band(frame, 2, y + 3, scene, &full_band(scene));
320}
321
322// ── shared chrome ───────────────────────────────────────────────────────────
323
324/// One status item: a label painted in its identity color.
325struct Seg {
326	label: Str,
327	color: Color,
328}
329
330impl Seg {
331	fn new(label: impl Into<Str>, color: Color) -> Self {
332		Self { label: label.into(), color }
333	}
334}
335
336fn brand(scene: &Scene) -> Seg {
337	Seg::new(fmts!("{} {}", scene.spinner(), scene.timer()), GREEN)
338}
339
340fn omp_brand(scene: &Scene) -> Seg {
341	Seg::new(fmts!("{} omp", scene.charset.icon(Icon::Omp)), MUTED)
342}
343
344fn model(scene: &Scene) -> Seg {
345	Seg::new(fmts!("{} {MODEL}", scene.charset.icon(Icon::Model)), GREEN)
346}
347
348fn git(scene: &Scene) -> Seg {
349	Seg::new(fmts!("{} {GIT}", scene.charset.icon(Icon::Branch)), CYAN)
350}
351
352fn context(scene: &Scene) -> Seg {
353	Seg::new(fmts!("{} {CONTEXT}", scene.charset.icon(Icon::Context)), GOLD)
354}
355
356fn cost() -> Seg {
357	Seg::new(Str::new_static(COST), PURPLE)
358}
359
360fn full_band(scene: &Scene) -> [Seg; 5] {
361	[brand(scene), model(scene), git(scene), context(scene), cost()]
362}
363
364/// The picked split arrangement: brand caps left, session caps right.
365fn draw_split_bands(frame: &mut Frame, y: u16, scene: &Scene) {
366	let left = [brand(scene), model(scene)];
367	let right = [git(scene), context(scene), cost()];
368	draw_band(frame, 0, y, scene, &left);
369	let x = scene.width.saturating_sub(band_width(scene, &right));
370	draw_band(frame, x, y, scene, &right);
371}
372
373/// Status-band chrome per tier, mirroring the `<status>` component.
374const fn band_chrome(charset: Charset) -> (&'static str, &'static str, &'static str) {
375	match charset {
376		Charset::Ascii => ("", ">", ">"),
377		Charset::Unicode => ("", "›", "›"),
378		Charset::NerdFont => ("\u{e0b6}", "\u{e0b1}", "\u{e0b0}"),
379	}
380}
381
382const fn border_glyphs(
383	charset: Charset,
384) -> (&'static str, &'static str, &'static str, &'static str, &'static str, &'static str) {
385	match charset {
386		Charset::Ascii => ("+", "+", "+", "+", "-", "|"),
387		_ => ("╭", "╮", "╰", "╯", "─", "│"),
388	}
389}
390
391const fn beam(charset: Charset) -> &'static str {
392	match charset {
393		Charset::Ascii => "_",
394		_ => "▏",
395	}
396}
397
398const fn ink(color: Color) -> Style {
399	Style::new().fg(color)
400}
401
402fn width_of(text: &str) -> u16 {
403	u16::try_from(xutf::width_str(text)).unwrap_or(u16::MAX)
404}
405
406/// [`TITLE`] truncated to at most `max` cells, ellipsized when it cannot
407/// fit whole.
408fn fit_title(scene: &Scene, max: u16) -> Str {
409	if width_of(TITLE) <= max {
410		return Str::new_static(TITLE);
411	}
412	let ellipsis = match scene.charset {
413		Charset::Ascii => "...",
414		_ => "…",
415	};
416	let budget = max.saturating_sub(width_of(ellipsis));
417	let mut used = 0_u16;
418	let mut end = 0_usize;
419	for grapheme in xutf::graphemes_str(TITLE) {
420		let cells = width_of(grapheme);
421		if used.saturating_add(cells) > budget {
422			break;
423		}
424		used = used.saturating_add(cells);
425		end += grapheme.len();
426	}
427	if end == 0 {
428		return Str::default();
429	}
430	fmts!("{}{ellipsis}", TITLE[..end].trim_end())
431}
432
433/// Total cells a powerline band with `segments` occupies, mirroring the
434/// `<status>` component's measurement.
435fn band_width(scene: &Scene, segments: &[Seg]) -> u16 {
436	let (left_cap, separator, right_cap) = band_chrome(scene.charset);
437	let text = segments
438		.iter()
439		.map(|segment| width_of(&segment.label))
440		.fold(0_u16, u16::saturating_add);
441	let separators = u16::try_from(segments.len().saturating_sub(1))
442		.unwrap_or(u16::MAX)
443		.saturating_mul(width_of(separator).saturating_add(2));
444	text
445		.saturating_add(separators)
446		.saturating_add(width_of(left_cap))
447		.saturating_add(2)
448		.saturating_add(width_of(right_cap))
449}
450
451/// Paints a powerline band at `x`: cap, padded segments, cap.
452fn draw_band(frame: &mut Frame, x: u16, y: u16, scene: &Scene, segments: &[Seg]) {
453	let (left_cap, separator, right_cap) = band_chrome(scene.charset);
454	let base = Style::new().fg(TEXT).bg(BAND_BG);
455	let edge = ink(BAND_BG);
456	let mut column = frame.put(x, y, left_cap, edge);
457	column = frame.put(column, y, " ", base);
458	for (index, segment) in segments.iter().enumerate() {
459		if index > 0 {
460			column = frame.put(column, y, " ", base.dim());
461			column = frame.put(column, y, separator, base.dim());
462			column = frame.put(column, y, " ", base.dim());
463		}
464		column = frame.put(column, y, &segment.label, base.fg(segment.color));
465	}
466	column = frame.put(column, y, " ", base);
467	frame.put(column, y, right_cap, edge);
468}
469
470/// A full-width horizontal border row: corner, rule fill, corner.
471fn draw_border_row(
472	frame: &mut Frame,
473	y: u16,
474	scene: &Scene,
475	left: &str,
476	right: &str,
477	horizontal: &str,
478) {
479	let edge = scene.right_edge();
480	let mut column = frame.put(0, y, left, ink(FAINT));
481	while column < edge {
482		column = frame.put(column, y, horizontal, ink(FAINT));
483	}
484	frame.put(edge, y, right, ink(FAINT));
485}
486
487/// Right-aligns ` TITLE ` into an already-painted border row, keeping two
488/// rule cells before the corner and truncating against `min_x`.
489fn draw_border_title(frame: &mut Frame, y: u16, scene: &Scene, min_x: u16) {
490	let slot_end = scene.right_edge().saturating_sub(2);
491	let title = fit_title(scene, slot_end.saturating_sub(min_x).saturating_sub(2));
492	if title.is_empty() {
493		return;
494	}
495	let x = slot_end.saturating_sub(width_of(&title).saturating_add(2));
496	let column = frame.put(x, y, " ", ink(FAINT));
497	let column = frame.put(column, y, &title, ink(TEXT));
498	frame.put(column, y, " ", ink(FAINT));
499}
500
501/// The shimmering working line: cancel hint, then the narration, riding
502/// one crest sweep exactly like the chat demo's activity row.
503fn draw_working(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
504	let hint = scene.charset.icon(Icon::Cancellable);
505	let length = width_of(hint)
506		.saturating_add(1)
507		.saturating_add(width_of(WORKING));
508	let shimmer = Shimmer::new(scene.elapsed, SHIMMER_PERIOD, length);
509	let mut column = x;
510	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), hint, shimmer, ink(CYAN));
511	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), " ", shimmer, ink(GREEN));
512	draw_shimmer(frame, &mut column, x, y, scene.right_edge(), WORKING, shimmer, ink(GREEN));
513}
514
515/// The pi flavor of the working line: the spinner and timer lead, then
516/// the shimmering narration — the band below carries no brand segment.
517fn draw_working_spin(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
518	let mut column = frame.put(x, y, scene.spinner(), ink(GREEN));
519	column = frame.put(column, y, " ", ink(GREEN));
520	column = frame.put(column, y, &scene.timer(), ink(MUTED));
521	column = frame.put(column, y, " ", ink(MUTED));
522	let shimmer = Shimmer::new(scene.elapsed, SHIMMER_PERIOD, width_of(WORKING));
523	let start = column;
524	draw_shimmer(frame, &mut column, start, y, scene.right_edge(), WORKING, shimmer, ink(GREEN));
525}
526
527/// Paints `text` under the crest, advancing `column`; `start` anchors
528/// cell zero so every segment rides one sweep.
529#[allow(clippy::too_many_arguments, reason = "immediate-mode painter threading frame state")]
530fn draw_shimmer(
531	frame: &mut Frame,
532	column: &mut u16,
533	start: u16,
534	y: u16,
535	right: u16,
536	text: &str,
537	shimmer: Shimmer,
538	high: Style,
539) {
540	for grapheme in xutf::graphemes_str(text) {
541		if *column >= right {
542			return;
543		}
544		let style = shimmer.pick(*column - start, ink(FAINT), ink(MUTED), high);
545		let next = frame.put(*column, y, grapheme, style);
546		if next == *column {
547			return;
548		}
549		*column = next;
550	}
551}
552
553/// The composer prompt row: corner, then the idle cursor beam.
554fn draw_input(frame: &mut Frame, x: u16, y: u16, scene: &Scene) {
555	let prompt = match scene.charset {
556		Charset::Ascii => "+-",
557		_ => "╰─",
558	};
559	let column = frame.put(x, y, prompt, ink(FAINT));
560	frame.put(column.saturating_add(1), y, beam(scene.charset), ink(TEXT));
561}