use escriba_core::CursorShape;
use escriba_runtime::EditorState;
use ishou_tokens::{EscribaSignals, SignalMode, VellumPalette};
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 vellum(rgb: ishou_tokens::Rgb) -> Color {
Color::Rgb(rgb.r, rgb.g, rgb.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);
draw_buffer(f, chunks[0], state);
draw_status_line(f, chunks[1], state);
}
fn draw_buffer(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
let Some(buf) = state.buffers.get(state.active) else {
f.render_widget(Paragraph::new("<no buffer>").style(error_style()), 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 vis_cols = win.map_or(usize::MAX, |w| w.viewport.visible_columns as usize);
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();
lines.push(line_with_gutter(
ln,
&text,
cursor,
left as usize,
vis_cols,
shape,
));
}
let block = Block::default()
.borders(Borders::NONE)
.style(buffer_style());
f.render_widget(Paragraph::new(lines).block(block), area);
}
fn line_with_gutter(
ln: u32,
text: &str,
cursor: escriba_core::Position,
left: usize,
vis_cols: usize,
shape: CursorShape,
) -> Line<'static> {
let gutter = format!("{:>4} │ ", ln + 1);
let mut spans = vec![Span::styled(gutter, muted_style())];
let chars: Vec<char> = text.chars().collect();
let visible: Vec<char> = chars.iter().copied().skip(left).take(vis_cols).collect();
if ln == cursor.line && cursor.column as usize >= left {
let rel = cursor.column as usize - left;
if rel >= visible.len() {
spans.push(Span::raw(visible.iter().collect::<String>()));
spans.extend(cursor_spans(' ', shape));
} else {
let before: String = visible[..rel].iter().collect();
let under = visible[rel];
let after: String = visible[rel + 1..].iter().collect();
spans.push(Span::raw(before));
spans.extend(cursor_spans(under, shape));
spans.push(Span::raw(after));
}
} else {
spans.push(Span::raw(visible.iter().collect::<String>()));
}
Line::from(spans)
}
fn cursor_spans(under: char, shape: CursorShape) -> Vec<Span<'static>> {
match shape {
CursorShape::Block => vec![Span::styled(under.to_string(), cursor_block_style())],
CursorShape::Bar => vec![
Span::styled("▏".to_string(), cursor_bar_style()),
Span::raw(under.to_string()),
],
CursorShape::Underline => vec![Span::styled(under.to_string(), cursor_underline_style())],
}
}
fn draw_status_line(f: &mut Frame<'_>, area: ratatui::layout::Rect, state: &EditorState) {
let mode = state.modal.mode().as_str();
let pos = format!("{}:{}", state.cursor().line + 1, state.cursor().column + 1);
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 sig = EscribaSignals::prescribed();
let modified_indicator = if modified {
format!(" {}", sig.modified.render(SignalMode::Glyph))
} else {
String::new()
};
let mode_glyph = mode_signal(&sig, state.modal.mode()).render(SignalMode::Glyph);
let mode_span = Span::styled(
format!(" {mode_glyph} {mode} "),
mode_style_for(state.modal.mode()),
);
let path_span = Span::styled(format!(" {path}{modified_indicator} "), status_style());
let minibuffer = if state.modal.mode() == escriba_core::Mode::Command {
Span::styled(format!(" :{}", state.modal.minibuffer()), cmd_style())
} else {
Span::raw("")
};
let pos_span = Span::styled(format!(" {pos} "), status_style());
let available = usize::from(area.width);
let left = format!("{}{}", mode_span.content, path_span.content,);
let right = format!("{}{}", minibuffer.content, pos_span.content);
let pad = available.saturating_sub(left.chars().count() + right.chars().count());
let line = Line::from(vec![
mode_span,
path_span,
Span::raw(" ".repeat(pad)),
minibuffer,
pos_span,
]);
f.render_widget(Paragraph::new(line).style(status_style()), area);
}
fn buffer_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.snow1)) .bg(vellum(p.night0)) }
fn muted_style() -> Style {
let p = VellumPalette::vellum();
Style::default().fg(vellum(p.shadow1)) }
fn cursor_block_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.night0)) .bg(vellum(p.green_bright)) .add_modifier(Modifier::BOLD)
}
fn cursor_bar_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.green_bright)) .add_modifier(Modifier::BOLD)
}
fn cursor_underline_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.green_bright)) .add_modifier(Modifier::UNDERLINED)
.add_modifier(Modifier::BOLD)
}
fn status_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(Color::Rgb(0xCD, 0xC7, 0xB6)) .bg(vellum(p.night1)) }
fn cmd_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.first_light)) .bg(vellum(p.night1)) .add_modifier(Modifier::BOLD)
}
fn error_style() -> Style {
let p = VellumPalette::vellum();
Style::default()
.fg(vellum(p.aurora_red)) .bg(vellum(p.night0)) }
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(mode: escriba_core::Mode) -> Style {
let p = VellumPalette::vellum();
let bg = match mode {
escriba_core::Mode::Normal => p.ice_cyan, escriba_core::Mode::Insert => p.aurora_green, escriba_core::Mode::Visual | escriba_core::Mode::VisualLine => p.solar_magenta, escriba_core::Mode::Command => p.first_light, };
Style::default()
.fg(vellum(p.night0)) .bg(vellum(bg))
.add_modifier(Modifier::BOLD)
}
#[cfg(test)]
mod tests {
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('a', CursorShape::Block);
assert_eq!(block.len(), 1);
assert_eq!(block[0].content, "a");
assert_eq!(block[0].style.bg, Some(vellum(VellumPalette::vellum().green_bright)));
let bar = cursor_spans('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('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);
}
}