use ratatui::{
Frame,
layout::{Position, Rect},
style::{Modifier, Style},
text::{Line, Span},
widgets::{Clear, Paragraph},
};
use leaf_ratatui::Theme;
use leaf_core::Doc;
use leaf_core::wysiwyg::text_width;
use crate::commands::{Ctx, GROUPS};
use crate::find::{Find, FindField};
use crate::palette::Palette;
use crate::{App, ContextMenu, DirtyAction, MenuEntry, TextPrompt};
#[derive(Clone, Copy)]
struct Chrome {
base: Style,
bold: Style,
dim: Style,
key: Style,
selected: Style,
warn: Style,
}
impl Chrome {
fn new(theme: &Theme) -> Self {
let base = Style::default().bg(theme.panel_bg).fg(theme.panel_fg);
Chrome {
base,
bold: base.add_modifier(Modifier::BOLD),
dim: base.fg(theme.panel_dim),
key: base.fg(theme.panel_accent),
selected: base.bg(theme.panel_selected_bg).fg(theme.panel_selected_fg),
warn: base.fg(theme.panel_warning).add_modifier(Modifier::BOLD),
}
}
}
pub fn render(f: &mut Frame, doc: &mut Doc, app: &mut App) {
let screen = f.area();
let bar_rows = app.find.as_ref().map_or(0, Find::rows).min(screen.height);
let available = Rect {
height: screen.height - bar_rows,
..screen
};
app.editor
.set_line_width((app.line_width > 0).then_some(app.line_width));
let body = available;
leaf_ratatui::render(f, body, doc, &mut app.editor);
app.body_width = doc.body_width;
let chrome = Chrome::new(app.editor.theme());
if let Some(prompt) = &app.dirty_prompt {
let verb = match prompt.action {
DirtyAction::Quit => "quit",
DirtyAction::New => "start a new document",
};
render_choice_overlay(
f,
&format!("Unsaved changes — {verb}?"),
&["Save", "Discard", "Cancel"],
prompt.selected,
&chrome,
);
} else if let Some(prompt) = &app.conflict {
render_choice_overlay(
f,
"File changed on disk since it was opened",
&["Overwrite", "Reload", "Cancel"],
prompt.selected,
&chrome,
);
} else if let Some(msg) = &doc.status {
render_toast(f, body, msg, chrome.warn);
} else if doc.read_only() {
render_toast(f, body, "read-only", chrome.dim);
}
if let Some(find) = &app.find {
render_find_bar(f, screen, find, doc.read_only(), &chrome);
}
if let Some(menu) = &mut app.context_menu {
let ctx = Ctx::read(doc);
render_context_menu(f, f.area(), menu, &ctx, &chrome);
}
if let Some(palette) = &mut app.palette {
let ctx = Ctx::read(doc);
render_palette(f, f.area(), palette, &ctx, &chrome);
}
if let Some(prompt) = &app.text_prompt {
render_text_prompt(f, f.area(), prompt, &chrome);
}
if app.help {
render_help(f, f.area(), doc, &chrome);
}
}
fn render_choice_overlay(
f: &mut Frame,
message: &str,
items: &[&str],
selected: usize,
chrome: &Chrome,
) {
let mut choices = Vec::new();
for (i, label) in items.iter().enumerate() {
if i > 0 {
choices.push(Span::styled(" ", chrome.base));
}
let style = if i == selected {
chrome.selected
} else {
chrome.key
};
let mnemonic = label.chars().next().unwrap_or(' ').to_ascii_lowercase();
choices.push(Span::styled(format!(" {label} ({mnemonic}) "), style));
}
let lines = vec![
Line::from(Span::styled(format!(" {message} "), chrome.warn)),
Line::from(choices),
];
let screen = f.area();
let choices_w: usize = items.iter().map(|l| l.chars().count() + 7).sum::<usize>() + 2;
let width = (message.chars().count() + 2)
.max(choices_w)
.min(screen.width.max(1) as usize) as u16;
let height = 2u16.min(screen.height.max(1));
let rect = centered(screen, width, height);
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).style(chrome.base), rect);
}
fn render_toast(f: &mut Frame, area: Rect, msg: &str, style: Style) {
if area.width == 0 || area.height == 0 {
return;
}
let text = format!(" {msg} ");
let width = (text.chars().count() as u16).min(area.width);
let rect = Rect {
x: area.x + area.width - width,
y: area.y + area.height - 1,
width,
height: 1,
};
f.render_widget(Clear, rect);
f.render_widget(
Paragraph::new(Line::from(Span::styled(text, style))).style(style),
rect,
);
}
fn render_find_bar(f: &mut Frame, screen: Rect, find: &Find, read_only: bool, chrome: &Chrome) {
let rows = find.rows().min(screen.height);
if rows == 0 || screen.width == 0 {
return;
}
let rect = Rect {
y: screen.y + screen.height - rows,
height: rows,
..screen
};
f.render_widget(Clear, rect);
let caption = find.caption();
let caption_w = text_width(&caption) + 2;
let field_w = (rect.width as usize).saturating_sub(FIELD_PREFIX + caption_w);
let mut lines = vec![field_line(
"find",
&find.query,
find.query_cursor,
find.field == FindField::Query,
field_w,
&caption,
chrome,
)];
if let Some(replacement) = &find.replacement {
lines.push(field_line(
"with",
replacement,
find.replacement_cursor,
find.field == FindField::Replacement,
field_w,
"",
chrome,
));
}
let mut hints = vec![
Span::styled(" enter ", chrome.key),
Span::styled("next ", chrome.dim),
Span::styled("↑↓ ", chrome.key),
Span::styled("prev/next ", chrome.dim),
];
if find.replacement.is_some() {
hints.extend([
Span::styled("tab ", chrome.key),
Span::styled("field ", chrome.dim),
Span::styled("^r ", chrome.key),
Span::styled("replace all ", chrome.dim),
]);
} else if !read_only {
hints.extend([
Span::styled("^h ", chrome.key),
Span::styled("replace ", chrome.dim),
]);
}
hints.extend([
Span::styled("esc ", chrome.key),
Span::styled("close ", chrome.dim),
]);
lines.push(Line::from(hints));
f.render_widget(Paragraph::new(lines).style(chrome.base), rect);
let (value, cursor) = find.field();
let row = match find.field {
FindField::Query => rect.y,
FindField::Replacement => rect.y + 1,
};
let (_, cursor_col) = field_window(value, cursor, field_w);
let cursor_x = rect.x.saturating_add((FIELD_PREFIX + cursor_col) as u16);
if row < rect.y + rect.height && cursor_x < rect.x + rect.width {
f.set_cursor_position(Position::new(cursor_x, row));
}
}
const FIELD_PREFIX: usize = FIELD_LABEL_W + 2 + 2;
const FIELD_LABEL_W: usize = "find".len();
#[allow(clippy::too_many_arguments)]
fn field_line(
label: &str,
value: &str,
cursor: usize,
focused: bool,
field_w: usize,
caption: &str,
chrome: &Chrome,
) -> Line<'static> {
let ink = if focused { chrome.bold } else { chrome.dim };
let (window, _) = field_window(value, cursor, field_w);
Line::from(vec![
Span::styled(format!(" {label:<FIELD_LABEL_W$} "), chrome.key),
Span::styled("› ", if focused { chrome.key } else { chrome.dim }),
Span::styled(window, ink),
Span::styled(format!(" {caption} "), chrome.dim),
])
}
fn field_window(value: &str, cursor: usize, width: usize) -> (String, usize) {
let cursor_col = text_width(&value[..cursor.min(value.len())]);
let start = cursor_col.saturating_sub(width.saturating_sub(1));
let end = start + width;
let mut out = String::new();
let mut drawn = 0usize;
let mut col = 0usize;
for ch in value.chars() {
let w = text_width(ch.encode_utf8(&mut [0u8; 4]));
if col >= start && col + w <= end {
while drawn < col - start {
out.push(' ');
drawn += 1;
}
out.push(ch);
drawn += w;
}
col += w;
}
while drawn < width {
out.push(' ');
drawn += 1;
}
(out, cursor_col.saturating_sub(start))
}
fn centered(screen: Rect, width: u16, height: u16) -> Rect {
Rect {
x: screen.x + (screen.width.saturating_sub(width)) / 2,
y: screen.y + (screen.height.saturating_sub(height)) / 2,
width,
height,
}
}
fn render_context_menu(
f: &mut Frame,
screen: Rect,
menu: &mut ContextMenu,
ctx: &Ctx,
chrome: &Chrome,
) {
let mut parent: Option<(Rect, usize)> = None;
for i in 0..menu.levels.len() {
let items = menu.levels[i].items;
let selected = menu.levels[i].selected;
let (label_w, hint_w) = menu_columns(items);
let width = menu_level_width(items);
let height = items.len() as u16;
let (x, y) = match parent {
None => {
let (ax, ay) = menu.anchor;
(
ax.min(screen.width.saturating_sub(width)),
ay.min(screen.height.saturating_sub(height)),
)
}
Some((prect, prow)) => {
let x = if prect.x + prect.width + width <= screen.width {
prect.x + prect.width
} else {
prect.x.saturating_sub(width)
};
let y = (prect.y + prow as u16).min(screen.height.saturating_sub(height));
(x, y)
}
};
let rect = Rect {
x,
y,
width,
height,
};
menu.levels[i].rect = Some(rect);
let lines: Vec<Line<'static>> = items
.iter()
.enumerate()
.map(|(r, entry)| menu_row(*entry, r == selected, ctx, label_w, hint_w, chrome))
.collect();
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).style(chrome.base), rect);
parent = Some((rect, selected));
}
}
fn menu_columns(items: &[MenuEntry]) -> (usize, usize) {
let label = items
.iter()
.map(|e| e.label().chars().count())
.max()
.unwrap_or(0);
let hint = items
.iter()
.map(|e| e.hint().chars().count())
.max()
.unwrap_or(0);
(label, hint)
}
fn menu_level_width(items: &[MenuEntry]) -> u16 {
let (label, hint) = menu_columns(items);
let keys = if hint > 0 { hint + 2 } else { 0 };
(label + keys + 6) as u16
}
fn menu_row(
entry: MenuEntry,
selected: bool,
ctx: &Ctx,
label_w: usize,
hint_w: usize,
chrome: &Chrome,
) -> Line<'static> {
let keys = |hint: &str| -> String {
if hint_w == 0 {
String::new()
} else {
format!(" {hint:>hint_w$}")
}
};
match entry {
MenuEntry::Header(label) => {
let w = label_w + hint_w + if hint_w > 0 { 2 } else { 0 } + 4;
Line::from(Span::styled(format!(" {label:<w$} "), chrome.dim))
}
MenuEntry::Action(cmd) => {
let enabled = cmd.enabled(ctx);
let active = enabled && cmd.active(ctx);
let check = if active { '✓' } else { ' ' };
let style = if !enabled {
chrome.dim
} else if selected {
chrome.selected
} else if active {
chrome.key
} else {
chrome.base
};
Line::from(Span::styled(
format!(
" {check} {label:<label_w$}{k} ",
label = cmd.label(),
k = keys(cmd.hint())
),
style,
))
}
MenuEntry::Submenu(label, items) => {
let enabled = items.iter().any(|e| match e {
MenuEntry::Action(c) => c.enabled(ctx),
_ => false,
});
let style = if !enabled {
chrome.dim
} else if selected {
chrome.selected
} else {
chrome.base
};
Line::from(Span::styled(
format!(" {label:<label_w$}{k} ▸ ", k = keys("")),
style,
))
}
}
}
fn render_palette(f: &mut Frame, screen: Rect, palette: &mut Palette, ctx: &Ctx, chrome: &Chrome) {
let width = 52u16.min(screen.width.max(1));
let room = (screen.height.saturating_sub(6)).clamp(1, 14);
let list_h = (palette.rows.len() as u16).clamp(1, room);
let height = (list_h + 2).min(screen.height.max(1));
let rect = centered(screen, width, height);
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(Vec::<Line>::new()).style(chrome.base), rect);
let query = Rect { height: 1, ..rect };
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" › ", chrome.key),
Span::styled(palette.query.clone(), chrome.bold),
]))
.style(chrome.base),
query,
);
let list_h = rect.height.saturating_sub(2) as usize;
let first = palette.selected.saturating_sub(list_h.saturating_sub(1));
let list_rect = Rect {
y: rect.y + 1,
height: rect.height.saturating_sub(2),
..rect
};
let lines: Vec<Line<'static>> = palette
.rows
.iter()
.skip(first)
.take(list_h)
.enumerate()
.map(|(i, row)| {
let selected = first + i == palette.selected;
let active = row.enabled && row.command.active(ctx);
let label_w = (width as usize).saturating_sub(14);
let style = if !row.enabled {
chrome.dim
} else if selected {
chrome.selected
} else if active {
chrome.key
} else {
chrome.base
};
Line::from(Span::styled(
format!(
" {check} {label:<label_w$} {hint:>8} ",
check = if active { '✓' } else { ' ' },
label = truncate(row.command.label(), label_w),
hint = row.command.hint()
),
style,
))
})
.collect();
f.render_widget(Paragraph::new(lines).style(chrome.base), list_rect);
palette.list_rect = Some(list_rect);
palette.scrolled_by = first;
let hint = Rect {
y: rect.y + rect.height - 1,
height: 1,
..rect
};
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(" ↑↓ ", chrome.key),
Span::styled("choose ", chrome.dim),
Span::styled("enter ", chrome.key),
Span::styled("run ", chrome.dim),
Span::styled("esc ", chrome.key),
Span::styled("close ", chrome.dim),
]))
.style(chrome.base),
hint,
);
let cursor_x = rect.x + 3 + palette.query[..palette.cursor].chars().count() as u16;
if cursor_x < rect.x + rect.width {
f.set_cursor_position(Position::new(cursor_x, rect.y));
}
}
fn render_help(f: &mut Frame, screen: Rect, doc: &mut Doc, chrome: &Chrome) {
let ctx = Ctx::read(doc);
let mut rows: Vec<HelpRow> = Vec::new();
for (group, commands) in GROUPS {
let keyed: Vec<_> = commands.iter().filter(|c| !c.hint().is_empty()).collect();
if keyed.is_empty() {
continue;
}
if !rows.is_empty() {
rows.push(HelpRow::Blank);
}
rows.push(HelpRow::Group(group));
for cmd in keyed {
rows.push(HelpRow::Key(cmd.hint(), cmd.label(), cmd.enabled(&ctx)));
}
}
rows.push(HelpRow::Blank);
rows.push(HelpRow::Key("⌥p", "the command palette", true));
const COLUMN: usize = 32;
let by_width = (screen.width as usize / COLUMN).clamp(1, 3);
let fits = |n: usize| rows.len().div_ceil(n) < screen.height as usize;
let columns = (1..=by_width).find(|n| fits(*n)).unwrap_or(by_width);
let per_column = rows.len().div_ceil(columns);
let mut lines: Vec<Line<'static>> = Vec::new();
for i in 0..per_column {
let mut spans = Vec::new();
for c in 0..columns {
spans.extend(help_spans(rows.get(i + c * per_column), COLUMN, chrome));
}
lines.push(Line::from(spans));
}
lines.push(Line::from(Span::styled(
format!(
" {} · {} · markup {} · line flow {} · any key closes",
doc.format_name(),
doc.view_name(),
leaf_ratatui::markup_mode_name(doc.markup_mode()),
leaf_ratatui::line_flow_name(doc.line_flow()),
),
chrome.dim,
)));
let width = ((COLUMN * columns) as u16).min(screen.width.max(1));
let height = (lines.len() as u16).min(screen.height.max(1));
let rect = centered(screen, width, height);
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).style(chrome.base), rect);
}
#[derive(Clone, Copy)]
enum HelpRow {
Group(&'static str),
Key(&'static str, &'static str, bool),
Blank,
}
fn help_spans(row: Option<&HelpRow>, width: usize, chrome: &Chrome) -> Vec<Span<'static>> {
match row {
None | Some(HelpRow::Blank) => vec![Span::styled(" ".repeat(width), chrome.base)],
Some(HelpRow::Group(name)) => {
vec![Span::styled(
format!(" {name:<w$}", w = width - 1),
chrome.bold,
)]
}
Some(HelpRow::Key(hint, label, enabled)) => {
let label_w = width.saturating_sub(9);
let (key, ink) = if *enabled {
(chrome.key, chrome.base)
} else {
(chrome.dim, chrome.dim)
};
vec![
Span::styled(format!(" {hint:>4} "), key),
Span::styled(format!("{:<label_w$} ", truncate(label, label_w)), ink),
]
}
}
}
fn truncate(s: &str, width: usize) -> String {
if s.chars().count() <= width {
return s.to_string();
}
s.chars().take(width.saturating_sub(1)).collect::<String>() + "…"
}
fn render_text_prompt(f: &mut Frame, screen: Rect, prompt: &TextPrompt, chrome: &Chrome) {
let hint = " enter confirm esc cancel ";
let content = [
prompt.label.chars().count(),
prompt.value.chars().count(),
hint.chars().count(),
]
.into_iter()
.max()
.unwrap_or(0) as u16
+ 2;
let width = content.max(24).min(screen.width.max(1));
let height = 3u16.min(screen.height.max(1));
let rect = centered(screen, width, height);
let lines = vec![
Line::from(Span::styled(format!(" {} ", prompt.label), chrome.bold)),
Line::from(Span::styled(format!(" {} ", prompt.value), chrome.base)),
Line::from(vec![
Span::styled(" enter ", chrome.key),
Span::styled("confirm ", chrome.dim),
Span::styled("esc ", chrome.key),
Span::styled("cancel ", chrome.dim),
]),
];
f.render_widget(Clear, rect);
f.render_widget(Paragraph::new(lines).style(chrome.base), rect);
let cursor_x = rect.x + 1 + prompt.value[..prompt.cursor].chars().count() as u16;
if rect.height >= 2 && cursor_x < rect.x + rect.width {
f.set_cursor_position(Position::new(cursor_x, rect.y + 1));
}
}