use ratatui::layout::{Alignment, Position, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Borders, Clear, Paragraph};
use ratatui::Frame;
use crate::app::{App, FlashKind};
use crate::finder::{Finder, VISIBLE_ROWS};
use crate::help::Help;
use crate::input::mode::{Mode, Prompt, PromptKind};
use crate::render::align::{self, TextAlign};
use crate::render::cache::RenderCache;
use crate::render::markdown::block::BlockKind;
use crate::render::splash;
use crate::render::focus::FocusRegion;
use crate::render::layout::{display_width, scroll_offset, Layout, VisualRow};
use crate::render::numbers::NumberMode;
use crate::render::theme::Color as ThemeColor;
use crate::render::theme::Style as ThemeStyle;
use crate::render::StyledSpan;
use crate::text::cursor::Cursor;
use crate::tree::{Entry, FileTree, IconKind};
pub const STATUS_ROWS: u16 = 1;
pub fn render(frame: &mut Frame, app: &App) {
let area = frame.area();
app.overlay.set(None);
app.images.borrow_mut().clear();
let cfg = &app.config;
let bg = app.theme.background.to_ratatui();
frame.render_widget(Block::default().style(Style::default().bg(bg)), area);
if let Some(view) = &app.diff {
render_diff(frame, app, view, area);
return;
}
if let Some(help) = &app.help {
render_help(frame, app, help, area);
return;
}
let tw = tree_width(app, area);
if let Some(tree) = &app.tree {
let tree_rect = Rect { x: area.x, y: area.y, width: tw, height: area.height };
render_tree(frame, app, tree, tree_rect);
}
let ea = Rect {
x: area.x + tw,
y: area.y,
width: area.width.saturating_sub(tw),
height: area.height,
};
let geo = app.layout.geometry(pane_area(app, area));
let mut caret = None;
for (id, rect) in &geo.panes {
let Some(pane) = app.layout.pane(*id) else { continue };
if let Some(pos) = render_pane(frame, app, pane, *rect, *id == app.focus_pane) {
caret = Some(pos);
}
}
for d in &geo.dividers {
frame.render_widget(
Block::default().style(
Style::default()
.bg(app.theme.text_dim.to_ratatui())
.fg(app.theme.text_dim.to_ratatui()),
),
*d,
);
}
if cfg.status.enabled {
if let Some(status) = status_text(app) {
let style = match app.flash.as_ref().map(|f| &f.kind) {
Some(FlashKind::Error) => Style::default().fg(Color::Red),
_ => Style::default().add_modifier(Modifier::DIM),
};
let lay = Layout::compute(&cfg.layout, ea.width, ea.height, STATUS_ROWS);
let status_area = Rect {
x: ea.x + lay.margin_left,
y: ea.y + ea.height.saturating_sub(STATUS_ROWS),
width: lay.measure,
height: STATUS_ROWS,
};
let status = elide(&status, lay.measure as usize);
let line = Line::from(Span::styled(status, style)).right_aligned();
frame.render_widget(Paragraph::new(line), status_area);
}
}
let tree_focused = app.tree.as_ref().is_some_and(|t| t.focused);
if let Some(finder) = &app.finder {
render_finder(frame, app, finder, ea);
} else if let Mode::Prompt(p) = &app.mode {
let prompt = prompt_label(app, p);
render_command_box(frame, app, p.kind.title(), &prompt, &p.input, ea);
} else if tree_focused {
} else if let Mode::Command(buf) = &app.mode {
render_command_box(frame, app, " command ", ":", buf, ea);
} else if let Mode::Search { query, reverse } = &app.mode {
let prompt = if *reverse { "?" } else { "/" };
render_command_box(frame, app, " search ", prompt, query, ea);
} else if let Some(pos) = caret {
frame.set_cursor_position(pos);
}
}
pub fn pane_area(app: &App, area: Rect) -> Rect {
let tw = tree_width(app, area);
let reserved = if app.config.status.enabled { STATUS_ROWS } else { 0 };
Rect {
x: area.x + tw,
y: area.y,
width: area.width.saturating_sub(tw),
height: area.height.saturating_sub(reserved),
}
}
fn render_pane(
frame: &mut Frame,
app: &App,
pane: &crate::render::pane::Pane,
rect: Rect,
focused: bool,
) -> Option<Position> {
let cfg = &app.config;
let bg = app.theme.background.to_ratatui();
let doc = app.docs.get(pane.doc)?;
if splash::active(app) {
splash::render(frame, app, rect);
return None;
}
let mut lay = Layout::compute(&cfg.layout, rect.width, rect.height, 0);
let numbers = app.numbers;
lay.number_gutter = number_gutter_width(numbers, doc.buffer.line_count(), lay.margin_left);
let mut cache = doc.cache.borrow_mut();
cache.sync_doc(app, doc, lay.measure);
let cursor = if focused {
doc.buffer.cursor
} else {
let line = pane.cursor.line.min(doc.buffer.line_count().saturating_sub(1));
Cursor::new(line, pane.cursor.col.min(doc.buffer.line_len(line)))
};
let (cursor_row, cursor_offset) = cache.locate(cursor.line, cursor.col);
let top = scroll_offset(
cursor_row,
cache.total_rows(),
&lay,
&cfg.layout,
cfg.editor.scroll_off,
pane.scroll.get(),
);
pane.scroll.set(top);
let text_area = Rect {
x: rect.x + lay.margin_left,
y: rect.y + lay.top,
width: lay.measure,
height: lay.height,
};
let mut current: Option<(usize, DisplayLine)> = None;
for (i, row) in (top..top + lay.height as usize)
.filter_map(|r| cache.row(r))
.enumerate()
{
if row.is_spacer() {
let r = Rect { x: text_area.x, y: text_area.y + i as u16, width: lay.measure, height: 1 };
frame.render_widget(Paragraph::new("").style(Style::default().bg(bg)), r);
current = None;
continue;
}
if let Some(index) = row.embedded() {
let r = Rect {
x: text_area.x,
y: text_area.y + i as u16,
width: lay.measure,
height: 1,
};
render_embed_row(frame, app, &cache, row.line(), index, r, bg);
current = None;
continue;
}
let is_first_row = current.as_ref().is_none_or(|(l, _)| *l != row.line());
if is_first_row {
current = Some((row.line(), display_line_of(app, doc, &cache, row.line(), focused)));
}
let d = ¤t.as_ref().unwrap().1;
let mut spans = window_spans(&d.text, &d.spans, row.start_col, row.end_col);
let shift = gutter_shift(app, &cache, &lay, row.line(), row.start_col);
let hang = hang_x(app, &cache, &lay, &row);
let r = Rect {
x: text_area.x - shift + hang,
y: text_area.y + i as u16,
width: lay.measure + shift - hang,
height: 1,
};
let fenced = in_fence(&cache, row.line());
let row_bg = if fenced { app.theme.code_bg.to_ratatui() } else { bg };
let table = matches!(doc.blocks.kinds.get(row.line()), Some(BlockKind::Table));
let is_last_row = row.end_col >= d.text.chars().count();
let align = row_align(app.text_align, fenced || table, is_last_row);
let mut alignment = None;
match align {
TextAlign::Left => {}
TextAlign::Center => alignment = Some(Alignment::Center),
TextAlign::Right => alignment = Some(Alignment::Right),
TextAlign::Justified => {
let row_text: String =
d.text.chars().skip(row.start_col).take(row.end_col - row.start_col).collect();
let gaps = align::justify_gaps(&row_text, r.width);
if !gaps.is_empty() {
spans = stretch_spans(spans, &gaps);
}
}
}
let mut line = Line::from(spans);
if let Some(a) = alignment {
line = line.alignment(a);
}
frame.render_widget(Paragraph::new(line).style(Style::default().bg(row_bg)), r);
if fenced {
render_fence_bar(frame, app, r);
}
if lay.number_gutter > 0 && is_first_row && shift + lay.number_gutter <= lay.margin_left {
let gutter = Rect {
x: text_area.x - shift - lay.number_gutter,
y: text_area.y + i as u16,
width: lay.number_gutter - 1,
height: 1,
};
render_line_number(frame, app, gutter, numbers, row.line(), cursor.line);
}
}
render_scroll_hint(frame, app, rect, &text_area, top, cache.total_rows());
if !focused {
return None;
}
let screen_row = cursor_row.saturating_sub(top);
if screen_row >= lay.height as usize {
return None;
}
let vr = cache.row(cursor_row);
let row_start = vr.map(|r| r.start_col).unwrap_or(0);
let row_end = vr.map(|r| r.end_col).unwrap_or(0);
let hang = vr.map(|r| hang_x(app, &cache, &lay, &r)).unwrap_or(0);
let line_text = doc.buffer.line_text(cursor.line);
let prefix: String = line_text.chars().skip(row_start).take(cursor_offset).collect();
let shift = gutter_shift(app, &cache, &lay, cursor.line, row_start);
let table = matches!(doc.blocks.kinds.get(cursor.line), Some(BlockKind::Table));
let fenced = in_fence(&cache, cursor.line);
let is_last_row = row_end >= line_text.chars().count();
let align = row_align(app.text_align, fenced || table, is_last_row);
let row_width = lay.measure + shift - hang;
let extra = match align {
TextAlign::Left => 0,
TextAlign::Center | TextAlign::Right => {
let row_text: String = line_text.chars().skip(row_start).take(row_end - row_start).collect();
align::align_offset(align, display_width(&row_text), row_width)
}
TextAlign::Justified => {
let row_text: String = line_text.chars().skip(row_start).take(row_end - row_start).collect();
let gaps = align::justify_gaps(&row_text, row_width);
align::justify_offset(&gaps, cursor_offset)
}
};
let x = (text_area.x + display_width(&prefix) + hang + extra)
.saturating_sub(shift)
.min(rect.right().saturating_sub(1));
Some(Position::new(x, text_area.y + screen_row as u16))
}
fn render_embed_row(
frame: &mut Frame,
app: &App,
cache: &RenderCache,
line: usize,
index: usize,
rect: Rect,
bg: ratatui::style::Color,
) {
use crate::transclude::preview::Row;
let Some(row) = cache
.entry(line)
.and_then(|e| e.embed.as_ref())
.and_then(|x| x.rows.get(index))
else {
return;
};
let theme = &app.theme;
let dim = ThemeStyle::fg(theme.text_dim).to_ratatui();
let width = rect.width as usize;
let pixels = app.image_protocol != crate::image::Protocol::None;
let spans: Vec<Span> = match row {
Row::Image(pic) => {
app.images.borrow_mut().push(crate::render::Placement {
x: rect.x,
y: rect.y,
cols: pic.cols,
rows: pic.rows,
line,
index,
});
if pixels {
vec![Span::raw(" ".repeat(pic.cols as usize))]
} else {
let w = (pic.cols as usize).min(width).max(2);
vec![Span::styled(
format!("\u{256d}{}\u{256e}", "\u{2500}".repeat(w - 2)),
dim,
)]
}
}
Row::Reserved => {
if pixels {
vec![Span::raw("")]
} else {
let (w, last) = cache
.entry(line)
.and_then(|e| e.embed.as_ref())
.map(|x| {
let w = match x.rows.first() {
Some(Row::Image(p)) => (p.cols as usize).min(width).max(2),
_ => width.max(2),
};
let last = !matches!(x.rows.get(index + 1), Some(Row::Reserved));
(w, last)
})
.unwrap_or((width.max(2), true));
let (l, r) = if last { ('\u{2570}', '\u{256f}') } else { ('\u{2502}', '\u{2502}') };
let fill = if last { "\u{2500}".repeat(w - 2) } else { " ".repeat(w - 2) };
vec![Span::styled(format!("{l}{fill}{r}"), dim)]
}
}
Row::Caption(text) => vec![Span::styled(elide(text, width), dim)],
Row::Top(label) => {
let room = width.saturating_sub(5);
let label = elide(label, room);
let head = format!("\u{256d} {label} ");
let rule = "\u{2500}".repeat(width.saturating_sub(display_width(&head) as usize + 1));
vec![Span::styled(format!("{head}{rule}\u{256e}"), dim)]
}
Row::Bottom => vec![Span::styled(
format!(
"\u{2570}{}\u{256f}",
"\u{2500}".repeat(width.saturating_sub(2))
),
dim,
)],
Row::Error(msg) => vec![Span::styled(
msg.clone(),
ThemeStyle::fg(theme.error).to_ratatui(),
)],
Row::Body(text, block) => {
let md = &app.config.markdown;
let inline = crate::render::markdown::inline::scan(text, md);
let styled = crate::render::markdown::style_line_with(
text,
block.clone(),
crate::text::buffer::Syntax::Markdown,
theme,
md,
&inline,
);
let ctx = crate::render::conceal::ConcealCtx {
glyphs: &app.config.glyphs,
theme,
measure: width as u16,
tab_width: app.config.editor.tab_width,
};
let (shown, spans) = if app.config.layout.conceal {
crate::render::conceal::ConcealMap::build(text, block, &inline, &ctx)
.render(text, &styled)
} else {
(text.clone(), styled)
};
let bordered = app.config.transclude.border && app.embed_mode.framed();
let mut out = if bordered {
vec![Span::styled("\u{2502} ".to_string(), dim)]
} else {
Vec::new()
};
out.extend(window_spans(&shown, &spans, 0, shown.chars().count()));
if bordered {
let used = 2 + display_width(&shown) as usize;
out.push(Span::styled(
" ".repeat(width.saturating_sub(used + 2)) + " \u{2502}",
dim,
));
}
out
}
};
frame.render_widget(
Paragraph::new(Line::from(spans)).style(Style::default().bg(bg)),
rect,
);
}
fn elide(s: &str, cells: usize) -> String {
if display_width(s) as usize <= cells {
return s.to_string();
}
if cells <= 1 {
return "\u{2026}".to_string();
}
let mut out = String::new();
let mut w = 0usize;
for c in s.chars() {
let cw = display_width(&c.to_string()) as usize;
if w + cw > cells - 1 {
break;
}
out.push(c);
w += cw;
}
out.push('\u{2026}');
out
}
fn hang_x(app: &App, cache: &RenderCache, lay: &Layout, row: &VisualRow) -> u16 {
if row.hanging == 0 {
return 0;
}
let line_shift = gutter_shift(app, cache, lay, row.line(), 0);
row.hanging.saturating_sub(line_shift).min(lay.measure)
}
fn in_fence(cache: &RenderCache, line: usize) -> bool {
cache.entry(line).is_some_and(|e| {
matches!(
e.block,
BlockKind::FenceOpen(_) | BlockKind::FenceBody { .. } | BlockKind::FenceClose
)
})
}
fn number_gutter_width(mode: NumberMode, line_count: usize, margin_left: u16) -> u16 {
if !mode.is_on() {
return 0;
}
let digits = line_count.max(1).to_string().len() as u16;
if digits < margin_left { digits + 1 } else { 0 }
}
fn render_line_number(frame: &mut Frame, app: &App, gutter: Rect, mode: NumberMode, line: usize, cursor_line: usize) {
let n = if mode == NumberMode::Relative {
line.abs_diff(cursor_line)
} else {
line + 1
};
let color = if line == cursor_line { app.theme.text } else { app.theme.text_dim };
let style = Style::default()
.fg(color.to_ratatui())
.bg(app.theme.background.to_ratatui());
let digits = gutter.width as usize;
frame.render_widget(Paragraph::new(Span::styled(format!("{n:>digits$}"), style)), gutter);
}
fn render_fence_bar(frame: &mut Frame, app: &App, row: Rect) {
let glyph = &app.config.glyphs.fence_bar;
if glyph.is_empty() || row.x == 0 {
return;
}
let style = Style::default()
.fg(app.theme.fence_bar.to_ratatui())
.bg(app.theme.background.to_ratatui());
frame.render_widget(
Paragraph::new(Line::from(Span::styled(glyph.clone(), style))),
Rect { x: row.x - 1, y: row.y, width: 1, height: 1 },
);
}
fn render_scroll_hint(
frame: &mut Frame,
app: &App,
rect: Rect,
text_area: &Rect,
top: usize,
total: usize,
) {
let glyph = &app.config.glyphs.scroll_hint;
let height = text_area.height as usize;
if !app.config.layout.scroll_hint || glyph.is_empty() || total <= height || height == 0 {
return;
}
if rect.right() <= text_area.right() {
return;
}
let len = ((height * height) / total).max(1).min(height);
let span = total - height; let offset = (top.min(span) * (height - len) + span / 2) / span;
let style = Style::default().fg(app.theme.text_dim.to_ratatui());
let thumb: Vec<Line> = (0..len)
.map(|_| Line::from(Span::styled(glyph.clone(), style)))
.collect();
frame.render_widget(
Paragraph::new(thumb),
Rect {
x: rect.right() - 1,
y: text_area.y + offset as u16,
width: 1,
height: len as u16,
},
);
}
fn gutter_shift(app: &App, cache: &RenderCache, lay: &Layout, line: usize, start_col: usize) -> u16 {
if !app.config.layout.stable_gutter
|| !cache.conceal_on()
|| cache.is_concealed(line)
|| start_col != 0
{
return 0;
}
let Some(entry) = cache.entry(line) else {
return 0;
};
entry.cmap.leading_shift(&entry.source).min(lay.margin_left)
}
fn tree_width(app: &App, area: Rect) -> u16 {
if app.tree.is_some() {
crate::tree::WIDTH.min(area.width / 2)
} else {
0
}
}
fn render_tree(frame: &mut Frame, app: &App, tree: &FileTree, rect: Rect) {
let theme = &app.theme;
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(Style::default().fg(theme.text_dim.to_ratatui()))
.style(Style::default().bg(theme.background.to_ratatui()));
let inner = block.inner(rect);
frame.render_widget(Clear, rect);
frame.render_widget(block, rect);
if inner.width == 0 || inner.height == 0 {
return;
}
let h = inner.height as usize;
let len = tree.entries.len();
let start = if len <= h {
0
} else {
tree.selected.saturating_sub(h / 2).min(len - h)
};
let width = inner.width as usize;
let mut lines: Vec<Line> = Vec::with_capacity(h);
for (i, e) in tree.entries.iter().enumerate().skip(start).take(h) {
lines.push(tree_row(app, tree, e, i == tree.selected, width));
}
frame.render_widget(
Paragraph::new(lines).style(Style::default().bg(theme.background.to_ratatui())),
inner,
);
if tree.focused {
let row = tree.selected.saturating_sub(start);
frame.set_cursor_position(Position::new(inner.x, inner.y + row as u16));
}
}
fn tree_row<'a>(app: &App, tree: &FileTree, e: &Entry, selected: bool, width: usize) -> Line<'a> {
let theme = &app.theme;
let glyphs = &app.config.glyphs;
let nerd = glyphs.nerd_fonts;
let sel_bg = |s: Style| {
if selected {
s.bg(theme.selection.to_ratatui())
} else {
s
}
};
let mut prefix = String::new();
for &running in &e.guides {
prefix.push_str(if running { "│ " } else { " " });
}
if e.depth > 0 {
prefix.push_str(if e.last { "└╴" } else { "├╴" });
}
let (icon, icon_color) = if e.is_dir {
let open = tree.is_expanded(&e.path);
let glyph = match (nerd, open) {
(true, true) => glyphs.folder_open.as_str(),
(true, false) => glyphs.folder.as_str(),
(false, true) => "▾",
(false, false) => "▸",
};
(glyph.to_string(), theme.link)
} else if nerd {
let (glyph, kind) = crate::tree::file_icon(&e.name);
(glyph.to_string(), icon_color(theme, kind))
} else {
(" ".to_string(), theme.text_dim)
};
let mut name_style = Style::default().fg(if e.is_dir { theme.link } else { theme.text }.to_ratatui());
if e.is_dir {
name_style = name_style.add_modifier(Modifier::BOLD);
}
let fixed = prefix.chars().count() + icon.chars().count() + 1;
let room = width.saturating_sub(fixed);
let name: String = if e.name.chars().count() > room {
e.name.chars().take(room).collect()
} else {
e.name.clone()
};
let pad = width.saturating_sub(fixed + name.chars().count());
Line::from(vec![
Span::styled(
prefix,
sel_bg(Style::default().fg(theme.text_dim.to_ratatui()).add_modifier(Modifier::DIM)),
),
Span::styled(icon, sel_bg(Style::default().fg(icon_color.to_ratatui()))),
Span::styled(" ", sel_bg(Style::default())),
Span::styled(name, sel_bg(name_style)),
Span::styled(" ".repeat(pad), sel_bg(Style::default())),
])
}
fn render_diff(frame: &mut Frame, app: &App, view: &crate::diff::DiffView, area: Rect) {
use crate::diff::Row;
let theme = &app.theme;
let accent = theme.link.to_ratatui();
let dim = theme.text_dim.to_ratatui();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.style(Style::default().bg(theme.background.to_ratatui()))
.title(Span::styled(
format!(" diff: {} ", view.name),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
.title_bottom(
Line::from(Span::styled(
" Tab cycle · n p difference · l f b all · w commit · q abort ",
Style::default().fg(dim),
))
.right_aligned(),
);
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
if inner.width < 8 || inner.height < 2 {
return;
}
let side = (inner.width as usize - 1) / 2;
let right_w = inner.width as usize - 1 - side;
let head = Rect { height: 1, ..inner };
let body = Rect {
y: inner.y + 1,
height: inner.height - 1,
..inner
};
let pad = |s: String, w: usize| -> String {
let mut out = elide(&s, w);
let used = display_width(&out) as usize;
out.push_str(&" ".repeat(w.saturating_sub(used)));
out
};
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(pad(" live — in the editor".into(), side), Style::default().fg(dim)),
Span::styled("│", Style::default().fg(dim)),
Span::styled(pad(" file — on disk".into(), right_w), Style::default().fg(dim)),
])),
head,
);
let filler = |w: usize| "\u{2504}".repeat(w);
let current = view.align.hunks.get(view.hunk).cloned();
let mut lines: Vec<Line> = Vec::with_capacity(body.height as usize);
for i in 0..body.height as usize {
let row_index = view.scroll + i;
let Some(row) = view.align.rows.get(row_index) else {
break;
};
let (colour, marks) = match row {
Row::Same { .. } => (theme.text.to_ratatui(), (' ', ' ')),
Row::Changed { .. } => (theme.diff_change.to_ratatui(), ('~', '~')),
Row::Added { .. } => (theme.diff_add.to_ratatui(), (' ', '+')),
Row::Removed { .. } => (theme.diff_remove.to_ratatui(), ('-', ' ')),
};
let here = current.as_ref().is_some_and(|h| h.contains(&row_index));
let chosen = view.side_at(row_index);
let bar = match chosen {
Some(s) => Span::styled(
s.glyph().to_string(),
match here {
true => Style::default().fg(accent).add_modifier(Modifier::BOLD),
false => Style::default().fg(dim),
},
),
None => Span::styled("│".to_string(), Style::default().fg(dim)),
};
let keeps = |mine: bool| match chosen {
None => true,
Some(crate::diff::Side::Both) => true,
Some(crate::diff::Side::Live) => mine,
Some(crate::diff::Side::File) => !mine,
};
let text = |line: Option<usize>,
from: &[String],
mark: char,
w: usize,
live: bool|
-> Span<'static> {
match line {
Some(n) => Span::styled(
pad(format!("{mark}{}", from.get(n).cloned().unwrap_or_default()), w),
match keeps(live) {
true => Style::default().fg(colour),
false => Style::default().fg(dim).add_modifier(Modifier::CROSSED_OUT),
},
),
None => Span::styled(filler(w), Style::default().fg(dim)),
}
};
lines.push(Line::from(vec![
text(row.left(), &view.mine, marks.0, side, true),
bar,
text(row.right(), &view.theirs, marks.1, right_w, false),
]));
}
frame.render_widget(Paragraph::new(lines), body);
}
fn render_help(frame: &mut Frame, app: &App, help: &Help, area: Rect) {
let theme = &app.theme;
let margin = 2u16;
let rect = Rect {
x: area.x + margin,
y: area.y + margin.min(area.height / 4),
width: area.width.saturating_sub(margin * 2).max(10),
height: area.height.saturating_sub(margin * 2).max(3),
};
let accent = theme.link.to_ratatui();
let block = Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(Style::default().fg(accent))
.style(Style::default().bg(theme.background.to_ratatui()))
.title(Span::styled(
format!(" help: {} ", help.title),
Style::default().fg(accent).add_modifier(Modifier::BOLD),
))
.title_bottom(
Line::from(Span::styled(
" j/k scroll · q close ",
Style::default().fg(theme.text_dim.to_ratatui()),
))
.right_aligned(),
);
let inner = block.inner(rect);
frame.render_widget(Clear, rect);
frame.render_widget(block, rect);
let view_h = inner.height as usize;
let max_scroll = help.lines.len().saturating_sub(view_h);
let scroll = help.scroll.min(max_scroll) as u16;
let lines: Vec<Line> = help
.lines
.iter()
.map(|l| Line::from(Span::styled(l.clone(), Style::default().fg(theme.text.to_ratatui()))))
.collect();
frame.render_widget(Paragraph::new(lines).scroll((scroll, 0)), inner);
}
pub fn visible_line_range(app: &App, area: Rect) -> Option<(usize, usize)> {
let cfg = &app.config;
let rect = focused_rect(app, area)?;
let lay = Layout::compute(&cfg.layout, rect.width, rect.height, 0);
let mut cache = app.cache.borrow_mut();
cache.sync(app, lay.measure);
let (cursor_row, _) = cache.locate(app.buffer.cursor.line, app.buffer.cursor.col);
let top = scroll_offset(
cursor_row,
cache.total_rows(),
&lay,
&cfg.layout,
cfg.editor.scroll_off,
app.scroll_hint(),
);
let first = cache.row(top)?.line();
let last_row = (top + lay.height as usize)
.min(cache.total_rows())
.saturating_sub(1);
let last = cache.row(last_row)?.line();
Some((first, last))
}
fn focused_rect(app: &App, area: Rect) -> Option<Rect> {
app.layout
.geometry(pane_area(app, area))
.panes
.into_iter()
.find(|(id, _)| *id == app.focus_pane)
.map(|(_, r)| r)
}
pub fn pane_at(app: &App, area: Rect, col: u16, row: u16) -> Option<crate::render::pane::PaneId> {
app.layout
.geometry(pane_area(app, area))
.panes
.into_iter()
.find(|(_, r)| col >= r.x && col < r.right() && row >= r.y && row < r.bottom())
.map(|(id, _)| id)
}
pub fn locate_click(app: &App, area: Rect, col: u16, row: u16) -> Option<Cursor> {
locate(app, area, col, row, false)
}
pub fn locate_drag(app: &App, area: Rect, col: u16, row: u16) -> Option<Cursor> {
locate(app, area, col, row, true)
}
fn locate(app: &App, area: Rect, col: u16, row: u16, clamp: bool) -> Option<Cursor> {
let cfg = &app.config;
let rect = focused_rect(app, area)?;
if !clamp && (col < rect.x || col >= rect.right() || row < rect.y || row >= rect.bottom()) {
return None;
}
let col = col.clamp(rect.x, rect.right().saturating_sub(1));
let row = row.clamp(rect.y, rect.bottom().saturating_sub(1));
let ex = rect.x;
let lay = Layout::compute(&cfg.layout, rect.width, rect.height, 0);
let mut cache = app.cache.borrow_mut();
cache.sync(app, lay.measure);
let (cursor_row, _) = cache.locate(app.buffer.cursor.line, app.buffer.cursor.col);
let top = scroll_offset(
cursor_row,
cache.total_rows(),
&lay,
&cfg.layout,
cfg.editor.scroll_off,
app.scroll_hint(),
);
if lay.height == 0 {
return None;
}
let screen_row = if row < rect.y + lay.top {
if !clamp {
return None;
}
0
} else {
(row - rect.y - lay.top) as usize
};
let last = lay.height as usize - 1;
if screen_row > last && !clamp {
return None;
}
let screen_row = screen_row.min(last);
let vr = match cache.row(top + screen_row) {
Some(vr) => vr,
None if clamp => cache.row(cache.total_rows().checked_sub(1)?)?,
None => return None,
};
let line = vr.line();
if !vr.editable {
return Some(Cursor::new(line, 0));
}
let entry = cache.entry(line)?;
let concealed = cache.is_concealed(line);
let shift = gutter_shift(app, &cache, &lay, line, vr.start_col);
let hang = hang_x(app, &cache, &lay, &vr);
let raw_click_x = (col + shift).saturating_sub(ex + lay.margin_left + hang);
let table = matches!(app.blocks.kinds.get(line), Some(BlockKind::Table));
let fenced = in_fence(&cache, line);
let display_text = if concealed { entry.cmap.display_text(&entry.source) } else { entry.source.clone() };
let is_last_row = vr.end_col >= display_text.chars().count();
let align = row_align(app.text_align, fenced || table, is_last_row);
let row_width = lay.measure + shift - hang;
let click_x = match align {
TextAlign::Left => raw_click_x,
TextAlign::Center | TextAlign::Right => {
let row_text: String =
display_text.chars().skip(vr.start_col).take(vr.end_col - vr.start_col).collect();
raw_click_x.saturating_sub(align::align_offset(align, display_width(&row_text), row_width))
}
TextAlign::Justified => {
let row_text: String =
display_text.chars().skip(vr.start_col).take(vr.end_col - vr.start_col).collect();
let gaps = align::justify_gaps(&row_text, row_width);
align::unstretch_col(&gaps, raw_click_x)
}
};
let display_col = vr.start_col as u16 + click_x;
let len = entry.source.chars().count();
let src_col = if concealed {
entry.cmap.source_col(&entry.source, display_col).min(len)
} else {
(display_col as usize).min(len)
};
Some(Cursor::new(line, src_col))
}
fn render_command_box(
frame: &mut Frame,
app: &App,
title: &str,
prompt: &str,
buf: &str,
area: Rect,
) {
let theme = &app.theme;
let wanted = 60.max(display_width(prompt) + display_width(buf) + 4);
let width = wanted.min(area.width.saturating_sub(4)).max(20);
let height = 3u16;
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + (area.height / 3).min(area.height.saturating_sub(height));
let rect = Rect { x, y, width, height };
app.overlay.set(Some(rect));
let accent = spotlight_accent(app);
let block = spotlight_block(app, accent, title);
let inner = block.inner(rect);
frame.render_widget(Clear, rect);
frame.render_widget(block, rect);
let line = Line::from(vec![
Span::styled(prompt.to_string(), Style::default().fg(accent.to_ratatui())),
Span::styled(
buf.to_string(),
Style::default()
.fg(theme.text.to_ratatui())
.add_modifier(Modifier::BOLD),
),
]);
frame.render_widget(Paragraph::new(line), inner);
let cx = (inner.x + display_width(prompt) + display_width(buf))
.min(inner.x + inner.width.saturating_sub(1));
frame.set_cursor_position(Position::new(cx, inner.y));
}
fn prompt_label(app: &App, p: &Prompt) -> String {
let shown = |path: &std::path::Path| {
app.tree
.as_ref()
.map(|t| t.relative(path))
.unwrap_or_else(|| path.display().to_string())
};
match &p.kind {
PromptKind::Create => format!("{}/", shown(&p.target)),
PromptKind::Rename => "name: ".to_string(),
PromptKind::Move => "to: ".to_string(),
PromptKind::Export { format, justify } => {
let mode = if *justify { " (justified)" } else { "" };
format!("{}{mode} to: ", format.name())
}
PromptKind::Delete { entries } => {
let what = shown(&p.target);
if *entries > 1 {
format!("delete {what} and {} entries inside? (y/N) ", entries - 1)
} else {
format!("delete {what}? (y/N) ")
}
}
}
}
fn spotlight_accent(app: &App) -> ThemeColor {
match app.flash.as_ref().map(|f| &f.kind) {
Some(FlashKind::Error) => app.theme.error,
_ => app.theme.link,
}
}
fn spotlight_block<'a>(app: &App, accent: ThemeColor, title: &'a str) -> Block<'a> {
let bold_accent = Style::default()
.fg(accent.to_ratatui())
.add_modifier(Modifier::BOLD);
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.border_style(bold_accent)
.style(Style::default().bg(app.theme.background.to_ratatui()))
.title(Span::styled(title, bold_accent))
}
fn render_finder(frame: &mut Frame, app: &App, finder: &Finder, area: Rect) {
let theme = &app.theme;
let accent = spotlight_accent(app);
let width = 60u16.min(area.width.saturating_sub(4)).max(20);
let inner_w = width.saturating_sub(2) as usize;
let typed = format!("> {}", finder.query);
let count = format!(
"{}/{}{}",
finder.matches.len(),
finder.file_count(),
if finder.truncated { "+" } else { "" }
);
let gap = inner_w.saturating_sub(typed.chars().count() + count.chars().count());
let mut lines = vec![Line::from(vec![
Span::styled("> ", Style::default().fg(accent.to_ratatui())),
Span::styled(
finder.query.clone(),
Style::default()
.fg(theme.text.to_ratatui())
.add_modifier(Modifier::BOLD),
),
Span::raw(" ".repeat(gap)),
Span::styled(
count,
Style::default()
.fg(theme.text_dim.to_ratatui())
.add_modifier(Modifier::DIM),
),
])];
let len = finder.matches.len();
let shown = len.min(VISIBLE_ROWS);
let start = if len <= VISIBLE_ROWS {
0
} else {
finder.selected.saturating_sub(VISIBLE_ROWS / 2).min(len - VISIBLE_ROWS)
};
for i in start..start + shown {
let Some((rel, positions)) = finder.row(i) else {
continue;
};
lines.push(result_line(app, rel, positions, i == finder.selected, inner_w));
}
if len == 0 {
lines.push(Line::from(Span::styled(
" no matching files",
Style::default()
.fg(theme.text_dim.to_ratatui())
.add_modifier(Modifier::DIM),
)));
}
let height = (lines.len() as u16 + 2).min(area.height);
let x = area.x + area.width.saturating_sub(width) / 2;
let y = area.y + (area.height / 3).min(area.height.saturating_sub(height));
let rect = Rect { x, y, width, height };
app.overlay.set(Some(rect));
let block = spotlight_block(app, accent, " find ");
let inner = block.inner(rect);
frame.render_widget(Clear, rect);
frame.render_widget(block, rect);
frame.render_widget(Paragraph::new(lines), inner);
let cx = (inner.x + 2 + display_width(&finder.query)).min(inner.x + inner.width.saturating_sub(1));
frame.set_cursor_position(Position::new(cx, inner.y));
}
fn result_line<'a>(
app: &App,
rel: &str,
positions: &[usize],
selected: bool,
width: usize,
) -> Line<'a> {
let theme = &app.theme;
let accent = spotlight_accent(app);
let base = if selected {
Style::default()
.fg(theme.text.to_ratatui())
.bg(theme.selection.to_ratatui())
} else {
Style::default().fg(theme.text_dim.to_ratatui())
};
let hit = base.fg(accent.to_ratatui()).add_modifier(Modifier::BOLD);
let mut spans = vec![Span::styled(if selected { "▸ " } else { " " }, base)];
let mut lead = 2;
if app.config.glyphs.nerd_fonts {
let name = rel.rsplit_once('/').map(|(_, n)| n).unwrap_or(rel);
let (glyph, kind) = crate::tree::file_icon(name);
spans.push(Span::styled(
format!("{glyph} "),
base.fg(icon_color(theme, kind).to_ratatui()),
));
lead += 2;
}
let avail = width.saturating_sub(lead);
let chars: Vec<char> = rel.chars().collect();
let (skipped, elided) = if chars.len() > avail && avail > 1 {
(chars.len() - (avail - 1), true)
} else {
(0, false)
};
if elided {
spans.push(Span::styled("…", base));
}
for (i, c) in chars.iter().enumerate().skip(skipped) {
let style = if positions.contains(&i) { hit } else { base };
spans.push(Span::styled(c.to_string(), style));
}
let drawn = lead + usize::from(elided) + (chars.len() - skipped);
spans.push(Span::styled(" ".repeat(width.saturating_sub(drawn)), base));
Line::from(spans)
}
fn icon_color(theme: &crate::render::theme::Theme, kind: IconKind) -> ThemeColor {
match kind {
IconKind::Prose => theme.headings[0],
IconKind::Code => theme.code,
IconKind::Data => theme.tag,
IconKind::Media => theme.wiki_link,
IconKind::Plain => theme.text_dim,
}
}
struct DisplayLine {
text: String,
spans: Vec<StyledSpan>,
}
fn display_line_of(
app: &App,
doc: &crate::app::BufferState,
cache: &RenderCache,
line: usize,
focused: bool,
) -> DisplayLine {
let _ = doc;
let Some(entry) = cache.entry(line) else {
return DisplayLine {
text: String::new(),
spans: Vec::new(),
};
};
let source = &entry.source;
let mut styled = entry.styled.clone();
if let Some(region) = app.focus_region.as_ref().filter(|_| focused) {
apply_focus_dim(&mut styled, source.chars().count(), region, line);
}
if matches!(app.mode, Mode::Command(_) | Mode::Search { .. }) || app.finder.is_some() {
overlay_dim(&mut styled, 0..source.chars().count());
}
if let Some(search) = &app.search {
let needle: Vec<char> = search.pattern.chars().collect();
let hay: Vec<char> = source.chars().collect();
let mut i = 0;
while let Some(pos) = find_sub(&hay, &needle, i) {
overlay_bg(&mut styled, pos..pos + needle.len(), app.theme.search_match);
i = pos + needle.len();
}
}
if !cache.is_concealed(line) {
if let Some((s, e)) = selection_cols(app, line, source.chars().count()).filter(|_| focused) {
overlay_bg(&mut styled, s..e, app.theme.selection);
}
return DisplayLine {
text: source.clone(),
spans: styled,
};
}
let (text, spans) = entry.cmap.render(source, &styled);
DisplayLine { text, spans }
}
fn selection_cols(app: &App, line: usize, len: usize) -> Option<(usize, usize)> {
if !matches!(app.mode, Mode::Visual | Mode::VisualLine) {
return None;
}
let anchor = app.anchor?;
let cursor = app.buffer.cursor;
let (lo, hi) = if (anchor.line, anchor.col) <= (cursor.line, cursor.col) {
(anchor, cursor)
} else {
(cursor, anchor)
};
if line < lo.line || line > hi.line {
return None;
}
if matches!(app.mode, Mode::VisualLine) {
return Some((0, len));
}
let start = if line == lo.line { lo.col } else { 0 };
let end = if line == hi.line { hi.col + 1 } else { len };
Some((start.min(len), end.min(len)))
}
fn overlay_bg(spans: &mut Vec<StyledSpan>, range: std::ops::Range<usize>, bg: ThemeColor) {
overlay(spans, range, |style| style.bg = bg);
}
fn overlay_dim(spans: &mut Vec<StyledSpan>, range: std::ops::Range<usize>) {
overlay(spans, range, |style| style.attrs.dim = true);
}
fn overlay(
spans: &mut Vec<StyledSpan>,
range: std::ops::Range<usize>,
tweak: impl Fn(&mut crate::render::theme::Style),
) {
if range.start >= range.end {
return;
}
let mut out = Vec::with_capacity(spans.len() + 2);
for sp in spans.drain(..) {
let (s, e) = (sp.range.start, sp.range.end);
let a = range.start.max(s);
let b = range.end.min(e);
if a >= b {
out.push(sp);
continue;
}
if s < a {
out.push(StyledSpan { range: s..a, style: sp.style });
}
let mut mid = sp.style;
tweak(&mut mid);
out.push(StyledSpan { range: a..b, style: mid });
if b < e {
out.push(StyledSpan { range: b..e, style: sp.style });
}
}
*spans = out;
}
fn find_sub(hay: &[char], needle: &[char], from: usize) -> Option<usize> {
if needle.is_empty() || needle.len() > hay.len() || from > hay.len() - needle.len() {
return None;
}
(from..=hay.len() - needle.len()).find(|&i| hay[i..i + needle.len()] == needle[..])
}
fn apply_focus_dim(spans: &mut Vec<StyledSpan>, len: usize, region: &FocusRegion, line: usize) {
if line < region.start.line || line > region.end.line {
overlay_dim(spans, 0..len);
return;
}
let keep_start = if line == region.start.line { region.start.col } else { 0 };
let keep_end = if line == region.end.line { region.end.col.min(len) } else { len };
if keep_start > 0 {
overlay_dim(spans, 0..keep_start.min(len));
}
if keep_end < len {
overlay_dim(spans, keep_end..len);
}
}
fn row_align(align: TextAlign, ineligible: bool, is_last_row: bool) -> TextAlign {
if ineligible || (align == TextAlign::Justified && is_last_row) {
return TextAlign::Left;
}
align
}
fn stretch_spans(spans: Vec<Span<'static>>, gaps: &[(usize, u16)]) -> Vec<Span<'static>> {
let mut out = Vec::with_capacity(spans.len() + gaps.len());
let mut idx = 0usize;
let mut next_gap = 0usize;
for span in spans {
let mut buf = String::new();
for ch in span.content.chars() {
buf.push(ch);
idx += 1;
if next_gap < gaps.len() && idx == gaps[next_gap].0 + 1 {
out.push(Span::styled(std::mem::take(&mut buf), span.style));
out.push(Span::raw(" ".repeat(gaps[next_gap].1 as usize)));
next_gap += 1;
}
}
if !buf.is_empty() {
out.push(Span::styled(buf, span.style));
}
}
out
}
fn window_spans(source: &str, styled: &[StyledSpan], start: usize, end: usize) -> Vec<Span<'static>> {
let chars: Vec<char> = source.chars().collect();
let end = end.min(chars.len());
let mut out = Vec::new();
for sp in styled {
let s = sp.range.start.max(start);
let e = sp.range.end.min(end);
if s >= e {
continue;
}
let text: String = chars[s..e].iter().collect();
out.push(Span::styled(text, sp.style.to_ratatui()));
}
out
}
fn status_text(app: &App) -> Option<String> {
if matches!(app.mode, Mode::Command(_) | Mode::Search { .. }) || app.finder.is_some() {
return None;
}
let flash = app.flash.as_ref()?;
if let Some(text) = flash.text.as_ref() {
return Some(text.clone());
}
let mut parts: Vec<String> = Vec::new();
for item in &app.config.status.show {
match item.as_str() {
"file" => {
let mut name = app.buffer.display_name();
let marker = &app.config.glyphs.conflict;
if app.buffer.conflict && !marker.is_empty() {
name.push(' ');
name.push_str(marker);
}
parts.push(name);
}
"modified" if app.buffer.modified => parts.push(app.config.glyphs.modified.clone()),
"position" => parts.push(format!(
"{}:{}",
app.buffer.cursor.line + 1,
app.buffer.cursor.col + 1
)),
"words" => parts.push(format!("{} words", app.buffer.word_count())),
_ => {}
}
}
if parts.is_empty() {
None
} else {
Some(parts.join(" · "))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::app::App;
use crate::config::Config;
use crate::render::markdown::block::BlockCache;
use crate::render::theme::Color;
use crate::text::cursor::Cursor;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn app_with(text: &str) -> App {
let mut app = App::new(Config::default(), None, None).unwrap();
pin_theme(&mut app);
app.buffer.insert_str(Cursor::new(0, 0), text);
app.blocks = BlockCache::build(&app.buffer);
app
}
fn pin_theme(app: &mut App) {
let theme = crate::render::theme::Theme::authored(&app.config.theme).unwrap();
app.theme = theme;
}
fn render_to(app: &App, w: u16, h: u16) -> ratatui::buffer::Buffer {
let mut terminal = Terminal::new(TestBackend::new(w, h)).unwrap();
terminal.draw(|f| render(f, app)).unwrap();
terminal.backend().buffer().clone()
}
fn rows(buf: &ratatui::buffer::Buffer) -> Vec<String> {
(0..buf.area.height)
.map(|y| {
(0..buf.area.width)
.map(|x| buf[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect()
}
#[test]
fn a_highlighted_fence_reaches_the_screen() {
let app = app_with("```rust\nlet s = \"hi\";\n```\n");
let buf = render_to(&app, 80, 12);
let row = (0..buf.area.height)
.find(|y| (0..buf.area.width).any(|x| buf[(x, *y)].symbol() == "l"))
.expect("the body line is on screen");
let colors: Vec<ratatui::style::Color> = (0..buf.area.width)
.map(|x| &buf[(x, row)])
.filter(|c| !c.symbol().trim().is_empty())
.map(|c| c.fg)
.collect();
assert!(colors.contains(&app.theme.syntax_keyword.to_ratatui()), "`let` is a keyword");
assert!(colors.contains(&app.theme.syntax_string.to_ratatui()), "`\"hi\"` is a string");
let bg = app.theme.code_bg.to_ratatui();
assert!((0..buf.area.width).filter(|x| buf[(*x, row)].bg == bg).count() >= 40);
}
#[test]
fn line_spacing_draws_air_between_lines() {
let mut app = app_with("alpha\nbravo\n\ncharlie\n");
app.config.layout.line_spacing = 1;
let drawn = rows(&render_to(&app, 60, 16));
let row_of = |word: &str| drawn.iter().position(|r| r.trim() == word).expect(word);
assert_eq!(row_of("bravo") - row_of("alpha"), 2);
assert_eq!(row_of("charlie") - row_of("bravo"), 3);
}
#[test]
fn heading_line_gets_heading_style() {
let app = app_with("# Title\n\nbody text\n");
let buf = render_to(&app, 80, 12);
let heading = app.theme.headings[0].to_ratatui();
let painted_heading = buf.content().iter().any(|c| {
c.symbol().chars().any(|ch| ch.is_alphabetic()) && c.fg == heading
});
assert!(painted_heading, "expected a heading-colored glyph on screen");
}
#[test]
fn heading_marker_is_dimmed() {
let app = app_with("# Title\n");
let buf = render_to(&app, 80, 6);
let dim = Color::None; let hash = buf
.content()
.iter()
.find(|c| c.symbol() == "#")
.expect("hash marker should be on screen");
assert_eq!(hash.fg, app.theme.text_dim.to_ratatui());
assert_ne!(hash.fg, dim.to_ratatui());
}
fn row_text(buf: &ratatui::buffer::Buffer, y: u16) -> String {
(0..buf.area().width)
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
.trim_end()
.to_string()
}
#[test]
fn an_ordinal_reads_as_loud_as_a_bullet() {
let mut app = app_with("intro\n\n- bulleted\n1. numbered\n2. second\n10. ten\n");
let bullet = app.theme.list_bullet.to_ratatui();
let dim = app.theme.text_dim.to_ratatui();
let check = |buf: &ratatui::buffer::Buffer, ch: &str| {
let pos = (0..buf.area().height)
.flat_map(|y| (0..buf.area().width).map(move |x| (x, y)))
.find(|p| buf[*p].symbol() == ch)
.unwrap_or_else(|| panic!("{ch:?} should be on screen"));
let cell = &buf[pos];
assert_eq!(cell.fg, bullet, "{ch:?} should wear the bullet color");
assert_ne!(cell.fg, dim, "{ch:?} should not be a dimmed marker");
assert!(
!cell.modifier.contains(Modifier::DIM),
"{ch:?} should not carry the DIM attribute either"
);
};
let buf = render_to(&app, 60, 24);
for ch in ["1", "2", "0", "."] {
check(&buf, ch);
}
assert_eq!(buf[(0..buf.area().height)
.flat_map(|y| (0..buf.area().width).map(move |x| (x, y)))
.find(|p| buf[*p].symbol() == "\u{2022}")
.expect("a concealed bullet")]
.fg, bullet);
app.buffer.cursor = Cursor::new(3, 0);
app.active = crate::render::conceal::ActiveSet { start: 3, end: 3 };
let buf = render_to(&app, 60, 24);
for ch in ["1", "."] {
check(&buf, ch);
}
}
fn composition(body: &str, frag: &str) -> (std::path::PathBuf, App) {
use std::sync::atomic::{AtomicUsize, Ordering};
static N: AtomicUsize = AtomicUsize::new(0);
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let n = N.fetch_add(1, Ordering::Relaxed);
let d = std::env::temp_dir().join(format!("shoin-fx-{t}-{n}"));
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("frag.md"), frag).unwrap();
let note = d.join("note.md");
std::fs::write(¬e, body).unwrap();
let mut app = App::new(Config::default(), Some(note), None).unwrap();
pin_theme(&mut app);
app.embed_mode = crate::transclude::Mode::Short;
app.refresh_blocks();
(d, app)
}
#[test]
fn an_embed_expands_where_it_sits() {
let (d, app) = composition("top\n\n![[frag]]\n\ntail\n", "# Frag\n\nfragment body\n");
let rows: Vec<String> = (0..24).map(|y| row_text(&render_to(&app, 80, 24), y)).collect();
let all = rows.join("\n");
assert!(all.contains("fragment body"), "the target is drawn");
assert!(all.contains("frag"), "…under its label");
assert!(!all.contains("![["), "and its source is not");
let top = rows.iter().position(|r| r.contains("top")).unwrap();
let body = rows.iter().position(|r| r.contains("fragment body")).unwrap();
let tail = rows.iter().position(|r| r.contains("tail")).unwrap();
assert!(top < body && body < tail);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_active_embed_line_shows_its_source() {
let (d, mut app) = composition("top\n\n![[frag]]\n", "fragment body\n");
app.buffer.cursor = Cursor::new(2, 0);
app.active = crate::render::conceal::ActiveSet { start: 2, end: 2 };
let buf = render_to(&app, 80, 24);
let all: String = (0..24).map(|y| row_text(&buf, y)).collect::<Vec<_>>().join("\n");
assert!(all.contains("![[frag]]"), "the cursor's line is raw");
assert!(!all.contains("fragment body"), "so it is not expanded");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn embedded_markdown_is_concealed_like_any_other_line() {
let (d, app) = composition("top\n![[frag]]\n", "# Frag title\n\nwith **bold** in it\n");
let buf = render_to(&app, 80, 24);
let all: String = (0..24).map(|y| row_text(&buf, y)).collect::<Vec<_>>().join("\n");
assert!(all.contains("Frag title"));
assert!(!all.contains("# Frag title"), "the heading marker is concealed");
assert!(all.contains("bold") && !all.contains("**bold**"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn embed_preview_is_a_toggle() {
let (d, mut app) = composition("top\n![[frag]]\n", "alpha\nbeta\ngamma\n");
let rows_on = {
let mut c = app.cache.borrow_mut();
c.sync(&app, 40);
c.total_rows()
};
app.embed_mode = crate::transclude::Mode::Off;
let rows_off = {
let mut c = app.cache.borrow_mut();
c.sync(&app, 40);
c.total_rows()
};
assert!(rows_on > rows_off, "expanding adds rows: {rows_on} vs {rows_off}");
let all: String = (0..24)
.map(|y| row_text(&render_to(&app, 80, 24), y))
.collect::<Vec<_>>()
.join("\n");
assert!(!all.contains("alpha"), "the target is not drawn with preview off");
assert!(all.contains("frag"));
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn the_diff_view_draws_both_sides_with_fillers() {
let mut app = app_with("keep\nmine\ntail\n");
app.diff = Some(crate::diff::DiffView::new(
0,
"notes.md".into(),
vec!["keep".into(), "mine".into(), "tail".into()],
vec!["keep".into(), "theirs".into(), "extra".into(), "tail".into()],
None,
));
let buf = render_to(&app, 80, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&buf, y)).collect();
let screen = rows.join("\n");
assert!(screen.contains("diff: notes.md"), "titled:\n{screen}");
assert!(screen.contains("live") && screen.contains("file — on disk"), "{screen}");
let changed = rows
.iter()
.find(|r| r.contains("mine") && r.contains("theirs"))
.expect("the changed line shows both sides:\n{screen}");
assert!(
changed.find("mine") < changed.find("theirs"),
"buffer on the left: {changed:?}"
);
let added = rows
.iter()
.find(|r| r.contains("extra"))
.expect("the added line is on screen");
assert!(
added.contains('\u{2504}'),
"the buffer side is filled, not blank: {added:?}"
);
assert!(!screen.contains("words"), "no status line behind it:\n{screen}");
}
#[test]
fn the_diff_view_shows_which_side_wins() {
use crate::diff::Side;
use ratatui::style::Modifier;
let mut app = app_with("x\n");
app.diff = Some(crate::diff::DiffView::new(
0,
"notes.md".into(),
vec!["keep".into(), "mine".into(), "tail".into()],
vec!["keep".into(), "theirs".into(), "tail".into()],
None,
));
let probe = |app: &App| -> (String, bool, bool) {
let buf = render_to(app, 60, 12);
let y = (0..12)
.find(|y| row_text(&buf, *y).contains("mine"))
.expect("the changed row is on screen");
let row = row_text(&buf, y);
let struck = |needle: &str| -> bool {
let at = row.find(needle).expect("column text present") as u16;
buf[(at, y)].modifier.contains(Modifier::CROSSED_OUT)
};
let bar = row
.chars()
.find(|c| matches!(c, '\u{25c0}' | '\u{25b6}' | '\u{25c6}'))
.map(String::from)
.unwrap_or_default();
(bar, struck("mine"), struck("theirs"))
};
app.diff.as_mut().unwrap().set_all(Side::Live);
let (bar, mine_out, theirs_out) = probe(&app);
assert_eq!(bar, "\u{25c0}", "the glyph points left, at live");
assert!(!mine_out, "the surviving side reads normally");
assert!(theirs_out, "and the losing side is struck out");
app.diff.as_mut().unwrap().set_all(Side::File);
let (bar, mine_out, theirs_out) = probe(&app);
assert_eq!(bar, "\u{25b6}", "and right, at file");
assert!(mine_out);
assert!(!theirs_out);
app.diff.as_mut().unwrap().set_all(Side::Both);
let (bar, mine_out, theirs_out) = probe(&app);
assert_eq!(bar, "\u{25c6}");
assert!(!mine_out && !theirs_out, "both survive, so neither is struck");
}
#[test]
fn a_conflicted_file_is_marked_on_the_status_line() {
let mut app = app_with("hi\n");
app.buffer.path = Some(std::path::PathBuf::from("notes.md"));
let default_content = || crate::app::Flash {
text: None,
kind: crate::app::FlashKind::Info,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
};
app.flash = Some(default_content());
let clean = status_text(&app).unwrap_or_default();
assert!(clean.contains("notes.md"), "got: {clean:?}");
assert!(
!clean.contains(&app.config.glyphs.conflict),
"nothing to warn about yet: {clean:?}"
);
app.buffer.conflict = true;
let marked = status_text(&app).unwrap_or_default();
assert!(
marked.contains(&format!("notes.md {}", app.config.glyphs.conflict)),
"the marker sits with the name: {marked:?}"
);
app.config.glyphs.conflict = String::new();
let off = status_text(&app).unwrap_or_default();
assert!(off.contains("notes.md"), "{off:?}");
assert_eq!(off.trim_end(), off, "and leaves no dangling space");
}
#[test]
fn an_overlong_status_says_it_was_cut() {
let mut app = app_with("hi\n");
app.flash = Some(crate::app::Flash {
text: Some("x".repeat(400)),
kind: crate::app::FlashKind::Error,
expires_at: std::time::Instant::now() + std::time::Duration::from_secs(60),
});
let buf = render_to(&app, 60, 10);
let rows: Vec<String> = (0..10).map(|y| row_text(&buf, y)).collect();
let status = rows
.iter()
.find(|r| r.contains('x'))
.expect("the message is on screen");
assert!(status.contains('\u{2026}'), "elided: {status:?}");
let lay = Layout::compute(&app.config.layout, 60, 10 - STATUS_ROWS, 0);
assert!(
display_width(status.trim()) as usize <= lay.measure as usize,
"and inside the measure: {status:?}"
);
}
#[test]
fn the_embed_box_closes_on_every_row() {
let (d, app) = composition(
"top\n![[frag]]\n",
"# Frag\n\nshort\n\na much longer line of prose that has to wrap at least once\n",
);
let buf = render_to(&app, 80, 24);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let right = lay.margin_left + lay.measure - 1;
let left = lay.margin_left;
let mut corners = (0, 0);
let mut bars = 0;
for y in 0..24 {
let l = buf[(left, y)].symbol().to_string();
let r = buf[(right, y)].symbol().to_string();
match l.as_str() {
"\u{256d}" => {
corners.0 += 1;
assert_eq!(r, "\u{256e}", "the top rule must close");
}
"\u{2570}" => {
corners.1 += 1;
assert_eq!(r, "\u{256f}", "the bottom rule must close");
}
"\u{2502}" => {
bars += 1;
assert_eq!(r, "\u{2502}", "every body row must reach the right bar");
}
_ => {}
}
}
assert_eq!(corners, (1, 1), "one box");
assert!(bars >= 4, "and its body rows, got {bars}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_long_label_is_elided_not_overflowed() {
let (d, app) = composition(
"top\n![[a-name-far-too-long-to-fit-inside-a-narrow-measure-at-all]]\n",
"body\n",
);
std::fs::copy(
d.join("frag.md"),
d.join("a-name-far-too-long-to-fit-inside-a-narrow-measure-at-all.md"),
)
.unwrap();
let buf = render_to(&app, 50, 20);
let lay = Layout::compute(&app.config.layout, 50, 20 - STATUS_ROWS, 0);
let right = lay.margin_left + lay.measure - 1;
let top = (0..20)
.find(|y| buf[(lay.margin_left, *y)].symbol() == "\u{256d}")
.expect("a box");
assert_eq!(buf[(right, top)].symbol(), "\u{256e}", "the corner survives");
let text: String = (0..50).map(|x| buf[(x, top)].symbol()).collect();
assert!(text.contains('\u{2026}'), "the label is elided: {text:?}");
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn clicking_an_embed_selects_its_source_line() {
let (d, app) = composition("top\n![[frag]]\n", "aaa\nbbb\n");
let area = Rect { x: 0, y: 0, width: 80, height: 24 };
let _ = render_to(&app, 80, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&render_to(&app, 80, 24), y)).collect();
let y = rows.iter().position(|r| r.contains("bbb")).unwrap() as u16;
let got = locate_click(&app, area, 40, y).expect("a position");
assert_eq!(got.line, 1, "the `![[frag]]` line");
assert_eq!(got.col, 0);
std::fs::remove_dir_all(&d).ok();
}
#[test]
fn a_wrapped_list_item_hangs_under_its_content() {
let long = "- ".to_string() + &"word ".repeat(30);
let mut app = app_with(&(long + "\n"));
app.config.layout.measure = 30;
let buf = render_to(&app, 60, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&buf, y)).collect();
let first = rows.iter().position(|r| r.contains("word")).unwrap();
let indent = |s: &str| s.len() - s.trim_start().len();
let content = rows[first].find("word").unwrap();
assert_eq!(indent(&rows[first + 1]), content, "continuation hangs");
assert!(
rows[first + 1].trim_start().starts_with("word"),
"…and it is the item's own text"
);
}
#[test]
fn a_wrapped_paragraph_does_not_hang() {
let mut app = app_with(&("word ".repeat(30) + "\n"));
app.config.layout.measure = 30;
let buf = render_to(&app, 60, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&buf, y)).collect();
let first = rows.iter().position(|r| r.contains("word")).unwrap();
let indent = |s: &str| s.len() - s.trim_start().len();
assert_eq!(indent(&rows[first]), indent(&rows[first + 1]));
}
#[test]
fn hanging_indent_honors_its_setting() {
let long = "- ".to_string() + &"word ".repeat(30);
let mut app = app_with(&(long + "\n"));
app.config.layout.measure = 30;
app.config.layout.hanging_indent = false;
let buf = render_to(&app, 60, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&buf, y)).collect();
let first = rows.iter().position(|r| r.contains("word")).unwrap();
let indent = |s: &str| s.len() - s.trim_start().len();
assert_eq!(indent(&rows[first]), indent(&rows[first + 1]), "flush again");
}
#[test]
fn fenced_code_gets_a_slab_and_a_gutter_bar() {
let app = app_with("intro\n\n```rust\nfn x() {}\n```\n\nafter\n");
let buf = render_to(&app, 80, 24);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let bar_x = lay.margin_left - 1;
let bar = app.theme.fence_bar.to_ratatui();
let rows: Vec<u16> = (0..buf.area().height)
.filter(|y| buf[(bar_x, *y)].symbol() == "\u{258a}" && buf[(bar_x, *y)].fg == bar)
.collect();
assert_eq!(rows.len(), 3, "a bar on the fence open, body and close rows");
let body = rows[1];
let code_bg = app.theme.code_bg.to_ratatui();
let last = lay.margin_left + lay.measure - 1;
assert_eq!(buf[(last, body)].bg, code_bg, "slab spans the whole measure");
assert_ne!(
buf[(bar_x, body)].bg,
code_bg,
"the bar hangs in the margin, outside the slab"
);
assert_ne!(buf[(last, rows[0] - 1)].bg, code_bg);
assert_ne!(buf[(bar_x, rows[2] + 1)].symbol(), "\u{258a}");
}
#[test]
fn an_empty_fence_bar_glyph_draws_nothing() {
let mut app = app_with("```\ncode\n```\n");
app.config.glyphs.fence_bar = String::new();
let buf = render_to(&app, 80, 24);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let bar_x = lay.margin_left - 1;
let painted = (0..buf.area().height)
.any(|y| buf[(bar_x, y)].fg == app.theme.fence_bar.to_ratatui());
assert!(!painted, "no bar should be drawn");
}
fn hint_rows(buf: &ratatui::buffer::Buffer) -> Vec<u16> {
let x = buf.area().width - 1;
(0..buf.area().height)
.filter(|y| buf[(x, *y)].symbol() == "\u{2590}")
.collect()
}
#[test]
fn scroll_hint_is_absent_when_the_document_fits() {
let app = app_with("one\ntwo\nthree\n");
let buf = render_to(&app, 80, 24);
assert!(hint_rows(&buf).is_empty(), "short document should show no hint");
}
#[test]
fn scroll_hint_tracks_the_scroll_position() {
let text: String = (0..200).map(|i| format!("line {i}\n")).collect();
let mut app = app_with(&text);
let buf = render_to(&app, 80, 24);
let top_rows = hint_rows(&buf);
assert!(!top_rows.is_empty(), "long document should show a hint");
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
assert_eq!(top_rows[0], lay.top, "thumb starts at the first text row");
app.buffer.cursor = Cursor::new(199, 0);
let buf = render_to(&app, 80, 24);
let bottom_rows = hint_rows(&buf);
assert!(!bottom_rows.is_empty());
assert_eq!(
*bottom_rows.last().unwrap(),
lay.top + lay.height - 1,
"thumb ends flush with the last text row"
);
assert!(bottom_rows[0] > top_rows[0], "thumb moved down");
assert_eq!(bottom_rows.len(), top_rows.len(), "thumb keeps its length");
}
#[test]
fn scroll_does_not_move_on_a_step_that_stays_inside_the_padded_view() {
let text: String = (0..200).map(|i| format!("line {i}\n")).collect();
let mut app = app_with(&text);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let height = lay.height as usize;
let pad = (app.config.editor.scroll_off as usize).min(height.saturating_sub(1) / 2);
let settle_row = height + pad + 3;
for line in 0..=settle_row {
app.buffer.cursor = Cursor::new(line, 0);
render_to(&app, 80, 24);
}
let top_before = app.scroll_hint();
assert!(top_before > 0, "should have scrolled by now");
app.buffer.cursor = Cursor::new(settle_row - 1, 0);
render_to(&app, 80, 24);
assert_eq!(
app.scroll_hint(),
top_before,
"scroll jumped on a move that stayed on screen"
);
}
#[test]
fn scroll_hint_honors_its_setting() {
let text: String = (0..200).map(|i| format!("line {i}\n")).collect();
let mut app = app_with(&text);
app.config.layout.scroll_hint = false;
let buf = render_to(&app, 80, 24);
assert!(hint_rows(&buf).is_empty(), "disabled hint should not paint");
}
#[test]
fn numbers_off_draws_no_gutter() {
let app = app_with("hello\n");
assert_eq!(app.numbers, NumberMode::Off);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
assert_eq!(
rows[lay.top as usize].find("hello"),
Some(lay.margin_left as usize)
);
}
#[test]
fn absolute_numbers_the_source_line_one_indexed() {
let mut app = app_with("one\ntwo\nthree\n");
app.numbers = NumberMode::Absolute;
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
let top = lay.top as usize;
assert!(rows[top].contains("1 one"), "{:?}", rows[top]);
assert!(rows[top + 1].contains("2 two"), "{:?}", rows[top + 1]);
assert!(rows[top + 2].contains("3 three"), "{:?}", rows[top + 2]);
}
#[test]
fn relative_numbers_read_zero_at_the_cursor_and_count_outward() {
let mut app = app_with("a\nb\nc\nd\ne\n");
app.numbers = NumberMode::Relative;
app.buffer.cursor = Cursor::new(2, 0); let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
let top = lay.top as usize;
assert!(rows[top].contains("2 a"), "{:?}", rows[top]);
assert!(rows[top + 1].contains("1 b"), "{:?}", rows[top + 1]);
assert!(rows[top + 2].contains("0 c"), "{:?}", rows[top + 2]);
assert!(rows[top + 3].contains("1 d"), "{:?}", rows[top + 3]);
assert!(rows[top + 4].contains("2 e"), "{:?}", rows[top + 4]);
}
#[test]
fn a_wrapped_continuation_row_shows_no_number() {
let mut app = app_with(&format!("{}\n", "a".repeat(30)));
app.config.layout.measure = 10;
app.numbers = NumberMode::Absolute;
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
let top = lay.top as usize;
assert!(rows[top].trim_start().starts_with('1'), "{:?}", rows[top]);
assert!(
!rows[top + 1].trim_start().starts_with(|c: char| c.is_ascii_digit()),
"continuation row should carry no number: {:?}",
rows[top + 1]
);
}
#[test]
fn numbers_with_no_room_in_the_margin_draw_nothing() {
let text: String = (0..15).map(|i| format!("line {i}\n")).collect();
let mut app = app_with(&text);
app.config.layout.align = "left".into();
app.numbers = NumberMode::Absolute;
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
assert_eq!(
rows[lay.top as usize].find("line 0"),
Some(lay.margin_left as usize),
"text sits exactly at the margin, nothing squeezed in ahead of it"
);
}
#[test]
fn a_revealed_heading_and_its_number_shift_together_without_overlapping() {
let mut app = app_with("### Title\nmore\n");
app.numbers = NumberMode::Absolute;
app.buffer.cursor = Cursor::new(0, 0); let lay = Layout::compute(&app.config.layout, 120, 24 - STATUS_ROWS, 0);
let gutter = number_gutter_width(NumberMode::Absolute, app.buffer.line_count(), lay.margin_left);
assert!(gutter > 0, "test needs the number gutter to actually be reserved");
let buf = render_to(&app, 120, 24);
let row = &rows(&buf)[lay.top as usize];
let heading_at = row.find("### Title").expect("the heading is drawn whole, not clipped");
let digit_at = row
.find(|c: char| c.is_ascii_digit())
.expect("there was room for the number too, not just the heading");
assert!(digit_at < heading_at, "number and markers overlapped: {row:?}");
}
#[test]
fn a_crowded_margin_drops_the_number_rather_than_overlap_the_heading() {
let mut app = app_with("### Title\nmore\n");
app.numbers = NumberMode::Absolute;
app.buffer.cursor = Cursor::new(0, 0);
let lay = Layout::compute(&app.config.layout, 80, 24 - STATUS_ROWS, 0);
let buf = render_to(&app, 80, 24);
let rows = rows(&buf);
let top = lay.top as usize;
assert!(rows[top].contains("### Title"), "the heading is whole: {:?}", rows[top]);
assert!(rows[top + 1].trim_start().starts_with('2'), "the next row keeps its own number: {:?}", rows[top + 1]);
}
#[test]
fn concealment_reveals_only_the_active_line() {
let mut app = app_with("# One\n## Two\n");
let buf = render_to(&app, 60, 8);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("# One"), "active line 0 should be raw");
assert!(joined.contains("Two"), "line 1 should be present");
assert!(!joined.contains("## Two"), "inactive line 1 should hide its ##");
app.buffer.cursor = Cursor::new(1, 0);
app.active = crate::render::conceal::ActiveSet::compute(&app.mode, &app.buffer.cursor, None);
let buf = render_to(&app, 60, 8);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("## Two"), "active line 1 should be raw");
assert!(!joined.contains("# One"), "inactive line 0 should hide its #");
assert!(joined.contains("One"), "line 0 text should remain");
}
#[test]
fn indent_guides_show_on_inactive_lines_only() {
let mut app = app_with("- top\n - nested\n");
let buf = render_to(&app, 60, 12);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("│ ◦ nested"), "guide on the inactive line: {joined:?}");
let (x, y) = (0..buf.area().height)
.find_map(|y| {
(0..buf.area().width).find(|&x| buf[(x, y)].symbol() == "│").map(|x| (x, y))
})
.expect("a guide glyph on screen");
assert_eq!(buf[(x, y)].fg, app.theme.indent_colors[0].to_ratatui());
app.buffer.cursor = Cursor::new(1, 0);
app.active = crate::render::conceal::ActiveSet::compute(&app.mode, &app.buffer.cursor, None);
let buf = render_to(&app, 60, 12);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains(" - nested"), "active line is its source");
assert!(!joined.contains("│"), "…so no guide glyph anywhere: {joined:?}");
}
#[test]
fn focus_mode_dims_other_paragraphs() {
use crate::render::focus::{FocusMode, FocusRegion};
let mut app = app_with("# Title\n\nbody words\n");
app.focus = FocusMode::Paragraph; app.focus_region = FocusRegion::compute(&app.buffer, app.focus);
let buf = render_to(&app, 60, 8);
let b = buf
.content()
.iter()
.find(|c| c.symbol() == "b")
.expect("'body words' should be on screen");
assert!(
b.modifier.contains(ratatui::style::Modifier::DIM),
"out-of-focus text should be faint"
);
assert_eq!(b.fg, app.theme.text.to_ratatui(), "…but keep its syntax color");
}
#[test]
fn command_mode_dims_the_buffer() {
let mut app = app_with("hello there\n\nmore text\n");
app.mode = Mode::Command(String::new());
let buf = render_to(&app, 60, 20); let h = buf
.content()
.iter()
.find(|c| c.symbol() == "h")
.expect("body text on screen");
assert!(
h.modifier.contains(ratatui::style::Modifier::DIM),
"buffer text should be dimmed behind the command box"
);
}
#[test]
fn tree_rows_draw_guides_and_icons() {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("shoin-frame-tree-{t}"));
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("sub").join("deep.md"), "").unwrap();
std::fs::write(dir.join("zeta.md"), "").unwrap();
let mut app = app_with("hi\n");
let mut tree = crate::tree::FileTree::open(dir.clone());
tree.selected = tree.entries.iter().position(|e| e.name == "sub").unwrap();
tree.activate(); app.tree = Some(tree);
let buf = render_to(&app, 80, 10);
let rows: Vec<String> = (0..4).map(|y| row_text(&buf, y)).collect();
assert!(rows[1].starts_with("├╴"), "sub is not the last sibling: {:?}", rows[1]);
assert!(rows[2].starts_with("│ └╴"), "deep.md hangs under sub: {:?}", rows[2]);
assert!(rows[3].starts_with("└╴"), "zeta.md closes the tree: {:?}", rows[3]);
let icon_x = rows[3].chars().position(|c| c == '\u{f48a}').expect("markdown icon");
assert_eq!(
buf[(icon_x as u16, 3)].fg,
app.theme.headings[0].to_ratatui(),
"a .md file gets the prose-colored icon"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn tree_without_nerd_fonts_uses_arrows() {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("shoin-frame-ascii-{t}"));
std::fs::create_dir_all(dir.join("sub")).unwrap();
std::fs::write(dir.join("zeta.md"), "").unwrap();
let mut app = app_with("hi\n");
app.config.glyphs.nerd_fonts = false;
app.tree = Some(crate::tree::FileTree::open(dir.clone()));
let buf = render_to(&app, 80, 10);
let joined: String = (0..4).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("▸ sub"), "a collapsed dir gets an arrow: {joined:?}");
assert!(joined.contains("zeta.md"));
assert!(
!joined.chars().any(|c| ('\u{e000}'..='\u{f8ff}').contains(&c)),
"no Private Use Area glyphs: {joined:?}"
);
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn finder_overlay_lists_matches_and_highlights_them() {
let t = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("shoin-frame-finder-{t}"));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("alpha.md"), "").unwrap();
std::fs::write(dir.join("beta.md"), "").unwrap();
let mut app = app_with("hello there\n");
let mut finder = crate::finder::Finder::open(dir.clone());
finder.push_char('b');
app.finder = Some(finder);
let buf = render_to(&app, 60, 20);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("find"), "the box is titled");
assert!(joined.contains("> b"), "the query is echoed");
assert!(joined.contains("1/2"), "matched / total files");
assert!(joined.contains("beta.md"), "the match is listed");
assert!(!joined.contains("alpha.md"), "the non-match is filtered out");
let row = (0..buf.area().height)
.find(|&y| row_text(&buf, y).contains("beta.md"))
.expect("result row");
let icon_x = row_text(&buf, row)
.chars()
.position(|c| c == '\u{f48a}')
.expect("markdown icon on the result row");
assert_eq!(
buf[(icon_x as u16, row)].fg,
app.theme.headings[0].to_ratatui(),
"colored by type, like the tree"
);
let accent = app.theme.link.to_ratatui();
let x = row_text(&buf, row).chars().position(|c| c == 'b').unwrap() as u16;
assert_eq!(buf[(x, row)].fg, accent, "matched char is accented");
assert_ne!(buf[(x + 1, row)].fg, accent, "the rest of the path is not");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn a_split_renders_two_panes_side_by_side() {
let mut app = app_with("# Title\n\nbody text here\n");
app.split_pane_for_test(true);
let buf = render_to(&app, 100, 24);
let rows: Vec<String> = (0..24).map(|y| row_text(&buf, y)).collect();
let with_body: Vec<&String> = rows.iter().filter(|r| r.contains("body text here")).collect();
assert_eq!(with_body.len(), 1, "one row, holding the text twice");
let line = with_body[0];
assert_eq!(line.matches("body text here").count(), 2, "once per pane: {line:?}");
let geo = app.layout.geometry(pane_area(&app, Rect { x: 0, y: 0, width: 100, height: 24 }));
assert_eq!(geo.dividers.len(), 1);
let x = geo.dividers[0].x;
assert_eq!(buf[(x, 5)].bg, app.theme.text_dim.to_ratatui());
let heading_row = rows.iter().find(|r| r.contains("Title")).expect("the heading");
assert_eq!(heading_row.matches("# Title").count(), 2, "{heading_row:?}");
}
#[test]
fn click_maps_to_the_clicked_line() {
let app = app_with("# One\n\nsecond paragraph here\n");
let area = Rect { x: 0, y: 0, width: 60, height: 12 };
assert_eq!(locate_click(&app, area, 8, 2).map(|c| c.line), Some(0));
assert_eq!(locate_click(&app, area, 8, 4).map(|c| c.line), Some(2));
assert!(locate_click(&app, area, 8, 0).is_none());
}
#[test]
fn a_drag_clamps_to_the_text_area() {
let app = app_with("# One\n\nsecond paragraph here\n");
let area = Rect { x: 0, y: 0, width: 60, height: 12 };
assert_eq!(locate_drag(&app, area, 8, 0).map(|c| c.line), Some(0));
let end = locate_drag(&app, area, 59, 11).expect("a position");
let last = app.buffer.rope.len_lines() - 1;
assert_eq!(end.line, last, "the last line");
assert_eq!(locate_drag(&app, area, 0, 2).map(|c| c.col), Some(0));
}
#[test]
fn theme_background_is_painted() {
let app = app_with("hello world\n");
let buf = render_to(&app, 40, 6);
let bg = app.theme.background.to_ratatui();
assert!(
buf.content().iter().all(|c| c.bg == bg),
"every cell should carry the theme background"
);
}
#[test]
fn a_stable_gutter_keeps_revealed_text_in_place() {
let mut app = app_with("# Title\n\nbody\n");
let column_of = |buf: &ratatui::buffer::Buffer| -> Option<u16> {
(0..buf.area().height)
.flat_map(|y| (0..buf.area().width).map(move |x| (x, y)))
.find(|(x, y)| buf[(*x, *y)].symbol() == "T")
.map(|(x, _)| x)
};
let has_hash = |buf: &ratatui::buffer::Buffer| -> bool {
(0..buf.area().height)
.flat_map(|y| (0..buf.area().width).map(move |x| (x, y)))
.any(|(x, y)| buf[(x, y)].symbol() == "#")
};
app.buffer.cursor = Cursor::new(2, 0);
app.active = crate::render::conceal::ActiveSet { start: 2, end: 2 };
let concealed = render_to(&app, 60, 24);
app.buffer.cursor = Cursor::new(0, 0);
app.active = crate::render::conceal::ActiveSet { start: 0, end: 0 };
let revealed = render_to(&app, 60, 24);
assert_eq!(column_of(&concealed), column_of(&revealed));
assert!(column_of(&concealed).is_some(), "the heading should be drawn");
assert!(
has_hash(&revealed),
"the markers should be on screen, just left of the text"
);
app.config.layout.stable_gutter = false;
let unstable = render_to(&app, 60, 24);
assert_ne!(column_of(&concealed), column_of(&unstable));
}
#[test]
fn a_warm_cache_paints_what_a_cold_one_does() {
let mut app = app_with("# Title\n\nsome **bold** prose here\n\n- one\n- two\n\n> quoted\n");
let _ = render_to(&app, 60, 12);
app.buffer.insert_str(Cursor::new(2, 4), " more");
app.buffer.insert_str(Cursor::new(4, 0), "- inserted\n");
app.refresh_blocks();
app.buffer.cursor = Cursor::new(5, 2);
app.active = crate::render::conceal::ActiveSet { start: 5, end: 5 };
let warm = render_to(&app, 60, 12);
*app.cache.borrow_mut() = RenderCache::default();
let cold = render_to(&app, 60, 12);
assert_eq!(warm, cold);
}
#[test]
fn conceal_disabled_keeps_all_markers() {
let mut app = app_with("# One\n## Two\n");
app.config.layout.conceal = false;
let buf = render_to(&app, 60, 8);
let joined: String = (0..buf.area().height).map(|y| row_text(&buf, y)).collect();
assert!(joined.contains("# One"));
assert!(joined.contains("## Two"), "conceal off: line 1 keeps its ##");
}
#[test]
fn a_wide_table_row_does_not_wrap() {
let md = "| Name | Description | Owner |\n|---|---|---|\n| alpha | a long description that will not fit in a narrow measure | bob |\n";
let app = app_with(md);
let buf = render_to(&app, 40, 12);
let text = rows(&buf);
assert!(
text.iter().any(|r| r.contains("| alpha | a long description")),
"the row's start is on screen, unbroken: {text:?}"
);
assert!(
!text.iter().any(|r| r.trim() == "bob |"),
"the row's tail must not have wrapped onto its own line: {text:?}"
);
}
#[test]
fn left_is_the_default_and_leaves_a_short_row_flush() {
let app = app_with("hi\n");
assert_eq!(app.text_align, TextAlign::Left);
let drawn = rows(&render_to(&app, 80, 8));
let row = drawn.iter().find(|r| r.trim() == "hi").expect("hi is on screen");
let margin_left = (80u16 - app.config.layout.measure) / 2;
let leading = row.chars().take_while(|c| *c == ' ').count() as u16;
assert_eq!(leading, margin_left, "flush against the column's left edge");
}
#[test]
fn center_align_centers_a_short_row_in_the_measure() {
let mut app = app_with("hi\n");
app.config.layout.measure = 20;
app.text_align = TextAlign::Center;
let drawn = rows(&render_to(&app, 80, 8));
let row = drawn.iter().find(|r| r.trim() == "hi").expect("hi is on screen");
let margin_left = (80u16 - 20) / 2;
let leading = row.chars().take_while(|c| *c == ' ').count() as u16;
assert_eq!(leading, margin_left + 9, "(20/2) - (2/2) columns in from the edge");
}
#[test]
fn right_align_pushes_a_short_row_to_the_measures_right_edge() {
let mut app = app_with("hi\n");
app.config.layout.measure = 20;
app.text_align = TextAlign::Right;
let drawn = rows(&render_to(&app, 80, 8));
let row = drawn.iter().find(|r| r.trim() == "hi").expect("hi is on screen");
let margin_left = (80u16 - 20) / 2;
let leading = row.chars().take_while(|c| *c == ' ').count() as u16;
assert_eq!(leading, margin_left + 18, "20 - 2 columns in from the edge");
}
#[test]
fn justified_stretches_every_row_but_the_last() {
let mut app = app_with("aaa bbb ccc ddd\n");
app.config.layout.measure = 10;
app.text_align = TextAlign::Justified;
let drawn = rows(&render_to(&app, 80, 8));
let margin_left = (80u16 - 10) / 2;
let pad = " ".repeat(margin_left as usize);
let first = drawn.iter().find(|r| r.trim_start().starts_with("aaa")).expect("first row");
assert_eq!(first, &format!("{pad}aaa bbb"), "stretched flush to the measure");
let last = drawn.iter().find(|r| r.trim_start().starts_with("ccc")).expect("last row");
assert_eq!(last, &format!("{pad}ccc ddd"), "the last row stays ragged");
}
#[test]
fn justified_leaves_an_unwrapped_row_ragged() {
let mut app = app_with("aaa bbb\n");
app.config.layout.measure = 20;
app.text_align = TextAlign::Justified;
let drawn = rows(&render_to(&app, 80, 8));
let row = drawn.iter().find(|r| r.contains("aaa")).expect("on screen");
assert!(row.trim_end().ends_with("aaa bbb"), "single space, not stretched: {row:?}");
}
#[test]
fn fenced_code_ignores_text_align() {
let mut app = app_with("```\nfn x() {}\n```\n");
app.config.layout.measure = 40;
app.text_align = TextAlign::Justified;
let drawn = rows(&render_to(&app, 80, 8));
let row = drawn.iter().find(|r| r.contains("fn")).expect("code line on screen");
assert!(row.contains("fn x() {}"), "spacing untouched: {row:?}");
}
}