Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, widgets::{Clear, Paragraph},};
use crate::app::App;use crate::status::StatusMode;
use super::format_thousands;
/// Universal box-drawing separator (no special font required).const SEP_UNIVERSAL: char = '│';/// Powerline/Nerd Font filled right-arrow. Requires a patched font.const SEP_POWERLINE: char = '\u{e0b0}';
// ---------------------------------------------------------------------------// Helpers// ---------------------------------------------------------------------------
/// Build the pill1 (filename + dirty marker) span and return its background/// colour. Shared between the normal bar and the timed-message bar so both/// always show the same pill while only the hints zone changes.fn pill1_parts(app: &App) -> (Span<'static>, Color) { let theme = &app.theme; let (pill_bg, pill_fg): (Color, Color) = if app.read_only { (theme.muted, theme.bg) } else if app.is_dirty { (theme.accent, theme.bg) } else { (theme.text, theme.bg) }; let marker = if app.read_only { " [RO]" } else if app.is_dirty { " [*]" } else { "" }; let path_str = &app.shortened_path; let text = format!(" {path_str}{marker} "); ( Span::styled(text, Style::default().fg(pill_fg).bg(pill_bg)), pill_bg, )}
// ---------------------------------------------------------------------------// Public render entry points// ---------------------------------------------------------------------------
/// Render the bottom status bar into `area`.#[mutants::skip] // Writes into ratatui Buffer — void, not testable via return value.pub fn render_status_bar(f: &mut Frame, area: Rect, app: &App) { let theme = &app.theme; let canvas_bg = theme.bg; let hints_bg = theme.ui_bg; let warning_fg = theme.warning;
let content: Line = match &app.status.mode { StatusMode::ExitPrompt => Line::from(vec![Span::styled( " Save modified buffer? [Y]es [N]o [C]ancel ", Style::default() .fg(warning_fg) .bg(hints_bg) .add_modifier(Modifier::BOLD), )]),
StatusMode::TimedMessage { text, .. } => build_timed_message_bar(app, text),
StatusMode::DismissibleMessage(text) => { let msg = format!(" {text} "); let msg_display_width = msg.chars().count() as u16; let pad = area .width .saturating_sub(msg_display_width) .saturating_div(2); let padded = format!("{:pad$}{msg}{:pad$}", "", "", pad = pad as usize); Line::from(vec![Span::styled( padded, Style::default().fg(warning_fg).bg(hints_bg), )]) }
StatusMode::Normal => build_normal_status_bar(app),
StatusMode::GoToLine { input } => build_goto_line_bar(app, input),
StatusMode::SaveAs { input, .. } => build_save_as_bar(app, input),
StatusMode::SwitchFilePrompt => Line::from(vec![Span::styled( " Unsaved changes will be lost — switch file? [Y]es [N]o ", Style::default() .fg(warning_fg) .bg(hints_bg) .add_modifier(Modifier::BOLD), )]), };
let para = Paragraph::new(content).style(Style::default().bg(canvas_bg)); f.render_widget(para, area);}
// ---------------------------------------------------------------------------// Bar builders (pub(super) for unit-testing)// ---------------------------------------------------------------------------
/// Build the status bar for a timed flash message (e.g. "Saved.").////// Pill1 (filename / dirty state) stays visible on the left. The hints zone/// is replaced by the message text drawn on `canvas_bg` so it visually/// dissolves into the background — only the accent-coloured text remains.pub(super) fn build_timed_message_bar(app: &App, text: &str) -> Line<'static> { let theme = &app.theme; let canvas_bg = theme.bg; let sep = if app.powerline_glyphs { SEP_POWERLINE } else { SEP_UNIVERSAL } .to_string();
let (pill1, pill_bg) = pill1_parts(app); // Separator transitions pill_bg → canvas_bg so the hints zone disappears. let cap1 = Span::styled(sep, Style::default().fg(pill_bg).bg(canvas_bg)); let msg = Span::styled( format!(" {text} "), Style::default() .fg(theme.accent) .bg(canvas_bg) .add_modifier(Modifier::BOLD), );
Line::from(vec![pill1, cap1, msg])}
pub(super) fn build_normal_status_bar(app: &App) -> Line<'static> { let theme = &app.theme; let canvas_bg = theme.bg; let hints_bg = theme.ui_bg; let muted_fg = theme.muted; let sep = if app.powerline_glyphs { SEP_POWERLINE } else { SEP_UNIVERSAL } .to_string();
let (pill1, pill_bg) = pill1_parts(app); let cap1 = Span::styled(sep.clone(), Style::default().fg(pill_bg).bg(hints_bg));
let hints_text = if app.read_only { " ^E Unlock ^Q Quit F1 Keys F2 Settings " } else { " ^S Save ^Q Quit F1 Keys F2 Settings " }; let hints = Span::styled(hints_text, Style::default().fg(muted_fg).bg(hints_bg)); let cap2 = Span::styled(sep, Style::default().fg(hints_bg).bg(canvas_bg));
Line::from(vec![pill1, cap1, hints, cap2])}
/// Build the status bar for the go-to-line prompt.////// Shows `" Go to line: {input}_ "` centred on `hints_bg` so the prompt is/// visually distinct from the normal hints bar but not as alarming as the exit/// prompt. The trailing `_` acts as a simple text cursor indicator.pub(super) fn build_goto_line_bar(app: &App, input: &str) -> Line<'static> { let theme = &app.theme; let hints_bg = theme.ui_bg; let accent_fg = theme.accent;
let content = format!(" Go to line: {input}_ "); Line::from(vec![Span::styled( content, Style::default() .fg(accent_fg) .bg(hints_bg) .add_modifier(Modifier::BOLD), )])}
/// Build the status bar for the save-as prompt (untitled buffer first save).////// Shows `" Save as: {input}_ "` in accent colour on `hints_bg`, matching the/// go-to-line prompt style so the two prompts feel visually consistent.#[mutants::skip] // Only called from #[mutants::skip] render_status_bar; no standalone test path.pub(super) fn build_save_as_bar(app: &App, input: &str) -> Line<'static> { let theme = &app.theme; let hints_bg = theme.ui_bg; let accent_fg = theme.accent;
let content = format!(" Save as: {input}_ "); Line::from(vec![Span::styled( content, Style::default() .fg(accent_fg) .bg(hints_bg) .add_modifier(Modifier::BOLD), )])}
/// Nerd Font typewriter icon (nf-md-typewriter, U+F04C3). Requires a patched font.const TYPEWRITER_GLYPH: &str = "";/// ASCII fallback for the typewriter mode indicator.const TYPEWRITER_ASCII: &str = "[T]";/// Nerd Font eye icon (nf-md-eye, U+F06D2) for focus mode. Requires a patched font.const FOCUS_GLYPH: &str = "";/// ASCII fallback for the focus mode indicator.const FOCUS_ASCII: &str = "[F]";
/// Render the second-to-last row: cursor position, word count, and mode indicators.////// Builds the entire row as a **single** `Line` covering the full `area` width so/// that every cell receives an explicit (char, style) pair in one pass. Using/// multiple narrow `Paragraph` renders (clear + text + indicators) left gaps where/// only the style was set but no character was written, which could expose stale/// buffer content from the previous frame on certain terminals.#[mutants::skip] // Writes into ratatui Buffer — void, not testable via return value.pub fn render_info_line(f: &mut Frame, area: Rect, app: &App) { let theme = &app.theme;
// Hard-clear the row first so no stale cell content can survive from any // prior frame. `Clear` writes Cell::default() (space + default style) to // every cell, giving us a clean slate before our spans land on top. f.render_widget(Clear, area);
let (row, col) = app.textarea.cursor(); let text = format!( " Ln {}, Col {} · {} words ", format_thousands(row + 1), format_thousands(col + 1), format_thousands(app.word_count), );
// Mode indicators — right-aligned, accent coloured. // Each active mode contributes a token; they are concatenated into a single // right-aligned span so they stack naturally. let mut indicators = String::new(); if app.focus_mode { let icon = if app.powerline_glyphs { FOCUS_GLYPH } else { FOCUS_ASCII }; indicators.push_str(&format!(" {icon} ")); } if app.typewriter_mode { let icon = if app.powerline_glyphs { TYPEWRITER_GLYPH } else { TYPEWRITER_ASCII }; indicators.push_str(&format!(" {icon} ")); }
let text_style = Style::default().fg(theme.muted).bg(theme.bg); let ind_style = Style::default().fg(theme.accent).bg(theme.bg); let bg_style = Style::default().bg(theme.bg);
// How many display columns are available for each zone. let text_width = (text.chars().count() as u16).min(area.width); let remaining = area.width.saturating_sub(text_width); let ind_width = (indicators.chars().count() as u16).min(remaining); let gap_width = remaining.saturating_sub(ind_width);
// Char-safe truncation (the widths above are in chars, not bytes). let text_str: String = text.chars().take(text_width as usize).collect(); let ind_str: String = indicators.chars().take(ind_width as usize).collect(); let gap_str = " ".repeat(gap_width as usize);
// Render the entire row in one shot: [word-count][gap][indicators] // Every cell in `area` gets an explicit character + style so the diff // correctly replaces any previous content. let line = Line::from(vec![ Span::styled(text_str, text_style), Span::styled(gap_str, bg_style), Span::styled(ind_str, ind_style), ]); f.render_widget(Paragraph::new(line).style(bg_style), area);}