1use 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
18fn rgb(c: ishou_tokens::Rgb) -> Color {
23 Color::Rgb(c.r, c.g, c.b)
24}
25
26pub fn draw_frame(f: &mut Frame<'_>, state: &EditorState) {
28 let area = f.area();
29 let chunks = RLayout::default()
30 .direction(Direction::Vertical)
31 .constraints([Constraint::Min(3), Constraint::Length(1)])
32 .split(area);
33
34 draw_buffer(f, chunks[0], state);
35 draw_status_line(f, chunks[1], state);
36}
37
38fn draw_buffer(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
39 let Some(buf) = state.buffers.get(state.active) else {
40 f.render_widget(Paragraph::new("<no buffer>").style(error_style()), area);
41 return;
42 };
43
44 let win = state.layout.active_window();
45 let top = win.map_or(0, |w| w.viewport.top_line);
46 let left = win.map_or(0, |w| w.viewport.left_column);
47 let vis_cols = win.map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
49 let visible = area.height.saturating_sub(2).max(1);
50 let cursor = state.cursor();
51 let shape = state.modal.mode().cursor_shape();
56
57 let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible as usize);
58 for row in 0..visible as u32 {
59 let ln = top + row;
60 if ln >= buf.line_count() {
61 break;
62 }
63 let Some(line_str) = buf.line(ln) else {
64 continue;
65 };
66 let text = line_str
67 .trim_end_matches('\n')
68 .trim_end_matches('\r')
69 .to_string();
70 let line_start = buf.position_to_char(escriba_core::Position::new(ln, 0)).unwrap_or(0);
74 let line_len = text.chars().count();
75 let hl: Vec<(usize, usize)> = state
76 .search
77 .highlights()
78 .iter()
79 .filter_map(|m| {
80 let s = m.start.saturating_sub(line_start);
83 let e = m.end.saturating_sub(line_start);
84 (m.end > line_start && m.start < line_start + line_len + 1)
85 .then(|| (s.min(line_len), e.min(line_len)))
86 })
87 .filter(|(s, e)| e > s)
88 .collect();
89 lines.push(line_with_gutter(
90 ln,
91 &text,
92 cursor,
93 left as usize,
94 vis_cols,
95 shape,
96 &hl,
97 ));
98 }
99
100 let block = Block::default()
101 .borders(Borders::NONE)
102 .style(buffer_style());
103 f.render_widget(Paragraph::new(lines).block(block), area);
104}
105
106fn line_with_gutter(
112 ln: u32,
113 text: &str,
114 cursor: escriba_core::Position,
115 left: usize,
116 vis_cols: usize,
117 shape: CursorShape,
118 highlights: &[(usize, usize)],
119) -> Line<'static> {
120 let gutter = format!("{:>4} │ ", ln + 1);
121 let mut spans = vec![Span::styled(gutter, muted_style())];
122
123 let chars: Vec<char> = text.chars().collect();
124 let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
126
127 let mut cell_styles: Vec<Option<Style>> = vec![None; visible.len()];
132 for &(hs, he) in highlights {
133 for col in hs..he {
134 if col >= left {
135 if let Some(slot) = cell_styles.get_mut(col - left) {
136 *slot = Some(search_match_style());
137 }
138 }
139 }
140 }
141
142 let cursor_here =
145 (ln == cursor.line && cursor.column as usize >= left).then(|| cursor.column as usize - left);
146
147 if let Some(rel) = cursor_here {
148 if rel >= visible.len() {
149 push_runs(&mut spans, &visible, &cell_styles);
150 spans.extend(cursor_spans(' ', shape));
151 return Line::from(spans);
152 }
153 push_runs(&mut spans, &visible[..rel], &cell_styles[..rel]);
154 spans.extend(cursor_spans(visible[rel], shape));
155 push_runs(&mut spans, &visible[rel + 1..], &cell_styles[rel + 1..]);
156 } else {
157 push_runs(&mut spans, &visible, &cell_styles);
158 }
159
160 Line::from(spans)
161}
162
163fn push_runs(spans: &mut Vec<Span<'static>>, chars: &[char], styles: &[Option<Style>]) {
167 debug_assert_eq!(chars.len(), styles.len(), "one style slot per cell");
168 let mut i = 0;
169 while i < chars.len() {
170 let style = styles.get(i).copied().flatten();
171 let mut j = i + 1;
172 while j < chars.len() && styles.get(j).copied().flatten() == style {
173 j += 1;
174 }
175 let run: String = chars[i..j].iter().collect();
176 spans.push(match style {
177 Some(st) => Span::styled(run, st),
178 None => Span::raw(run),
179 });
180 i = j;
181 }
182}
183
184fn cursor_spans(under: char, shape: CursorShape) -> Vec<Span<'static>> {
193 match shape {
194 CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style())],
195 CursorShape::Bar => vec![
196 Span::styled("▏".to_string(), cursor_bar_style()),
197 Span::raw(under.to_string()),
198 ],
199 CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style())],
200 }
201}
202
203fn draw_status_line(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
204 let mode = state.modal.mode().as_str();
205 let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
206 let path = state
207 .buffers
208 .get(state.active)
209 .and_then(|b| b.path.clone())
210 .map_or("scratch".to_string(), |p| p.display().to_string());
211 let modified = state.buffers.get(state.active).is_some_and(|b| b.modified);
212 let sig = EscribaSignals::prescribed();
216 let modified_indicator = if modified {
217 format!(" {}", sig.modified.render(SignalMode::Glyph))
218 } else {
219 String::new()
220 };
221
222 let mode_glyph = mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph);
224 let mode_span = Span::styled(
225 format!(" {mode_glyph} {mode} "),
226 mode_style_for(state.modal.mode()),
227 );
228 let path_span = Span::styled(format!(" {path}{modified_indicator} "), status_style());
229 let minibuffer = if state.modal.mode() == escriba_core::Mode::Command {
230 {
231 let prefix = match state.search.prompt().map(|p| p.direction) {
237 Some(escriba_search::Direction::Forward) => '/',
238 Some(escriba_search::Direction::Backward) => '?',
239 None => ':',
240 };
241 let mut line = String::from(" ");
242 line.push(prefix);
243 line.push_str(state.modal.minibuffer());
244 Span::styled(line, cmd_style())
245 }
246 } else {
247 Span::raw("")
248 };
249 let pos_span = Span::styled(format!(" {pos} "), status_style());
250
251 let available = usize::from(area.width);
253 let left = format!("{}{}", mode_span.content, path_span.content,);
254 let right = format!("{}{}", minibuffer.content, pos_span.content);
255 let pad = available.saturating_sub(left.chars().count() + right.chars().count());
256
257 let line = Line::from(vec![
258 mode_span,
259 path_span,
260 Span::raw(" ".repeat(pad)),
261 minibuffer,
262 pos_span,
263 ]);
264 f.render_widget(Paragraph::new(line).style(status_style()), area);
265}
266
267fn buffer_style() -> Style {
285 let c = ChromePalette::prescribed();
286 Style::default().fg(rgb(c.text)).bg(rgb(c.background))
287}
288
289fn muted_style() -> Style {
290 let c = ChromePalette::prescribed();
291 Style::default().fg(rgb(c.text_dim)) }
293
294fn cursor_block_style() -> Style {
297 let c = ChromePalette::prescribed();
298 Style::default()
299 .fg(rgb(c.background)) .bg(rgb(c.cursor))
301 .add_modifier(Modifier::BOLD)
302}
303
304fn cursor_bar_style() -> Style {
307 let c = ChromePalette::prescribed();
308 Style::default().fg(rgb(c.cursor)).add_modifier(Modifier::BOLD)
309}
310
311fn search_match_style() -> Style {
321 let c = ChromePalette::prescribed();
322 Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
323}
324
325fn cursor_underline_style() -> Style {
326 let c = ChromePalette::prescribed();
327 Style::default()
328 .fg(rgb(c.cursor))
329 .add_modifier(Modifier::UNDERLINED)
330 .add_modifier(Modifier::BOLD)
331}
332
333fn status_style() -> Style {
334 let c = ChromePalette::prescribed();
335 Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
339}
340
341fn cmd_style() -> Style {
342 let c = ChromePalette::prescribed();
343 Style::default().fg(rgb(c.warning)).bg(rgb(c.surface)).add_modifier(Modifier::BOLD)
344}
345
346fn error_style() -> Style {
347 let c = ChromePalette::prescribed();
348 Style::default().fg(rgb(c.error)).bg(rgb(c.background))
349}
350
351fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
358 match mode {
359 escriba_core::Mode::Normal => &sig.mode_normal,
360 escriba_core::Mode::Insert => &sig.mode_insert,
361 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
362 escriba_core::Mode::Command => &sig.mode_command,
363 }
364}
365
366fn mode_style_for(mode: escriba_core::Mode) -> Style {
367 let c = ChromePalette::prescribed();
368 let bg = match mode {
374 escriba_core::Mode::Normal => c.info,
375 escriba_core::Mode::Insert => c.success,
376 escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
377 escriba_core::Mode::Command => c.warning,
378 };
379 Style::default().fg(rgb(c.background)).bg(rgb(bg)).add_modifier(Modifier::BOLD)
380}
381
382#[cfg(test)]
383mod tests {
384
385 fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
388 spans
391 .iter()
392 .map(|sp| (sp.content.to_string(), sp.style.bg == search_match_style().bg))
393 .collect()
394 }
395
396 #[test]
397 fn push_runs_merges_adjacent_cells_of_equal_style() {
398 let chars: Vec<char> = "aaaabbbb".chars().collect();
400 let mut styles = vec![None; 8];
401 for slot in styles.iter_mut().take(4) {
402 *slot = Some(search_match_style());
403 }
404 let mut spans = vec![];
405 push_runs(&mut spans, &chars, &styles);
406 assert_eq!(spans.len(), 2, "one span per run, not per char");
407 assert_eq!(spans[0].content, "aaaa");
408 assert_eq!(spans[1].content, "bbbb");
409 }
410
411 #[test]
412 fn push_runs_on_empty_input_emits_nothing() {
413 let mut spans = vec![];
414 push_runs(&mut spans, &[], &[]);
415 assert!(spans.is_empty());
416 }
417
418 #[test]
419 fn a_match_is_painted_and_the_rest_is_not() {
420 let line = line_with_gutter(
422 0,
423 "hello world",
424 escriba_core::Position::new(9, 0), 0,
426 80,
427 CursorShape::Block,
428 &[(6, 11)],
429 );
430 let painted: Vec<String> = styles_of(&line.spans)
431 .into_iter()
432 .filter(|(_, hl)| *hl)
433 .map(|(t, _)| t)
434 .collect();
435 assert_eq!(painted, vec!["world".to_string()], "exactly the match is lit");
436 }
437
438 #[test]
439 fn two_matches_on_one_line_are_both_painted() {
440 let line = line_with_gutter(
443 0,
444 "foo bar foo",
445 escriba_core::Position::new(9, 0),
446 0,
447 80,
448 CursorShape::Block,
449 &[(0, 3), (8, 11)],
450 );
451 let painted: Vec<String> = styles_of(&line.spans)
452 .into_iter()
453 .filter(|(_, hl)| *hl)
454 .map(|(t, _)| t)
455 .collect();
456 assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
457 }
458
459 #[test]
460 fn the_cursor_stays_visible_when_sitting_on_a_match() {
461 let line = line_with_gutter(
464 0,
465 "foo bar",
466 escriba_core::Position::new(0, 1),
467 0,
468 80,
469 CursorShape::Block,
470 &[(0, 3)],
471 );
472 let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
473 assert!(texts.contains(&"o".to_string()), "cursor cell rendered alone: {texts:?}");
474 }
475
476 #[test]
477 fn highlights_respect_horizontal_scroll() {
478 let line = line_with_gutter(
480 0,
481 "hello world",
482 escriba_core::Position::new(9, 0),
483 4,
484 80,
485 CursorShape::Block,
486 &[(6, 11)],
487 );
488 let painted: Vec<String> = styles_of(&line.spans)
489 .into_iter()
490 .filter(|(_, hl)| *hl)
491 .map(|(t, _)| t)
492 .collect();
493 assert_eq!(painted, vec!["world".to_string()], "still exactly the match");
494 }
495
496 #[test]
497 fn no_highlights_renders_a_plain_line() {
498 let line = line_with_gutter(
499 0,
500 "hello world",
501 escriba_core::Position::new(9, 0),
502 0,
503 80,
504 CursorShape::Block,
505 &[],
506 );
507 assert!(styles_of(&line.spans).iter().all(|(_, hl)| !hl), "nothing lit");
508 }
509 use super::*;
510 use escriba_core::Mode;
511
512 #[test]
515 fn mode_glyphs_are_fleet_signals() {
516 let sig = EscribaSignals::prescribed();
517 assert_eq!(mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph), "◆");
518 assert_eq!(mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph), "▸");
519 assert_eq!(mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph), "▮");
520 assert_eq!(
521 mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
522 "▮"
523 );
524 assert_eq!(
525 mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
526 ":"
527 );
528 }
529
530 #[test]
533 fn modified_indicator_is_fleet_signal() {
534 let sig = EscribaSignals::prescribed();
535 assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
536 }
537
538 #[test]
542 fn cursor_spans_render_per_mode_shape() {
543 let block = cursor_spans('a', CursorShape::Block);
545 assert_eq!(block.len(), 1);
546 assert_eq!(block[0].content, "a");
547 assert_eq!(block[0].style.bg, Some(rgb(ChromePalette::prescribed().cursor)));
551
552 let bar = cursor_spans('a', CursorShape::Bar);
554 assert_eq!(bar.len(), 2);
555 assert_eq!(bar[0].content, "▏");
556 assert_eq!(bar[1].content, "a");
557 assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
558
559 let under = cursor_spans('a', CursorShape::Underline);
561 assert_eq!(under.len(), 1);
562 assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
563 }
564
565 #[test]
568 fn buffer_shape_follows_modal_mode() {
569 use escriba_core::Mode;
570 assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
571 assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
572 assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
573 }
574
575 #[test]
587 fn escriba_tui_chrome_converges_with_fleet() {
588 use ishou_tokens::{FleetTheme, convergence::Guard};
589 let chrome_theme = FleetTheme::prescribed_default();
590 Guard::for_app("escriba-tui").expect_theme(chrome_theme).run();
591 }
592
593 #[test]
598 fn buffer_ground_is_the_prescribed_chrome() {
599 let c = ChromePalette::prescribed();
600 assert_eq!(buffer_style().bg, Some(rgb(c.background)));
601 assert_eq!(buffer_style().fg, Some(rgb(c.text)));
602 }
603}