1use 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
31const 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
124struct 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
150struct 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
198fn 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
229fn 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
249fn 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
262fn 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
283fn 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
296fn 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
306fn 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
322struct 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
364fn 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
373const 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
406fn 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
433fn 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
451fn 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
470fn 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
487fn 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
501fn 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
515fn 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#[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
553fn 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}