use escriba_core::CursorShape;
use escriba_runtime::EditorState;
use escriba_ui::chrome::ChromePalette;
use ishou_tokens::{EscribaSignals, SignalMode};
use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout as RLayout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
fn rgb(c: ishou_tokens::Rgb) -> Color {
Color::Rgb(c.r, c.g, c.b)
}
pub fn draw_frame(f: &mut Frame<'_>, state: &EditorState) {
let area = f.area();
let chunks = RLayout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Min(3), Constraint::Length(1)])
.split(area);
let chrome = state.chrome();
match state.splash() {
Some(splash) => draw_splash(f, chunks[0], splash, &chrome),
None => draw_buffer(f, chunks[0], state, &chrome),
}
draw_status_line(f, chunks[1], state, &chrome);
}
fn draw_splash(
f: &mut Frame<'_>,
area: ratatui::layout::Rect,
splash: &escriba_ui::splash::Splash,
chrome: &ChromePalette,
) {
let ground = Style::default()
.fg(rgb(chrome.text))
.bg(rgb(chrome.background));
f.render_widget(Block::default().borders(Borders::NONE).style(ground), area);
for row in splash.rows(area.width, area.height) {
let spans: Vec<Span<'static>> = row
.spans
.iter()
.map(|s| {
Span::styled(
s.text.clone(),
ground.fg(rgb(s.role.color(chrome))).add_modifier(
if matches!(
s.role,
escriba_ui::splash::SplashRole::Art
| escriba_ui::splash::SplashRole::MenuKey
) {
Modifier::BOLD
} else {
Modifier::empty()
},
),
)
})
.collect();
let line_area = ratatui::layout::Rect {
x: area.x + row.col,
y: area.y + row.row,
width: area.width.saturating_sub(row.col),
height: 1,
};
f.render_widget(Paragraph::new(Line::from(spans)).style(ground), line_area);
}
}
fn draw_buffer(
f: &mut Frame<'_>,
area: ratatui::layout::Rect,
state: &EditorState,
chrome: &ChromePalette,
) {
let Some(buf) = state.buffers.get(state.active) else {
f.render_widget(
Paragraph::new("<no buffer>").style(error_style(chrome)),
area,
);
return;
};
let win = state.layout.active_window();
let top = win.map_or(0, |w| w.viewport.top_line);
let left = win.map_or(0, |w| w.viewport.left_column);
let line_count = buf.line_count();
let gutter_cols = escriba_ui::gutter::gutter_width(line_count);
let vis_cols = win.map_or(usize::MAX, |w| {
(w.viewport.visible_columns as usize).saturating_sub(gutter_cols)
});
let visible = area.height.saturating_sub(2).max(1);
let cursor = state.cursor();
let shape = state.modal.mode().cursor_shape();
let mut lines: Vec<Line<'static>> = Vec::with_capacity(visible as usize);
for row in 0..visible as u32 {
let ln = top + row;
if ln >= buf.line_count() {
break;
}
let Some(line_str) = buf.line(ln) else {
continue;
};
let text = line_str
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
let line_start = buf
.position_to_char(escriba_core::Position::new(ln, 0))
.unwrap_or(0);
let line_len = text.chars().count();
let hl: Vec<(usize, usize)> = state
.search
.highlights()
.iter()
.filter_map(|m| {
let s = m.start.saturating_sub(line_start);
let e = m.end.saturating_sub(line_start);
(m.end > line_start && m.start < line_start + line_len + 1)
.then(|| (s.min(line_len), e.min(line_len)))
})
.filter(|(s, e)| e > s)
.collect();
let mark = state.results.worst_on_line(&state.world(), state.active, ln);
lines.push(line_with_gutter(
chrome,
mark,
ln,
line_count,
&text,
cursor,
left as usize,
vis_cols,
shape,
&hl,
));
}
let block = Block::default()
.borders(Borders::NONE)
.style(buffer_style(chrome));
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn line_with_gutter(
chrome: &ChromePalette,
mark: Option<escriba_shirube::Severity>,
ln: u32,
line_count: u32,
text: &str,
cursor: escriba_core::Position,
left: usize,
vis_cols: usize,
shape: CursorShape,
highlights: &[(usize, usize)],
) -> Line<'static> {
let mut spans: Vec<Span<'static>> = escriba_ui::gutter::gutter_cells(ln, mark, line_count)
.into_iter()
.map(|c| {
let style = match c.role {
escriba_ui::gutter::GutterRole::Mark(sev) => {
Style::default().fg(rgb(escriba_ui::chrome::severity_color(chrome, sev)))
}
_ => muted_style(chrome),
};
Span::styled(c.text, style)
})
.collect();
let chars: Vec<char> = text.chars().collect();
let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
let mut cell_styles: Vec<Option<Style>> = vec![None; visible.len()];
for &(hs, he) in highlights {
for col in hs..he {
if col >= left {
if let Some(slot) = cell_styles.get_mut(col - left) {
*slot = Some(search_match_style(chrome));
}
}
}
}
let cursor_here = (ln == cursor.line && cursor.column as usize >= left)
.then(|| cursor.column as usize - left);
if let Some(rel) = cursor_here {
if rel >= visible.len() {
push_runs(&mut spans, &visible, &cell_styles);
spans.extend(cursor_spans(chrome, ' ', shape));
return Line::from(spans);
}
push_runs(&mut spans, &visible[..rel], &cell_styles[..rel]);
spans.extend(cursor_spans(chrome, visible[rel], shape));
push_runs(&mut spans, &visible[rel + 1..], &cell_styles[rel + 1..]);
} else {
push_runs(&mut spans, &visible, &cell_styles);
}
Line::from(spans)
}
fn push_runs(spans: &mut Vec<Span<'static>>, chars: &[char], styles: &[Option<Style>]) {
debug_assert_eq!(chars.len(), styles.len(), "one style slot per cell");
let mut i = 0;
while i < chars.len() {
let style = styles.get(i).copied().flatten();
let mut j = i + 1;
while j < chars.len() && styles.get(j).copied().flatten() == style {
j += 1;
}
let run: String = chars[i..j].iter().collect();
spans.push(match style {
Some(st) => Span::styled(run, st),
None => Span::raw(run),
});
i = j;
}
}
fn cursor_spans(c: &ChromePalette, under: char, shape: CursorShape) -> Vec<Span<'static>> {
match shape {
CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style(c))],
CursorShape::Bar => vec![
Span::styled("▏".to_string(), cursor_bar_style(c)),
Span::raw(under.to_string()),
],
CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style(c))],
}
}
fn draw_status_line(
f: &mut Frame<'_>,
area: ratatui::layout::Rect,
state: &EditorState,
chrome: &ChromePalette,
) {
let model = state.status_model();
let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
let sig = EscribaSignals::prescribed();
let mut pill = String::with_capacity(16);
pill.push(' ');
match model.pill_sigil() {
Some(sigil) => pill.push(sigil),
None => pill.push_str(mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph)),
}
pill.push(' ');
pill.push_str(model.mode_label());
pill.push(' ');
let mode_span = Span::styled(pill, pill_style_for(chrome, &model, state.modal.mode()));
let context_span = if model.pill_sigil().is_some() {
let mut line = String::from(" ");
model.render_prompt_into(&mut line);
line.push(' ');
Span::styled(line, cmd_style(chrome))
} else {
let path = state
.buffers
.get(state.active)
.and_then(|b| b.path.clone())
.map_or("scratch".to_string(), |p| p.display().to_string());
let modified = state.buffers.get(state.active).is_some_and(|b| b.modified);
let modified_indicator = if modified {
format!(" {}", sig.modified.render(SignalMode::Glyph))
} else {
String::new()
};
Span::styled(
format!(" {path}{modified_indicator} "),
status_style(chrome),
)
};
let pos_span = Span::styled(format!(" {pos} "), status_style(chrome));
let count = model.count;
let count_span = if count.is_idle() {
Span::raw("")
} else {
let mut c = String::from(" ");
count.render_into(&mut c);
c.push(' ');
Span::styled(c, status_style(chrome))
};
let available = usize::from(area.width);
let left = mode_span.content.chars().count() + context_span.content.chars().count();
let right = count_span.content.chars().count() + pos_span.content.chars().count();
let pad = available.saturating_sub(left + right);
let line = Line::from(vec![
mode_span,
context_span,
Span::raw(" ".repeat(pad)),
count_span,
pos_span,
]);
f.render_widget(Paragraph::new(line).style(status_style(chrome)), area);
}
fn buffer_style(c: &ChromePalette) -> Style {
Style::default().fg(rgb(c.text)).bg(rgb(c.background))
}
fn muted_style(c: &ChromePalette) -> Style {
Style::default().fg(rgb(c.text_dim)) }
fn cursor_block_style(c: &ChromePalette) -> Style {
Style::default()
.fg(rgb(c.background)) .bg(rgb(c.cursor))
.add_modifier(Modifier::BOLD)
}
fn cursor_bar_style(c: &ChromePalette) -> Style {
Style::default()
.fg(rgb(c.cursor))
.add_modifier(Modifier::BOLD)
}
fn search_match_style(c: &ChromePalette) -> Style {
Style::default().fg(rgb(c.background)).bg(rgb(c.warning))
}
fn cursor_underline_style(c: &ChromePalette) -> Style {
Style::default()
.fg(rgb(c.cursor))
.add_modifier(Modifier::UNDERLINED)
.add_modifier(Modifier::BOLD)
}
fn status_style(c: &ChromePalette) -> Style {
Style::default().fg(rgb(c.text)).bg(rgb(c.surface))
}
fn cmd_style(c: &ChromePalette) -> Style {
Style::default()
.fg(rgb(c.warning))
.bg(rgb(c.surface))
.add_modifier(Modifier::BOLD)
}
fn error_style(c: &ChromePalette) -> Style {
Style::default().fg(rgb(c.error)).bg(rgb(c.background))
}
fn mode_signal(sig: &EscribaSignals, mode: escriba_core::Mode) -> &ishou_tokens::Signal {
match mode {
escriba_core::Mode::Normal => &sig.mode_normal,
escriba_core::Mode::Insert => &sig.mode_insert,
escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => &sig.mode_visual,
escriba_core::Mode::Command => &sig.mode_command,
}
}
fn mode_style_for(c: &ChromePalette, mode: escriba_core::Mode) -> Style {
let bg = match mode {
escriba_core::Mode::Normal => c.info,
escriba_core::Mode::Insert => c.success,
escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => c.accent,
escriba_core::Mode::Command => c.warning,
};
Style::default()
.fg(rgb(c.background))
.bg(rgb(bg))
.add_modifier(Modifier::BOLD)
}
fn pill_style_for(
c: &ChromePalette,
model: &escriba_runtime::StatusModel<'_>,
mode: escriba_core::Mode,
) -> Style {
if model.prompt.is_search() {
return Style::default()
.fg(rgb(c.background))
.bg(rgb(c.accent))
.add_modifier(Modifier::BOLD);
}
mode_style_for(c, mode)
}
#[cfg(test)]
mod tests {
fn chrome() -> ChromePalette {
ChromePalette::prescribed()
}
fn styles_of(spans: &[Span<'static>]) -> Vec<(String, bool)> {
spans
.iter()
.map(|sp| {
(
sp.content.to_string(),
sp.style.bg == search_match_style(&chrome()).bg,
)
})
.collect()
}
#[test]
fn push_runs_merges_adjacent_cells_of_equal_style() {
let chars: Vec<char> = "aaaabbbb".chars().collect();
let mut styles = vec![None; 8];
for slot in styles.iter_mut().take(4) {
*slot = Some(search_match_style(&chrome()));
}
let mut spans = vec![];
push_runs(&mut spans, &chars, &styles);
assert_eq!(spans.len(), 2, "one span per run, not per char");
assert_eq!(spans[0].content, "aaaa");
assert_eq!(spans[1].content, "bbbb");
}
#[test]
fn push_runs_on_empty_input_emits_nothing() {
let mut spans = vec![];
push_runs(&mut spans, &[], &[]);
assert!(spans.is_empty());
}
#[test]
fn a_match_is_painted_and_the_rest_is_not() {
let line = line_with_gutter(
&chrome(),
None,
0,
64,
"hello world",
escriba_core::Position::new(9, 0), 0,
80,
CursorShape::Block,
&[(6, 11)],
);
let painted: Vec<String> = styles_of(&line.spans)
.into_iter()
.filter(|(_, hl)| *hl)
.map(|(t, _)| t)
.collect();
assert_eq!(
painted,
vec!["world".to_string()],
"exactly the match is lit"
);
}
#[test]
fn two_matches_on_one_line_are_both_painted() {
let line = line_with_gutter(
&chrome(),
None,
0,
64,
"foo bar foo",
escriba_core::Position::new(9, 0),
0,
80,
CursorShape::Block,
&[(0, 3), (8, 11)],
);
let painted: Vec<String> = styles_of(&line.spans)
.into_iter()
.filter(|(_, hl)| *hl)
.map(|(t, _)| t)
.collect();
assert_eq!(painted, vec!["foo".to_string(), "foo".to_string()]);
}
#[test]
fn the_cursor_stays_visible_when_sitting_on_a_match() {
let line = line_with_gutter(
&chrome(),
None,
0,
64,
"foo bar",
escriba_core::Position::new(0, 1),
0,
80,
CursorShape::Block,
&[(0, 3)],
);
let texts: Vec<String> = line.spans.iter().map(|s| s.content.to_string()).collect();
assert!(
texts.contains(&"o".to_string()),
"cursor cell rendered alone: {texts:?}"
);
}
#[test]
fn highlights_respect_horizontal_scroll() {
let line = line_with_gutter(
&chrome(),
None,
0,
64,
"hello world",
escriba_core::Position::new(9, 0),
4,
80,
CursorShape::Block,
&[(6, 11)],
);
let painted: Vec<String> = styles_of(&line.spans)
.into_iter()
.filter(|(_, hl)| *hl)
.map(|(t, _)| t)
.collect();
assert_eq!(
painted,
vec!["world".to_string()],
"still exactly the match"
);
}
#[test]
fn no_highlights_renders_a_plain_line() {
let line = line_with_gutter(
&chrome(),
None,
0,
64,
"hello world",
escriba_core::Position::new(9, 0),
0,
80,
CursorShape::Block,
&[],
);
assert!(
styles_of(&line.spans).iter().all(|(_, hl)| !hl),
"nothing lit"
);
}
use super::*;
use escriba_core::Mode;
#[test]
fn mode_glyphs_are_fleet_signals() {
let sig = EscribaSignals::prescribed();
assert_eq!(
mode_signal(&sig, Mode::Normal).render(SignalMode::Glyph),
"◆"
);
assert_eq!(
mode_signal(&sig, Mode::Insert).render(SignalMode::Glyph),
"▸"
);
assert_eq!(
mode_signal(&sig, Mode::Visual).render(SignalMode::Glyph),
"▮"
);
assert_eq!(
mode_signal(&sig, Mode::VisualLine).render(SignalMode::Glyph),
"▮"
);
assert_eq!(
mode_signal(&sig, Mode::Command).render(SignalMode::Glyph),
":"
);
}
#[test]
fn modified_indicator_is_fleet_signal() {
let sig = EscribaSignals::prescribed();
assert_eq!(sig.modified.render(SignalMode::Glyph), "●");
}
#[test]
fn cursor_spans_render_per_mode_shape() {
let block = cursor_spans(&chrome(), 'a', CursorShape::Block);
assert_eq!(block.len(), 1);
assert_eq!(block[0].content, "a");
assert_eq!(block[0].style.bg, Some(rgb(chrome().cursor)));
let bar = cursor_spans(&chrome(), 'a', CursorShape::Bar);
assert_eq!(bar.len(), 2);
assert_eq!(bar[0].content, "▏");
assert_eq!(bar[1].content, "a");
assert_eq!(bar[1].style.bg, None, "bar leaves the glyph cell unfilled");
let under = cursor_spans(&chrome(), 'a', CursorShape::Underline);
assert_eq!(under.len(), 1);
assert!(under[0].style.add_modifier.contains(Modifier::UNDERLINED));
}
#[test]
fn buffer_shape_follows_modal_mode() {
use escriba_core::Mode;
assert_eq!(Mode::Normal.cursor_shape(), CursorShape::Block);
assert_eq!(Mode::Insert.cursor_shape(), CursorShape::Bar);
assert_eq!(Mode::Visual.cursor_shape(), CursorShape::Underline);
}
#[test]
fn escriba_tui_chrome_converges_with_fleet() {
use ishou_tokens::{FleetTheme, convergence::Guard};
let chrome_theme = FleetTheme::prescribed_default();
Guard::for_app("escriba-tui")
.expect_theme(chrome_theme)
.run();
}
#[test]
fn buffer_ground_is_the_prescribed_chrome() {
let c = ChromePalette::prescribed();
assert_eq!(buffer_style(&c).bg, Some(rgb(c.background)));
assert_eq!(buffer_style(&c).fg, Some(rgb(c.text)));
}
}