escriba_tui/render.rs
1//! Ratatui rendering — draws buffer pane + status line each frame.
2//!
3//! Chrome colors are the **Vellum** fleet theme (warm aged-paper
4//! Nord-matte) — every value is a BORN `ishou_tokens::VellumPalette`
5//! token, so the TUI chrome matches the rest of the fleet (mado, tear,
6//! frostmourne, …) and the GPU backend.
7
8use escriba_core::CursorShape;
9use escriba_runtime::EditorState;
10use escriba_ui::chrome::ChromePalette;
11use ishou_tokens::{EscribaSignals, SignalMode};
12use ratatui::Frame;
13use ratatui::layout::{Constraint, Direction, Layout as RLayout};
14use ratatui::style::{Color, Modifier, Style};
15use ratatui::text::{Line, Span};
16use ratatui::widgets::{Block, Borders, Paragraph};
17
18/// ishou `Rgb` → ratatui `Color::Rgb`. The single conversion point so
19/// every chrome color flows from the BORN Vellum tokens.
20/// Theme-agnostic `ishou` color → ratatui color. (Was `vellum()`, back when
21/// the paint path was hardwired to one theme.)
22fn rgb(c: ishou_tokens::Rgb) -> Color {
23 Color::Rgb(c.r, c.g, c.b)
24}
25
26/// The highlight ecosystem, built ONCE per thread.
27///
28/// `build_ecosystem` constructs tree-sitter hosts; doing that per frame would
29/// make every keystroke pay for grammar registration. The GPU face caches it
30/// on the renderer struct — this face has no such struct, its `draw_frame`
31/// takes `&EditorState`, so the cache lives here.
32fn ecosystem() -> &'static hikari_core::Ecosystem {
33 use std::sync::OnceLock;
34 static ECO: OnceLock<hikari_core::Ecosystem> = OnceLock::new();
35 ECO.get_or_init(escriba_ts::build_ecosystem)
36}
37
38/// Per-line syntax colouring for the visible window: for each row, the
39/// `(start_col, end_col, colour)` runs in CHARACTER columns.
40///
41/// Highlighted over the whole visible slice rather than line by line, exactly
42/// as the GPU face does. Per-line highlighting is easier and wrong: a block
43/// comment or a multi-line string only reads correctly when the highlighter
44/// sees the lines together, and the two faces disagreeing about that is the
45/// drift this repo keeps paying for.
46fn syntax_runs(
47 lines: &[String],
48 path: &str,
49 theme: &escriba_ui::syntax::ChromeSyntax,
50) -> Vec<Vec<(usize, usize, ishou_tokens::Rgb)>> {
51 use hikari_core::Theme as _;
52 let mut text = String::new();
53 let mut starts = Vec::with_capacity(lines.len());
54 for l in lines {
55 starts.push(text.len());
56 text.push_str(l);
57 text.push('\n');
58 }
59 let mut out = vec![Vec::new(); lines.len()];
60 let hl = ecosystem().highlighter_for_path(path);
61 for span in hl.highlight(&text) {
62 let r = span.span.range();
63 let c = theme.color(span.class);
64 let rgb = ishou_tokens::Rgb::new(c.r, c.g, c.b);
65 // Which row does this span start on, and where within it?
66 let Some(row) = starts.iter().rposition(|s| *s <= r.start) else {
67 continue;
68 };
69 let Some(line) = lines.get(row) else { continue };
70 let base = starts[row];
71 // BYTE offsets from the highlighter, CHARACTER columns on screen —
72 // the conversion every multibyte line depends on.
73 let to_col = |byte: usize| line[..byte.min(line.len())].chars().count();
74 let s_col = to_col(r.start.saturating_sub(base));
75 let e_col = to_col(r.end.saturating_sub(base).min(line.len()));
76 if e_col > s_col {
77 out[row].push((s_col, e_col, rgb));
78 }
79 }
80 out
81}
82
83/// How many buffer lines a terminal of `total_height` rows actually shows.
84///
85/// ONE definition, because two is what went wrong. The ratatui face never
86/// wrote `viewport.visible_lines`, on the reasoning that "ratatui auto-picks
87/// up the new size on the next draw" — true of PAINTING and false of the
88/// model. `scroll_to_contain` kept using the constructor's default of 40, so
89/// in any terminal shorter than that the editor believed the cursor was
90/// visible while it had scrolled off the screen. A rendered-frame test now
91/// pins it (`tests/viewport_frame.rs`).
92///
93/// The arithmetic must match `draw_frame`'s split (one row for the status
94/// line) and `draw_buffer`'s own reservation, which is why it lives here
95/// rather than being spelled again in the run loop.
96#[must_use]
97pub fn viewport_rows(total_height: u16) -> u16 {
98 // -1 status line (the layout split), -2 draw_buffer's own reservation.
99 total_height.saturating_sub(3).max(1)
100}
101
102/// Point `state`'s viewport at a terminal of this size.
103///
104/// The ratatui peer of the GPU face's `RenderCallback::resize`. Both faces
105/// have to tell the runtime how much they can show, or the scroll-to-contain
106/// invariant is computed against a window that does not exist.
107pub fn sync_viewport(state: &mut EditorState, width: u16, height: u16) {
108 // Report the frame FIRST — every pane rect is derived from it, so the
109 // solve below must see the new size.
110 state.layout.set_frame(escriba_ui::shikiri::Rect::new(
111 0,
112 0,
113 width,
114 viewport_rows(height),
115 ));
116 // Then give each window ITS pane's size, not the terminal's. Sizing every
117 // window from the whole frame is right for one window and wrong for two:
118 // scroll-to-contain would think a half-height pane could show the whole
119 // screen, and the cursor would sit off the bottom of its own split.
120 //
121 // Widths are the FULL pane width, not minus the gutter — `draw_buffer_in`
122 // subtracts that itself (it depends on the buffer's line count), and the
123 // GPU face splits the same way. Subtracting here too takes it twice.
124 let solved = state.layout.solved();
125 for w in state.layout.windows_mut() {
126 if let Some(r) = solved.rect_of(w.id) {
127 w.viewport.visible_lines = u32::from(r.h).max(1);
128 w.viewport.visible_columns = u32::from(r.w).max(1);
129 }
130 }
131 // A resize moves the WINDOW, not the cursor, so nothing else re-runs
132 // scroll-to-contain. Without this the cursor sits off-screen after a
133 // shrink until the operator happens to move it.
134 state.refollow_cursor();
135}
136
137/// Draw one frame. Call from within `terminal.draw(|f| draw_frame(f, state))`.
138pub fn draw_frame(f: &mut Frame<'_>, state: &EditorState) {
139 let area = f.area();
140 let chunks = RLayout::default()
141 .direction(Direction::Vertical)
142 .constraints([Constraint::Min(3), Constraint::Length(1)])
143 .split(area);
144
145 // The operator's theme, resolved once per frame and handed to every
146 // painter. Read from the EDITOR, not from the fleet default — that is
147 // what makes `(deftheme :preset …)` reach the screen.
148 let chrome = state.chrome();
149
150 // The start screen replaces the buffer pane rather than overlaying it:
151 // there is nothing behind it worth showing (escriba only raises it on
152 // an empty scratch buffer), and an overlay would have to reason about
153 // what it is covering.
154 match state.splash() {
155 Some(splash) => draw_splash(f, chunks[0], splash, &chrome),
156 None => {
157 // One pane per leaf, geometry DERIVED. `solved()` is a pure
158 // function of (tree, frame) — nothing here stores a rect, so a
159 // split and a resize cannot disagree about where a pane is.
160 // Solved against the area we are ACTUALLY painting, not against
161 // a frame remembered from an earlier `sync_viewport` call.
162 //
163 // Reading the stored frame made rendering depend on call order:
164 // a face that had not reported its size yet solved to a 0x0
165 // frame, every pane came back zero-area, and the screen went
166 // BLANK with nothing to indicate why. The area is right here in
167 // the draw; taking it from anywhere else is a second source of
168 // truth for the same number.
169 let solved = escriba_ui::shikiri::solve(
170 state.layout.tree(),
171 escriba_ui::shikiri::Rect::new(0, 0, chunks[0].width, chunks[0].height),
172 );
173 for (id, r) in &solved.panes {
174 // A degraded frame yields zero-area panes; skip rather than
175 // paint into nothing. This is the stated limit of `solve`.
176 if r.w == 0 || r.h == 0 {
177 continue;
178 }
179 let area = ratatui::layout::Rect {
180 x: chunks[0].x + r.x,
181 y: chunks[0].y + r.y,
182 width: r.w.min(chunks[0].width.saturating_sub(r.x)),
183 height: r.h.min(chunks[0].height.saturating_sub(r.y)),
184 };
185 draw_pane(f, area, state, *id, &chrome);
186 }
187 for rule in &solved.rules {
188 draw_rule(f, chunks[0], rule, &chrome);
189 }
190 }
191 }
192 draw_status_line(f, chunks[1], state, &chrome);
193 // The picker floats OVER the pane — painted last so it occludes, and
194 // outside the splash/buffer match because it is not an alternative to
195 // either. This is the first real overlay; the start screen replaces its
196 // pane rather than floating, which is why it could never have proven
197 // occlusion.
198 if let Some(p) = state.picker() {
199 draw_picker(f, chunks[0], p, &chrome);
200 }
201}
202
203/// Paint the start screen.
204///
205/// All the layout arithmetic lives in `escriba_ui::splash`; this walks the
206/// rows it hands back and colors each span by ROLE. That is the whole reason
207/// the model exists — the GPU and text faces run the same two loops over the
208/// same rows, so the three faces cannot lay the screen out three ways.
209fn draw_splash(
210 f: &mut Frame<'_>,
211 area: ratatui::layout::Rect,
212 splash: &escriba_ui::splash::Splash,
213 chrome: &ChromePalette,
214) {
215 let ground = Style::default()
216 .fg(rgb(chrome.text))
217 .bg(rgb(chrome.background));
218 f.render_widget(Block::default().borders(Borders::NONE).style(ground), area);
219
220 for row in splash.rows(area.width, area.height) {
221 let spans: Vec<Span<'static>> = row
222 .spans
223 .iter()
224 .map(|s| {
225 Span::styled(
226 s.text.clone(),
227 ground.fg(rgb(s.role.color(chrome))).add_modifier(
228 // The wordmark and the menu keys carry the weight;
229 // everything else stays quiet so they can.
230 if matches!(
231 s.role,
232 escriba_ui::splash::SplashRole::Art
233 | escriba_ui::splash::SplashRole::MenuKey
234 ) {
235 Modifier::BOLD
236 } else {
237 Modifier::empty()
238 },
239 ),
240 )
241 })
242 .collect();
243 let line_area = ratatui::layout::Rect {
244 x: area.x + row.col,
245 y: area.y + row.row,
246 width: area.width.saturating_sub(row.col),
247 height: 1,
248 };
249 f.render_widget(Paragraph::new(Line::from(spans)).style(ground), line_area);
250 }
251}
252
253/// Paint the picker as a centred floating panel.
254fn draw_picker(
255 f: &mut Frame<'_>,
256 area: ratatui::layout::Rect,
257 picker: &escriba_ui::picker::Picker,
258 chrome: &ChromePalette,
259) {
260 // Centred, and bounded so it never exceeds its pane — a surface that can
261 // be drawn outside its container is a panic waiting for a small terminal.
262 let w = area.width.saturating_mul(3) / 4;
263 let h = (picker.visible_count() as u16 + 3)
264 .min(area.height.saturating_sub(2))
265 .max(3);
266 let panel = ratatui::layout::Rect {
267 x: area.x + area.width.saturating_sub(w) / 2,
268 y: area.y + area.height.saturating_sub(h) / 2,
269 width: w.min(area.width),
270 height: h.min(area.height),
271 };
272
273 let ground = Style::default()
274 .fg(rgb(chrome.text))
275 .bg(rgb(chrome.surface));
276 f.render_widget(ratatui::widgets::Clear, panel);
277
278 let mut lines: Vec<Line<'static>> = Vec::with_capacity(panel.height as usize);
279 let mut title = String::from(" ");
280 title.push_str(picker.source().title());
281 title.push_str(" ");
282 title.push_str(picker.query());
283 lines.push(Line::from(Span::styled(
284 title,
285 ground.fg(rgb(chrome.accent)).add_modifier(Modifier::BOLD),
286 )));
287 for (label, selected) in picker.rows() {
288 let mut row = String::with_capacity(label.len() + 2);
289 row.push_str(if selected { "> " } else { " " });
290 row.push_str(&label);
291 lines.push(Line::from(Span::styled(
292 row,
293 if selected {
294 ground.fg(rgb(chrome.background)).bg(rgb(chrome.accent))
295 } else {
296 ground
297 },
298 )));
299 }
300
301 let block = Block::default()
302 .borders(Borders::ALL)
303 .border_style(Style::default().fg(rgb(chrome.accent)))
304 .style(ground);
305 f.render_widget(Paragraph::new(lines).block(block), panel);
306}
307
308/// Paint the separator between two panes.
309///
310/// A one-cell rule, dim, in the theme's own `text_dim`. Drawn from the SOLVED
311/// rules rather than inferred from pane edges: inferring means two places
312/// deciding where the boundary is, and they disagree the moment a pane is
313/// zero-width.
314fn draw_rule(
315 f: &mut Frame<'_>,
316 origin: ratatui::layout::Rect,
317 rule: &escriba_ui::shikiri::Rule,
318 chrome: &ChromePalette,
319) {
320 // HEAVY box-drawing, deliberately. The GUTTER already draws a light
321 // `│` between the line numbers and the text, so a light pane separator
322 // is indistinguishable from it — the operator cannot tell "this is
323 // another window" from "this is the same window's gutter". One glyph
324 // meaning two things is a reader's problem whichever they learn first,
325 // which this codebase already learned from the `●` finding-mark that
326 // collided with the status line's modified indicator.
327 let glyph = match rule.axis {
328 escriba_ui::shikiri::Axis::Stacked => "\u{2501}", // ━
329 escriba_ui::shikiri::Axis::SideBySide => "\u{2503}", // ┃
330 };
331 let r = rule.rect;
332 let area = ratatui::layout::Rect {
333 x: origin.x + r.x,
334 y: origin.y + r.y,
335 width: r.w.min(origin.width.saturating_sub(r.x)),
336 height: r.h.min(origin.height.saturating_sub(r.y)),
337 };
338 if area.width == 0 || area.height == 0 {
339 return;
340 }
341 let line: String = glyph.repeat(area.width as usize);
342 let style = Style::default()
343 .fg(rgb(chrome.text_dim))
344 .bg(rgb(chrome.background));
345 for y in 0..area.height {
346 let row = ratatui::layout::Rect {
347 y: area.y + y,
348 height: 1,
349 ..area
350 };
351 f.render_widget(
352 Paragraph::new(Line::from(Span::styled(line.clone(), style))),
353 row,
354 );
355 }
356}
357
358/// Paint ONE pane — the window `id`, in `area`.
359fn draw_pane(
360 f: &mut Frame<'_>,
361 area: ratatui::layout::Rect,
362 state: &EditorState,
363 id: escriba_core::WindowId,
364 chrome: &ChromePalette,
365) {
366 let Some(win) = state.layout.windows().find(|w| w.id == id) else {
367 return;
368 };
369 draw_buffer_in(f, area, state, win, chrome);
370}
371
372fn draw_buffer_in(
373 f: &mut Frame<'_>,
374 area: ratatui::layout::Rect,
375 state: &EditorState,
376 win: &escriba_ui::Window,
377 chrome: &ChromePalette,
378) {
379 // THIS window's buffer, not the editor's active one. Reading
380 // `state.active` here would paint every pane with the focused pane's
381 // file — a split showing two different files is the entire point.
382 let Some(buf) = state.buffers.get(win.buffer_id) else {
383 f.render_widget(
384 Paragraph::new("<no buffer>").style(error_style(chrome)),
385 area,
386 );
387 return;
388 };
389
390 // …and THIS window's scroll position. Two panes on one buffer scroll
391 // independently; that is what makes `:sp` useful for comparing two places
392 // in one file.
393 let top = win.viewport.top_line;
394 let left = win.viewport.left_column;
395 // The gutter's width derives from the buffer, so every line of THIS
396 // buffer agrees and the text column cannot move while scrolling. The old
397 // comment here claimed a fixed 7 columns; it was never 7 (the mark cell
398 // made it 8) and it was never fixed (a 10 000-line file needs 9).
399 let line_count = buf.line_count();
400 let gutter_cols = escriba_ui::gutter::gutter_width(line_count);
401 // Sized from the PANE, not the terminal — `area` is what this window
402 // actually got from `solve`.
403 let vis_cols = (area.width as usize).saturating_sub(gutter_cols);
404 let visible = area.height.saturating_sub(2).max(1);
405 // The cursor is painted in the FOCUSED pane only. An unfocused pane
406 // showing a block cursor would claim a focus it does not have, and with
407 // two panes on one buffer both would appear active.
408 let focused = win.id == state.layout.active();
409 let cursor = if focused {
410 state.cursor()
411 } else {
412 escriba_core::Position::new(u32::MAX, u32::MAX)
413 };
414 // The cursor's on-screen shape is derived from the active mode through
415 // the one typed `Mode::cursor_shape` function — block in Normal/Command,
416 // bar in Insert, underline in Visual. Both backends read it from there,
417 // so the shapes can't drift apart.
418 let shape = state.modal.mode().cursor_shape();
419
420 // The visible slice, gathered BEFORE painting so the highlighter sees the
421 // rows together — a block comment or multi-line string only reads right
422 // that way.
423 let visible_text: Vec<String> = (0..visible as u32)
424 .map_while(|row| {
425 let ln = top + row;
426 (ln < buf.line_count()).then(|| {
427 buf.line(ln)
428 .unwrap_or_default()
429 .trim_end_matches('\n')
430 .trim_end_matches('\r')
431 .to_string()
432 })
433 })
434 .collect();
435 let path = buf
436 .path
437 .as_ref()
438 .map(|p| p.to_string_lossy().into_owned())
439 .unwrap_or_default();
440 let syntax = syntax_runs(
441 &visible_text,
442 &path,
443 &escriba_ui::syntax::ChromeSyntax::new(*chrome),
444 );
445
446 let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible as usize);
447 for row in 0..visible as u32 {
448 let ln = top + row;
449 if ln >= buf.line_count() {
450 break;
451 }
452 let Some(line_str) = buf.line(ln) else {
453 continue;
454 };
455 let text = line_str
456 .trim_end_matches('\n')
457 .trim_end_matches('\r')
458 .to_string();
459 // Search matches are DOCUMENT char offsets; the renderer paints
460 // COLUMNS. Translate once per line via the line's own start offset,
461 // so no offset arithmetic leaks into the span builder.
462 let line_start = buf
463 .position_to_char(escriba_core::Position::new(ln, 0))
464 .unwrap_or(0);
465 let line_len = text.chars().count();
466 let hl: Vec<(usize, usize)> = state
467 .search
468 .highlights()
469 .iter()
470 .filter_map(|m| {
471 // Clip the match to this line; a multi-line match paints its
472 // overlapping part on each line it crosses.
473 let s = m.start.saturating_sub(line_start);
474 let e = m.end.saturating_sub(line_start);
475 (m.end > line_start && m.start < line_start + line_len + 1)
476 .then(|| (s.min(line_len), e.min(line_len)))
477 })
478 .filter(|(s, e)| e > s)
479 .collect();
480 // The worst finding on this line, if any — one cell, always, so a
481 // diagnostic arriving does not shift every line sideways.
482 let mark = state
483 .results
484 .worst_on_line(&state.world(), state.active, ln);
485 lines.push(line_with_gutter(
486 chrome,
487 mark,
488 ln,
489 line_count,
490 syntax.get(row as usize).map_or(&[][..], Vec::as_slice),
491 &text,
492 cursor,
493 left as usize,
494 vis_cols,
495 shape,
496 &hl,
497 ));
498 }
499
500 let block = Block::default()
501 .borders(Borders::NONE)
502 .style(buffer_style(chrome));
503 f.render_widget(Paragraph::new(lines).block(block), area);
504}
505
506/// Render one line with a gutter, sliced horizontally to the visible
507/// column window `[left, left + vis_cols)`. Slicing is char-based (not
508/// byte-based) so multibyte text stays aligned, and the cursor's on-screen
509/// column is computed relative to `left` so the cursor glyph tracks the
510/// horizontal scroll.
511fn line_with_gutter(
512 chrome: &ChromePalette,
513 mark: Option<escriba_shirube::Severity>,
514 ln: u32,
515 // The buffer's total line count — the gutter's width derives from it, so
516 // every line of one buffer agrees. Passed in rather than read here so a
517 // test can render a line without standing up an `EditorState`.
518 line_count: u32,
519 // Syntax colouring for THIS line, in character columns.
520 syntax: &[(usize, usize, ishou_tokens::Rgb)],
521 text: &str,
522 cursor: escriba_core::Position,
523 left: usize,
524 vis_cols: usize,
525 shape: CursorShape,
526 highlights: &[(usize, usize)],
527) -> Line<'static> {
528 // The gutter is COMPOSED by `escriba_ui::gutter`, not here. This face's
529 // only job is to turn each cell's role into a ratatui `Style` — which is
530 // what makes the GPU face able to paint the identical gutter by answering
531 // the same question in its own colours.
532 let mut spans: Vec<Span<'static>> = escriba_ui::gutter::gutter_cells(ln, mark, line_count)
533 .into_iter()
534 .map(|c| {
535 let style = match c.role {
536 escriba_ui::gutter::GutterRole::Mark(sev) => {
537 Style::default().fg(rgb(escriba_ui::chrome::severity_color(chrome, sev)))
538 }
539 _ => muted_style(chrome),
540 };
541 Span::styled(c.text, style)
542 })
543 .collect();
544
545 let chars: Vec<char> = text.chars().collect();
546 // The slice of characters actually visible in this window.
547 let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
548
549 // One style slot per visible cell. Painting cell-by-cell and coalescing
550 // afterwards is what lets the cursor and any number of search matches
551 // overlap on the same line — the previous before/cursor/after split could
552 // only ever express ONE styled region, so highlights had nowhere to go.
553 let mut cell_styles: Vec<Option<Style>> = vec![None; visible.len()];
554 // Syntax FIRST, so a search match paints over it. The precedence is
555 // deliberate and reads bottom-up at the call sites below: syntax, then
556 // search, then the cursor — each one is a more urgent thing to see than
557 // the one under it.
558 for &(ss, se, colour) in syntax {
559 for col in ss..se {
560 if col >= left {
561 if let Some(slot) = cell_styles.get_mut(col - left) {
562 *slot = Some(Style::default().fg(rgb(colour)));
563 }
564 }
565 }
566 }
567 for &(hs, he) in highlights {
568 for col in hs..he {
569 if col >= left {
570 if let Some(slot) = cell_styles.get_mut(col - left) {
571 *slot = Some(search_match_style(chrome));
572 }
573 }
574 }
575 }
576
577 // The cursor wins over a highlight on its own cell — you must always be
578 // able to see where you are, even sitting on a match.
579 let cursor_here = (ln == cursor.line && cursor.column as usize >= left)
580 .then(|| cursor.column as usize - left);
581
582 if let Some(rel) = cursor_here {
583 if rel >= visible.len() {
584 push_runs(&mut spans, &visible, &cell_styles);
585 spans.extend(cursor_spans(chrome, ' ', shape));
586 return Line::from(spans);
587 }
588 push_runs(&mut spans, &visible[..rel], &cell_styles[..rel]);
589 spans.extend(cursor_spans(chrome, visible[rel], shape));
590 push_runs(&mut spans, &visible[rel + 1..], &cell_styles[rel + 1..]);
591 } else {
592 push_runs(&mut spans, &visible, &cell_styles);
593 }
594
595 Line::from(spans)
596}
597
598/// Emit `chars` as the fewest spans that preserve `styles`, merging adjacent
599/// cells that share a style. Without the merge a 200-column line would emit
600/// 200 single-char spans every frame.
601fn push_runs(spans: &mut Vec<Span<'static>>, chars: &[char], styles: &[Option<Style>]) {
602 debug_assert_eq!(chars.len(), styles.len(), "one style slot per cell");
603 let mut i = 0;
604 while i < chars.len() {
605 let style = styles.get(i).copied().flatten();
606 let mut j = i + 1;
607 while j < chars.len() && styles.get(j).copied().flatten() == style {
608 j += 1;
609 }
610 let run: String = chars[i..j].iter().collect();
611 spans.push(match style {
612 Some(st) => Span::styled(run, st),
613 None => Span::raw(run),
614 });
615 i = j;
616 }
617}
618
619/// Render the cell under the cursor in its per-mode [`CursorShape`].
620///
621/// - [`CursorShape::Block`]: fill the cell (dark glyph on the cursor color)
622/// — the Normal/Command "you are here" indicator.
623/// - [`CursorShape::Bar`]: a thin vertical bar drawn BEFORE the glyph
624/// (Insert mode's between-glyphs caret), the glyph itself left plain.
625/// - [`CursorShape::Underline`]: the glyph with an underline modifier
626/// (Visual mode), so the highlighted selection stays readable.
627fn cursor_spans(c: &ChromePalette, under: char, shape: CursorShape) -> Vec<Span<'static>> {
628 match shape {
629 CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style(c))],
630 CursorShape::Bar => vec![
631 Span::styled("▏".to_string(), cursor_bar_style(c)),
632 Span::raw(under.to_string()),
633 ],
634 CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style(c))],
635 }
636}
637
638fn draw_status_line(
639 f: &mut Frame<'_>,
640 area: ratatui::layout::Rect,
641 state: &EditorState,
642 chrome: &ChromePalette,
643) {
644 // ONE model, read once. The pill and the prompt both derive from it, so
645 // they cannot describe two different states of the same editor.
646 let model = state.status_model();
647 let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
648 // Status glyphs are the BORN fleet vocabulary (`ishou_tokens::EscribaSignals`),
649 // not hand-picked literals. Single-width `Glyph` mode keeps the
650 // status-line column alignment-safe.
651 let sig = EscribaSignals::prescribed();
652
653 // The pill leads with the OPEN PROMPT'S sigil when there is one, and the
654 // mode glyph otherwise. Search reuses `Mode::Command` (vim's `/` IS the
655 // command line), so painting the raw mode drew `: COMMAND` for a search
656 // — a status line character-for-character identical to the one `:`
657 // produces. That is how a fully working search reads as "pressing `/`
658 // put me in `:` mode": the editor was right and its report was wrong.
659 let mut pill = String::with_capacity(16);
660 pill.push(' ');
661 match model.pill_sigil() {
662 Some(sigil) => pill.push(sigil),
663 None => pill.push_str(mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph)),
664 }
665 pill.push(' ');
666 pill.push_str(model.mode_label());
667 pill.push(' ');
668 let mode_span = Span::styled(pill, pill_style_for(chrome, &model, state.modal.mode()));
669
670 // vim puts the command line bottom-LEFT, where the eye already is. This
671 // prompt used to render at the far RIGHT, wedged between the match count
672 // and the cursor position — `/foo` was on screen and nobody saw it. When
673 // a prompt is open it takes the slot the path occupies, the way vim's
674 // cmdline covers the status text.
675 let context_span = if model.pill_sigil().is_some() {
676 let mut line = String::from(" ");
677 model.render_prompt_into(&mut line);
678 line.push(' ');
679 Span::styled(line, cmd_style(chrome))
680 } else {
681 let path = state
682 .buffers
683 .get(state.active)
684 .and_then(|b| b.path.clone())
685 .map_or("scratch".to_string(), |p| p.display().to_string());
686 let modified = state.buffers.get(state.active).is_some_and(|b| b.modified);
687 let modified_indicator = if modified {
688 format!(" {}", sig.modified.render(SignalMode::Glyph))
689 } else {
690 String::new()
691 };
692 Span::styled(
693 format!(" {path}{modified_indicator} "),
694 status_style(chrome),
695 )
696 };
697 let pos_span = Span::styled(format!(" {pos} "), status_style(chrome));
698
699 // `[3/17]`. Both halves were already computed by the engine and both were
700 // discarded; the denominator is what turns "press n until it looks right"
701 // into a decision — `[1/1]` says a rename is safe, `[1/240]` says narrow
702 // the pattern first. Silent when there is nothing to count.
703 let count = model.count;
704 let count_span = if count.is_idle() {
705 Span::raw("")
706 } else {
707 let mut c = String::from(" ");
708 count.render_into(&mut c);
709 c.push(' ');
710 Span::styled(c, status_style(chrome))
711 };
712
713 // Layout: [pill] [prompt-or-path] … (flex) … [count] [pos]
714 let available = usize::from(area.width);
715 let left = mode_span.content.chars().count() + context_span.content.chars().count();
716 let right = count_span.content.chars().count() + pos_span.content.chars().count();
717 let pad = available.saturating_sub(left + right);
718
719 // Where the caret would land, measured off the spans actually being
720 // painted rather than off a hand-counted constant — the pill's width
721 // changes with the mode label, so a literal here would drift the first
722 // time `SEARCH` became something longer.
723 let prompt_caret_col = model.prompt_caret_offset().map(|off| {
724 mode_span.content.chars().count()
725 + 1 // the context span's own leading space
726 + off
727 });
728
729 let line = Line::from(vec![
730 mode_span,
731 context_span,
732 Span::raw(" ".repeat(pad)),
733 count_span,
734 pos_span,
735 ]);
736 f.render_widget(Paragraph::new(line).style(status_style(chrome)), area);
737
738 // Park the terminal cursor in the prompt while one is open. Without this
739 // the caret is invisible: `←`/`→`/`Home`/`<C-w>` all worked on the model
740 // and nothing on screen moved, so correcting the middle of a pattern was
741 // guesswork. Ratatui hides the cursor unless a frame asks for it, so this
742 // is also what makes the prompt look focused at all.
743 if let Some(col) = prompt_caret_col {
744 if let Ok(col) = u16::try_from(col) {
745 // Clamp INTO the line rather than skipping the call on overflow —
746 // a pattern longer than the terminal is wide should pin the caret
747 // at the edge, not make it vanish.
748 let x = area.x + col.min(area.width.saturating_sub(1));
749 f.set_cursor_position((x, area.y));
750 }
751 }
752}
753
754// ─── Styles — Vellum (warm aged-paper Nord-matte) ───────────────────────
755//
756// Every chrome color resolves through `escriba_ui::chrome::ChromePalette`
757// — the one theme seam, shared with the GPU backend so the two faces cannot
758// drift apart. Colors are named by ROLE (text / surface / cursor / error),
759// never by a theme's own token spelling, which is what lets the theme change
760// without touching a single call site here.
761//
762// Each helper takes the LIVE palette rather than reading
763// `ChromePalette::prescribed()` for itself. That parameter is the whole
764// theming fix: while these read the prescribed value directly, an operator
765// could author `(deftheme :preset "vellum")`, watch it parse, validate and
766// resolve to a real `FleetTheme` — and see the editor paint Nord anyway,
767// because nothing downstream consumed it. A palette that arrives as an
768// argument cannot be ignored.
769
770fn buffer_style(c: &ChromePalette) -> Style {
771 Style::default().fg(rgb(c.text)).bg(rgb(c.background))
772}
773
774fn muted_style(c: &ChromePalette) -> Style {
775 Style::default().fg(rgb(c.text_dim)) // comment / gutter
776}
777
778/// Block cursor (Normal / Command) — dark glyph filled onto the cursor
779/// color, the "you are here" cell.
780fn cursor_block_style(c: &ChromePalette) -> Style {
781 Style::default()
782 .fg(rgb(c.background)) // ground-colored text on the cursor
783 .bg(rgb(c.cursor))
784 .add_modifier(Modifier::BOLD)
785}
786
787/// Bar cursor (Insert) — the thin vertical caret drawn between glyphs,
788/// colored in the cursor accent.
789fn cursor_bar_style(c: &ChromePalette) -> Style {
790 Style::default()
791 .fg(rgb(c.cursor))
792 .add_modifier(Modifier::BOLD)
793}
794
795/// Underline cursor (Visual) — the glyph kept legible with an underline in
796/// the cursor accent.
797/// Style for a search match under `hlsearch`.
798///
799/// Reversed against the `warning` role rather than a literal colour: it reads
800/// as "look here" without colliding with `cursor` (which must stay
801/// distinguishable when the cursor sits ON a match) or with `error`. Sourced
802/// from ChromePalette so it follows the fleet theme like every other style
803/// here — a hardcoded hex would be the one span that ignores the theme.
804fn search_match_style(c: &ChromePalette) -> Style {
805 Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
806}
807
808fn cursor_underline_style(c: &ChromePalette) -> Style {
809 Style::default()
810 .fg(rgb(c.cursor))
811 .add_modifier(Modifier::UNDERLINED)
812 .add_modifier(Modifier::BOLD)
813}
814
815fn status_style(c: &ChromePalette) -> Style {
816 // Was a raw `Color::Rgb(0xCD, 0xC7, 0xB6)` literal ("statusline_fg,
817 // Vellum extra") — the one genuinely hardcoded color in this file, and
818 // dead weight the moment the theme moved. It is now the `text` role.
819 Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
820}
821
822fn cmd_style(c: &ChromePalette) -> Style {
823 Style::default()
824 .fg(rgb(c.warning))
825 .bg(rgb(c.surface))
826 .add_modifier(Modifier::BOLD)
827}
828
829fn error_style(c: &ChromePalette) -> Style {
830 Style::default().fg(rgb(c.error)).bg(rgb(c.background))
831}
832
833/// Map an editor [`Mode`](escriba_core::Mode) to its fleet
834/// [`Signal`](ishou_tokens::Signal) from [`EscribaSignals`].
835///
836/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal
837/// set has one visual signal, matching how [`mode_style_for`] groups the
838/// two under one pill color.
839fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
840 match mode {
841 escriba_core::Mode::Normal => &sig.mode_normal,
842 escriba_core::Mode::Insert => &sig.mode_insert,
843 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
844 escriba_core::Mode::Command => &sig.mode_command,
845 }
846}
847
848fn mode_style_for(c: &ChromePalette, mode: escriba_core::Mode) -> Style {
849 // Mode pills — ground-colored text on a role-colored field:
850 // Normal info, Insert success, Visual accent, Command warning. Naming
851 // the ROLE rather than the hue is what keeps these correct across
852 // themes: on Nord `info` is frost blue, on Vellum it was ice cyan, and
853 // neither call site has to know.
854 let bg = match mode {
855 escriba_core::Mode::Normal => c.info,
856 escriba_core::Mode::Insert => c.success,
857 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
858 escriba_core::Mode::Command => c.warning,
859 };
860 Style::default()
861 .fg(rgb(c.background))
862 .bg(rgb(bg))
863 .add_modifier(Modifier::BOLD)
864}
865
866/// The pill's style, chosen from the status MODEL rather than the raw mode.
867///
868/// A search and an ex-command share `Mode::Command`, so [`mode_style_for`]
869/// alone paints them identically — same colour, and (before this) the same
870/// `: COMMAND` text. The search gets the accent field so the two prompts are
871/// distinguishable at a glance, not only by reading the label.
872fn pill_style_for(
873 c: &ChromePalette,
874 model: &escriba_runtime::StatusModel<'_>,
875 mode: escriba_core::Mode,
876) -> Style {
877 if model.prompt.is_search() {
878 return Style::default()
879 .fg(rgb(c.background))
880 .bg(rgb(c.accent))
881 .add_modifier(Modifier::BOLD);
882 }
883 mode_style_for(c, mode)
884}
885
886#[cfg(test)]
887mod tests {
888
889 // ── search highlight rendering ────────────────────────────────────
890
891 /// The palette the render tests paint with. A FIXED theme, not the
892 /// live one: these assert LAYOUT and span structure, and pinning them
893 /// to whatever the fleet currently prescribes would make them rewrite
894 /// themselves on every theme move (which is exactly what happened to
895 /// the mode-pill test before it started asserting roles).
896 fn chrome() -> ChromePalette {
897 ChromePalette::prescribed()
898 }
899
900 fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
901 // (text, is-highlighted) — comparing against the exact Style would
902 // pin the palette, which is a theming concern, not a layout one.
903 spans
904 .iter()
905 .map(|sp| {
906 (
907 sp.content.to_string(),
908 sp.style.bg == search_match_style(&chrome()).bg,
909 )
910 })
911 .collect()
912 }
913
914 #[test]
915 fn push_runs_merges_adjacent_cells_of_equal_style() {
916 // A 200-column line must not emit 200 spans per frame.
917 let chars: Vec<char> = "aaaabbbb".chars().collect();
918 let mut styles = vec![None; 8];
919 for slot in styles.iter_mut().take(4) {
920 *slot = Some(search_match_style(&chrome()));
921 }
922 let mut spans = vec![];
923 push_runs(&mut spans, &chars, &styles);
924 assert_eq!(spans.len(), 2, "one span per run, not per char");
925 assert_eq!(spans[0].content, "aaaa");
926 assert_eq!(spans[1].content, "bbbb");
927 }
928
929 #[test]
930 fn push_runs_on_empty_input_emits_nothing() {
931 let mut spans = vec![];
932 push_runs(&mut spans, &[], &[]);
933 assert!(spans.is_empty());
934 }
935
936 #[test]
937 fn a_match_is_painted_and_the_rest_is_not() {
938 // "hello world", match on "world" (cols 6..11), cursor elsewhere.
939 let line = line_with_gutter(
940 &chrome(),
941 None,
942 0,
943 64,
944 &[],
945 "hello world",
946 escriba_core::Position::new(9, 0), // cursor on another line
947 0,
948 80,
949 CursorShape::Block,
950 &[(6, 11)],
951 );
952 let painted: Vec<String> = styles_of(&line.spans)
953 .into_iter()
954 .filter(|(_, hl)| *hl)
955 .map(|(t, _)| t)
956 .collect();
957 assert_eq!(
958 painted,
959 vec!["world".to_string()],
960 "exactly the match is lit"
961 );
962 }
963
964 #[test]
965 fn two_matches_on_one_line_are_both_painted() {
966 // The old before/cursor/after split could express only ONE styled
967 // region — this is the case it structurally could not render.
968 let line = line_with_gutter(
969 &chrome(),
970 None,
971 0,
972 64,
973 &[],
974 "foo bar foo",
975 escriba_core::Position::new(9, 0),
976 0,
977 80,
978 CursorShape::Block,
979 &[(0, 3), (8, 11)],
980 );
981 let painted: Vec<String> = styles_of(&line.spans)
982 .into_iter()
983 .filter(|(_, hl)| *hl)
984 .map(|(t, _)| t)
985 .collect();
986 assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
987 }
988
989 #[test]
990 fn the_cursor_stays_visible_when_sitting_on_a_match() {
991 // A highlight must never swallow the cursor cell, or you lose your
992 // place the moment you land on a match — which is always, after `n`.
993 let line = line_with_gutter(
994 &chrome(),
995 None,
996 0,
997 64,
998 &[],
999 "foo bar",
1000 escriba_core::Position::new(0, 1),
1001 0,
1002 80,
1003 CursorShape::Block,
1004 &[(0, 3)],
1005 );
1006 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
1007 assert!(
1008 texts.contains(&"o".to_string()),
1009 "cursor cell rendered alone: {texts:?}"
1010 );
1011 }
1012
1013 #[test]
1014 fn highlights_respect_horizontal_scroll() {
1015 // Scrolled right by 4: the match at cols 6..11 must shift left by 4.
1016 let line = line_with_gutter(
1017 &chrome(),
1018 None,
1019 0,
1020 64,
1021 &[],
1022 "hello world",
1023 escriba_core::Position::new(9, 0),
1024 4,
1025 80,
1026 CursorShape::Block,
1027 &[(6, 11)],
1028 );
1029 let painted: Vec<String> = styles_of(&line.spans)
1030 .into_iter()
1031 .filter(|(_, hl)| *hl)
1032 .map(|(t, _)| t)
1033 .collect();
1034 assert_eq!(
1035 painted,
1036 vec!["world".to_string()],
1037 "still exactly the match"
1038 );
1039 }
1040
1041 #[test]
1042 fn no_highlights_renders_a_plain_line() {
1043 let line = line_with_gutter(
1044 &chrome(),
1045 None,
1046 0,
1047 64,
1048 &[],
1049 "hello world",
1050 escriba_core::Position::new(9, 0),
1051 0,
1052 80,
1053 CursorShape::Block,
1054 &[],
1055 );
1056 assert!(
1057 styles_of(&line.spans).iter().all(|(_, hl)| !hl),
1058 "nothing lit"
1059 );
1060 }
1061 use super::*;
1062 use escriba_core::Mode;
1063
1064 /// Forcing function: the status-line mode glyphs are sourced from the
1065 /// fleet `EscribaSignals` vocabulary, not hand-picked literals.
1066 #[test]
1067 fn mode_glyphs_are_fleet_signals() {
1068 let sig = EscribaSignals::prescribed();
1069 assert_eq!(
1070 mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph),
1071 "◆"
1072 );
1073 assert_eq!(
1074 mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph),
1075 "▸"
1076 );
1077 assert_eq!(
1078 mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph),
1079 "▮"
1080 );
1081 assert_eq!(
1082 mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
1083 "▮"
1084 );
1085 assert_eq!(
1086 mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
1087 ":"
1088 );
1089 }
1090
1091 /// The modified indicator is the fleet `modified` glyph (`●`), not a
1092 /// hand-picked literal.
1093 #[test]
1094 fn modified_indicator_is_fleet_signal() {
1095 let sig = EscribaSignals::prescribed();
1096 assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
1097 }
1098
1099 /// The cursor is rendered in its per-mode shape: a block fills the
1100 /// cell (Normal), a bar precedes the glyph (Insert), an underline marks
1101 /// the glyph (Visual). The shape is selected by `Mode::cursor_shape`.
1102 #[test]
1103 fn cursor_spans_render_per_mode_shape() {
1104 // Block: a single span styled with the cursor BG (block fill).
1105 let block = cursor_spans(&chrome(), 'a', CursorShape::Block);
1106 assert_eq!(block.len(), 1);
1107 assert_eq!(block[0].content, "a");
1108 // The cursor ROLE, not a theme's own token — this assertion used to
1109 // name `VellumPalette::vellum().green_bright`, which pinned the test
1110 // to one theme and would have had to change on every theme move.
1111 assert_eq!(block[0].style.bg, Some(rgb(chrome().cursor)));
1112
1113 // Bar: a thin caret span BEFORE the (unstyled) glyph.
1114 let bar = cursor_spans(&chrome(), 'a', CursorShape::Bar);
1115 assert_eq!(bar.len(), 2);
1116 assert_eq!(bar[0].content, "▏");
1117 assert_eq!(bar[1].content, "a");
1118 assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
1119
1120 // Underline: one glyph span carrying the UNDERLINED modifier.
1121 let under = cursor_spans(&chrome(), 'a', CursorShape::Underline);
1122 assert_eq!(under.len(), 1);
1123 assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
1124 }
1125
1126 /// End-to-end: the shape the buffer pane uses is derived from the live
1127 /// modal mode through the one typed `Mode::cursor_shape` mapping.
1128 #[test]
1129 fn buffer_shape_follows_modal_mode() {
1130 use escriba_core::Mode;
1131 assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
1132 assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
1133 assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
1134 }
1135
1136 /// Fleet convergence guard: escriba's TUI chrome paints whatever
1137 /// `ChromePalette::prescribed()` resolves, which is
1138 /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
1139 /// can no longer be satisfied by a stale hand-written constant.
1140 ///
1141 /// It previously hardcoded `FleetTheme::Vellum` here to match a paint
1142 /// path hardwired to `VellumPalette::vellum()`. When the fleet moved its
1143 /// prescribed theme to PlemeDark (Nord), that made the test RED —
1144 /// correctly: escriba really was painting the wrong theme. Asserting the
1145 /// resolved value instead of a literal is what stops that class of drift
1146 /// from needing a human to notice it twice.
1147 #[test]
1148 fn escriba_tui_chrome_converges_with_fleet() {
1149 use ishou_tokens::{FleetTheme, convergence::Guard};
1150 let chrome_theme = FleetTheme::prescribed_default();
1151 Guard::for_app("escriba-tui")
1152 .expect_theme(chrome_theme)
1153 .run();
1154 }
1155
1156 /// The chrome helpers must actually paint the fleet theme — not merely
1157 /// agree with it in the assertion above. Pins the buffer ground to the
1158 /// prescribed chrome's background so a renderer that silently kept a
1159 /// different palette would fail here even if the Guard passed.
1160 #[test]
1161 fn buffer_ground_is_the_prescribed_chrome() {
1162 let c = ChromePalette::prescribed();
1163 assert_eq!(buffer_style(&c).bg, Some(rgb(c.background)));
1164 assert_eq!(buffer_style(&c).fg, Some(rgb(c.text)));
1165 }
1166}