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 let line = Line::from(vec![
720 mode_span,
721 context_span,
722 Span::raw(" ".repeat(pad)),
723 count_span,
724 pos_span,
725 ]);
726 f.render_widget(Paragraph::new(line).style(status_style(chrome)), area);
727}
728
729// ─── Styles — Vellum (warm aged-paper Nord-matte) ───────────────────────
730//
731// Every chrome color resolves through `escriba_ui::chrome::ChromePalette`
732// — the one theme seam, shared with the GPU backend so the two faces cannot
733// drift apart. Colors are named by ROLE (text / surface / cursor / error),
734// never by a theme's own token spelling, which is what lets the theme change
735// without touching a single call site here.
736//
737// Each helper takes the LIVE palette rather than reading
738// `ChromePalette::prescribed()` for itself. That parameter is the whole
739// theming fix: while these read the prescribed value directly, an operator
740// could author `(deftheme :preset "vellum")`, watch it parse, validate and
741// resolve to a real `FleetTheme` — and see the editor paint Nord anyway,
742// because nothing downstream consumed it. A palette that arrives as an
743// argument cannot be ignored.
744
745fn buffer_style(c: &ChromePalette) -> Style {
746 Style::default().fg(rgb(c.text)).bg(rgb(c.background))
747}
748
749fn muted_style(c: &ChromePalette) -> Style {
750 Style::default().fg(rgb(c.text_dim)) // comment / gutter
751}
752
753/// Block cursor (Normal / Command) — dark glyph filled onto the cursor
754/// color, the "you are here" cell.
755fn cursor_block_style(c: &ChromePalette) -> Style {
756 Style::default()
757 .fg(rgb(c.background)) // ground-colored text on the cursor
758 .bg(rgb(c.cursor))
759 .add_modifier(Modifier::BOLD)
760}
761
762/// Bar cursor (Insert) — the thin vertical caret drawn between glyphs,
763/// colored in the cursor accent.
764fn cursor_bar_style(c: &ChromePalette) -> Style {
765 Style::default()
766 .fg(rgb(c.cursor))
767 .add_modifier(Modifier::BOLD)
768}
769
770/// Underline cursor (Visual) — the glyph kept legible with an underline in
771/// the cursor accent.
772/// Style for a search match under `hlsearch`.
773///
774/// Reversed against the `warning` role rather than a literal colour: it reads
775/// as "look here" without colliding with `cursor` (which must stay
776/// distinguishable when the cursor sits ON a match) or with `error`. Sourced
777/// from ChromePalette so it follows the fleet theme like every other style
778/// here — a hardcoded hex would be the one span that ignores the theme.
779fn search_match_style(c: &ChromePalette) -> Style {
780 Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
781}
782
783fn cursor_underline_style(c: &ChromePalette) -> Style {
784 Style::default()
785 .fg(rgb(c.cursor))
786 .add_modifier(Modifier::UNDERLINED)
787 .add_modifier(Modifier::BOLD)
788}
789
790fn status_style(c: &ChromePalette) -> Style {
791 // Was a raw `Color::Rgb(0xCD, 0xC7, 0xB6)` literal ("statusline_fg,
792 // Vellum extra") — the one genuinely hardcoded color in this file, and
793 // dead weight the moment the theme moved. It is now the `text` role.
794 Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
795}
796
797fn cmd_style(c: &ChromePalette) -> Style {
798 Style::default()
799 .fg(rgb(c.warning))
800 .bg(rgb(c.surface))
801 .add_modifier(Modifier::BOLD)
802}
803
804fn error_style(c: &ChromePalette) -> Style {
805 Style::default().fg(rgb(c.error)).bg(rgb(c.background))
806}
807
808/// Map an editor [`Mode`](escriba_core::Mode) to its fleet
809/// [`Signal`](ishou_tokens::Signal) from [`EscribaSignals`].
810///
811/// `VisualLine` shares `mode_visual` with `Visual` — the fleet signal
812/// set has one visual signal, matching how [`mode_style_for`] groups the
813/// two under one pill color.
814fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
815 match mode {
816 escriba_core::Mode::Normal => &sig.mode_normal,
817 escriba_core::Mode::Insert => &sig.mode_insert,
818 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
819 escriba_core::Mode::Command => &sig.mode_command,
820 }
821}
822
823fn mode_style_for(c: &ChromePalette, mode: escriba_core::Mode) -> Style {
824 // Mode pills — ground-colored text on a role-colored field:
825 // Normal info, Insert success, Visual accent, Command warning. Naming
826 // the ROLE rather than the hue is what keeps these correct across
827 // themes: on Nord `info` is frost blue, on Vellum it was ice cyan, and
828 // neither call site has to know.
829 let bg = match mode {
830 escriba_core::Mode::Normal => c.info,
831 escriba_core::Mode::Insert => c.success,
832 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
833 escriba_core::Mode::Command => c.warning,
834 };
835 Style::default()
836 .fg(rgb(c.background))
837 .bg(rgb(bg))
838 .add_modifier(Modifier::BOLD)
839}
840
841/// The pill's style, chosen from the status MODEL rather than the raw mode.
842///
843/// A search and an ex-command share `Mode::Command`, so [`mode_style_for`]
844/// alone paints them identically — same colour, and (before this) the same
845/// `: COMMAND` text. The search gets the accent field so the two prompts are
846/// distinguishable at a glance, not only by reading the label.
847fn pill_style_for(
848 c: &ChromePalette,
849 model: &escriba_runtime::StatusModel<'_>,
850 mode: escriba_core::Mode,
851) -> Style {
852 if model.prompt.is_search() {
853 return Style::default()
854 .fg(rgb(c.background))
855 .bg(rgb(c.accent))
856 .add_modifier(Modifier::BOLD);
857 }
858 mode_style_for(c, mode)
859}
860
861#[cfg(test)]
862mod tests {
863
864 // ── search highlight rendering ────────────────────────────────────
865
866 /// The palette the render tests paint with. A FIXED theme, not the
867 /// live one: these assert LAYOUT and span structure, and pinning them
868 /// to whatever the fleet currently prescribes would make them rewrite
869 /// themselves on every theme move (which is exactly what happened to
870 /// the mode-pill test before it started asserting roles).
871 fn chrome() -> ChromePalette {
872 ChromePalette::prescribed()
873 }
874
875 fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
876 // (text, is-highlighted) — comparing against the exact Style would
877 // pin the palette, which is a theming concern, not a layout one.
878 spans
879 .iter()
880 .map(|sp| {
881 (
882 sp.content.to_string(),
883 sp.style.bg == search_match_style(&chrome()).bg,
884 )
885 })
886 .collect()
887 }
888
889 #[test]
890 fn push_runs_merges_adjacent_cells_of_equal_style() {
891 // A 200-column line must not emit 200 spans per frame.
892 let chars: Vec<char> = "aaaabbbb".chars().collect();
893 let mut styles = vec![None; 8];
894 for slot in styles.iter_mut().take(4) {
895 *slot = Some(search_match_style(&chrome()));
896 }
897 let mut spans = vec![];
898 push_runs(&mut spans, &chars, &styles);
899 assert_eq!(spans.len(), 2, "one span per run, not per char");
900 assert_eq!(spans[0].content, "aaaa");
901 assert_eq!(spans[1].content, "bbbb");
902 }
903
904 #[test]
905 fn push_runs_on_empty_input_emits_nothing() {
906 let mut spans = vec![];
907 push_runs(&mut spans, &[], &[]);
908 assert!(spans.is_empty());
909 }
910
911 #[test]
912 fn a_match_is_painted_and_the_rest_is_not() {
913 // "hello world", match on "world" (cols 6..11), cursor elsewhere.
914 let line = line_with_gutter(
915 &chrome(),
916 None,
917 0,
918 64,
919 &[],
920 "hello world",
921 escriba_core::Position::new(9, 0), // cursor on another line
922 0,
923 80,
924 CursorShape::Block,
925 &[(6, 11)],
926 );
927 let painted: Vec<String> = styles_of(&line.spans)
928 .into_iter()
929 .filter(|(_, hl)| *hl)
930 .map(|(t, _)| t)
931 .collect();
932 assert_eq!(
933 painted,
934 vec!["world".to_string()],
935 "exactly the match is lit"
936 );
937 }
938
939 #[test]
940 fn two_matches_on_one_line_are_both_painted() {
941 // The old before/cursor/after split could express only ONE styled
942 // region — this is the case it structurally could not render.
943 let line = line_with_gutter(
944 &chrome(),
945 None,
946 0,
947 64,
948 &[],
949 "foo bar foo",
950 escriba_core::Position::new(9, 0),
951 0,
952 80,
953 CursorShape::Block,
954 &[(0, 3), (8, 11)],
955 );
956 let painted: Vec<String> = styles_of(&line.spans)
957 .into_iter()
958 .filter(|(_, hl)| *hl)
959 .map(|(t, _)| t)
960 .collect();
961 assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
962 }
963
964 #[test]
965 fn the_cursor_stays_visible_when_sitting_on_a_match() {
966 // A highlight must never swallow the cursor cell, or you lose your
967 // place the moment you land on a match — which is always, after `n`.
968 let line = line_with_gutter(
969 &chrome(),
970 None,
971 0,
972 64,
973 &[],
974 "foo bar",
975 escriba_core::Position::new(0, 1),
976 0,
977 80,
978 CursorShape::Block,
979 &[(0, 3)],
980 );
981 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
982 assert!(
983 texts.contains(&"o".to_string()),
984 "cursor cell rendered alone: {texts:?}"
985 );
986 }
987
988 #[test]
989 fn highlights_respect_horizontal_scroll() {
990 // Scrolled right by 4: the match at cols 6..11 must shift left by 4.
991 let line = line_with_gutter(
992 &chrome(),
993 None,
994 0,
995 64,
996 &[],
997 "hello world",
998 escriba_core::Position::new(9, 0),
999 4,
1000 80,
1001 CursorShape::Block,
1002 &[(6, 11)],
1003 );
1004 let painted: Vec<String> = styles_of(&line.spans)
1005 .into_iter()
1006 .filter(|(_, hl)| *hl)
1007 .map(|(t, _)| t)
1008 .collect();
1009 assert_eq!(
1010 painted,
1011 vec!["world".to_string()],
1012 "still exactly the match"
1013 );
1014 }
1015
1016 #[test]
1017 fn no_highlights_renders_a_plain_line() {
1018 let line = line_with_gutter(
1019 &chrome(),
1020 None,
1021 0,
1022 64,
1023 &[],
1024 "hello world",
1025 escriba_core::Position::new(9, 0),
1026 0,
1027 80,
1028 CursorShape::Block,
1029 &[],
1030 );
1031 assert!(
1032 styles_of(&line.spans).iter().all(|(_, hl)| !hl),
1033 "nothing lit"
1034 );
1035 }
1036 use super::*;
1037 use escriba_core::Mode;
1038
1039 /// Forcing function: the status-line mode glyphs are sourced from the
1040 /// fleet `EscribaSignals` vocabulary, not hand-picked literals.
1041 #[test]
1042 fn mode_glyphs_are_fleet_signals() {
1043 let sig = EscribaSignals::prescribed();
1044 assert_eq!(
1045 mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph),
1046 "◆"
1047 );
1048 assert_eq!(
1049 mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph),
1050 "▸"
1051 );
1052 assert_eq!(
1053 mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph),
1054 "▮"
1055 );
1056 assert_eq!(
1057 mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
1058 "▮"
1059 );
1060 assert_eq!(
1061 mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
1062 ":"
1063 );
1064 }
1065
1066 /// The modified indicator is the fleet `modified` glyph (`●`), not a
1067 /// hand-picked literal.
1068 #[test]
1069 fn modified_indicator_is_fleet_signal() {
1070 let sig = EscribaSignals::prescribed();
1071 assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
1072 }
1073
1074 /// The cursor is rendered in its per-mode shape: a block fills the
1075 /// cell (Normal), a bar precedes the glyph (Insert), an underline marks
1076 /// the glyph (Visual). The shape is selected by `Mode::cursor_shape`.
1077 #[test]
1078 fn cursor_spans_render_per_mode_shape() {
1079 // Block: a single span styled with the cursor BG (block fill).
1080 let block = cursor_spans(&chrome(), 'a', CursorShape::Block);
1081 assert_eq!(block.len(), 1);
1082 assert_eq!(block[0].content, "a");
1083 // The cursor ROLE, not a theme's own token — this assertion used to
1084 // name `VellumPalette::vellum().green_bright`, which pinned the test
1085 // to one theme and would have had to change on every theme move.
1086 assert_eq!(block[0].style.bg, Some(rgb(chrome().cursor)));
1087
1088 // Bar: a thin caret span BEFORE the (unstyled) glyph.
1089 let bar = cursor_spans(&chrome(), 'a', CursorShape::Bar);
1090 assert_eq!(bar.len(), 2);
1091 assert_eq!(bar[0].content, "▏");
1092 assert_eq!(bar[1].content, "a");
1093 assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
1094
1095 // Underline: one glyph span carrying the UNDERLINED modifier.
1096 let under = cursor_spans(&chrome(), 'a', CursorShape::Underline);
1097 assert_eq!(under.len(), 1);
1098 assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
1099 }
1100
1101 /// End-to-end: the shape the buffer pane uses is derived from the live
1102 /// modal mode through the one typed `Mode::cursor_shape` mapping.
1103 #[test]
1104 fn buffer_shape_follows_modal_mode() {
1105 use escriba_core::Mode;
1106 assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
1107 assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
1108 assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
1109 }
1110
1111 /// Fleet convergence guard: escriba's TUI chrome paints whatever
1112 /// `ChromePalette::prescribed()` resolves, which is
1113 /// `FleetTheme::prescribed_default()` BY CONSTRUCTION — so this Guard
1114 /// can no longer be satisfied by a stale hand-written constant.
1115 ///
1116 /// It previously hardcoded `FleetTheme::Vellum` here to match a paint
1117 /// path hardwired to `VellumPalette::vellum()`. When the fleet moved its
1118 /// prescribed theme to PlemeDark (Nord), that made the test RED —
1119 /// correctly: escriba really was painting the wrong theme. Asserting the
1120 /// resolved value instead of a literal is what stops that class of drift
1121 /// from needing a human to notice it twice.
1122 #[test]
1123 fn escriba_tui_chrome_converges_with_fleet() {
1124 use ishou_tokens::{FleetTheme, convergence::Guard};
1125 let chrome_theme = FleetTheme::prescribed_default();
1126 Guard::for_app("escriba-tui")
1127 .expect_theme(chrome_theme)
1128 .run();
1129 }
1130
1131 /// The chrome helpers must actually paint the fleet theme — not merely
1132 /// agree with it in the assertion above. Pins the buffer ground to the
1133 /// prescribed chrome's background so a renderer that silently kept a
1134 /// different palette would fail here even if the Guard passed.
1135 #[test]
1136 fn buffer_ground_is_the_prescribed_chrome() {
1137 let c = ChromePalette::prescribed();
1138 assert_eq!(buffer_style(&c).bg, Some(rgb(c.background)));
1139 assert_eq!(buffer_style(&c).fg, Some(rgb(c.text)));
1140 }
1141}