From 6c507c270b0a886daec65811627d88dd6677277d Mon Sep 17 00:00:00 2001 From: Kian McKenna Date: Wed, 20 May 2026 23:45:00 +0100 Subject: [PATCH] feat: add render debug command and help --- README.md | 22 +++ src/debug_render.rs | 254 ++++++++++++++++++++++++++++ src/main.rs | 401 ++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 664 insertions(+), 13 deletions(-) create mode 100644 src/debug_render.rs diff --git a/README.md b/README.md index f393aee..50a9152 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,28 @@ cargo install --path . glass ``` +Print full command, keybinding, and render-debugging help: + +```bash +glass --help +``` + +Render a full ANSI debug snapshot without opening the interactive editor: + +```bash +glass --render [--width 100] [--height rows] +``` + +`--width` defaults to 100 columns. When `--height` is omitted, Glass renders the +entire document followed by the status bar. + +Glass also renders automatically when stdout is redirected or piped: + +```bash +glass README.md | less -R +glass README.md > render.ansi +``` + ## Keybindings ### Normal mode diff --git a/src/debug_render.rs b/src/debug_render.rs new file mode 100644 index 0000000..95997bb --- /dev/null +++ b/src/debug_render.rs @@ -0,0 +1,254 @@ +use std::path::PathBuf; + +use anyhow::Result; +use ratatui::{ + Terminal, + backend::TestBackend, + layout::Rect, + style::{Color, Modifier}, +}; + +use crate::{ + app::App, + editor::render::{visible_rows, wrap_line}, + markdown::{highlight::concealed_wrap_line, table::TableLayout}, + ui, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CellStyle { + fg: Color, + bg: Color, + modifier: Modifier, +} + +impl Default for CellStyle { + fn default() -> Self { + Self { + fg: Color::Reset, + bg: Color::Reset, + modifier: Modifier::empty(), + } + } +} + +pub fn render_path_to_ansi( + notes_dir: PathBuf, + initial_file: Option, + width: u16, + height: Option, +) -> Result { + let mut app = App::new(notes_dir, initial_file)?; + let width = width.max(1); + let height = height.unwrap_or_else(|| full_render_height(&app, width)); + let backend = TestBackend::new(width, height.max(2)); + let mut terminal = Terminal::new(backend)?; + terminal.draw(|frame| ui::draw(frame, &mut app))?; + Ok(buffer_to_ansi(terminal.backend().buffer())) +} + +fn full_render_height(app: &App, width: u16) -> u16 { + let editor_area = Rect { + x: 0, + y: 0, + width, + height: 1, + }; + let page = ui::editor::page_area(editor_area); + let gutter_width = (app.buffer.line_count().to_string().len() + 1).max(1); + let text_width = page.width.saturating_sub(gutter_width as u16).max(1) as usize; + let table_layout = TableLayout::new(&app.buffer); + let rows = visible_rows( + &app.buffer, + 0, + 0, + usize::MAX, + text_width, + |line_num, text, w| { + if line_num == app.cursor.line { + wrap_line(text, w) + } else if table_layout.is_table_row(line_num) { + table_layout.wrap_line(line_num, text, w) + } else { + concealed_wrap_line(text, w) + } + }, + ); + rows.len().saturating_add(1).min(u16::MAX as usize) as u16 +} + +fn buffer_to_ansi(buffer: &ratatui::buffer::Buffer) -> String { + let mut out = String::new(); + let area = buffer.area; + for y in area.top()..area.bottom() { + let mut current = CellStyle::default(); + for x in area.left()..area.right() { + let Some(cell) = buffer.cell((x, y)) else { + continue; + }; + let next = CellStyle { + fg: cell.fg, + bg: cell.bg, + modifier: cell.modifier, + }; + if next != current { + out.push_str(&style_ansi(next)); + current = next; + } + out.push_str(cell.symbol()); + } + out.push_str("\x1b[0m"); + if y + 1 < area.bottom() { + out.push('\n'); + } + } + out +} + +fn style_ansi(style: CellStyle) -> String { + let mut sequence = String::from("\x1b[0m"); + sequence.push_str(&fg_ansi(style.fg)); + sequence.push_str(&bg_ansi(style.bg)); + if style.modifier.contains(Modifier::BOLD) { + sequence.push_str("\x1b[1m"); + } + if style.modifier.contains(Modifier::DIM) { + sequence.push_str("\x1b[2m"); + } + if style.modifier.contains(Modifier::ITALIC) { + sequence.push_str("\x1b[3m"); + } + if style.modifier.contains(Modifier::UNDERLINED) { + sequence.push_str("\x1b[4m"); + } + if style.modifier.contains(Modifier::REVERSED) { + sequence.push_str("\x1b[7m"); + } + if style.modifier.contains(Modifier::CROSSED_OUT) { + sequence.push_str("\x1b[9m"); + } + sequence +} + +fn fg_ansi(color: Color) -> String { + color_ansi(color, true) +} + +fn bg_ansi(color: Color) -> String { + color_ansi(color, false) +} + +fn color_ansi(color: Color, foreground: bool) -> String { + let target = if foreground { 38 } else { 48 }; + match color { + Color::Reset => format!("\x1b[{}m", if foreground { 39 } else { 49 }), + Color::Black => indexed_color_ansi(target, 0), + Color::Red => indexed_color_ansi(target, 1), + Color::Green => indexed_color_ansi(target, 2), + Color::Yellow => indexed_color_ansi(target, 3), + Color::Blue => indexed_color_ansi(target, 4), + Color::Magenta => indexed_color_ansi(target, 5), + Color::Cyan => indexed_color_ansi(target, 6), + Color::Gray => indexed_color_ansi(target, 7), + Color::DarkGray => indexed_color_ansi(target, 8), + Color::LightRed => indexed_color_ansi(target, 9), + Color::LightGreen => indexed_color_ansi(target, 10), + Color::LightYellow => indexed_color_ansi(target, 11), + Color::LightBlue => indexed_color_ansi(target, 12), + Color::LightMagenta => indexed_color_ansi(target, 13), + Color::LightCyan => indexed_color_ansi(target, 14), + Color::White => indexed_color_ansi(target, 15), + Color::Rgb(r, g, b) => format!("\x1b[{target};2;{r};{g};{b}m"), + Color::Indexed(index) => { + format!("\x1b[{target};5;{index}m") + } + } +} + +fn indexed_color_ansi(target: u8, index: u8) -> String { + format!("\x1b[{target};5;{index}m") +} + +#[cfg(test)] +mod tests { + use super::*; + use anyhow::Context; + use std::fs; + + #[test] + fn render_full_height_includes_entire_file_and_status_bar() -> Result<()> { + let dir = std::env::temp_dir().join(format!("glass-render-test-{}", std::process::id())); + fs::create_dir_all(&dir)?; + let file = dir.join("note.md"); + fs::write(&file, "one\ntwo\nthree\n")?; + + let ansi = render_path_to_ansi(dir.clone(), Some(file), 80, None)?; + + assert!(ansi.contains("one")); + assert!(ansi.contains("two")); + assert!(ansi.contains("three")); + assert!(ansi.contains(" NORMAL note.md")); + + fs::remove_dir_all(dir).context("failed to clean render test directory")?; + Ok(()) + } + + #[test] + fn render_height_can_clip_document_but_keeps_status_bar() -> Result<()> { + let dir = + std::env::temp_dir().join(format!("glass-render-clip-test-{}", std::process::id())); + fs::create_dir_all(&dir)?; + let file = dir.join("note.md"); + fs::write(&file, "one\ntwo\nthree\n")?; + + let ansi = render_path_to_ansi(dir.clone(), Some(file), 80, Some(2))?; + + assert!(ansi.contains("one")); + assert!(!ansi.contains("three")); + assert!(ansi.contains(" NORMAL note.md")); + + fs::remove_dir_all(dir).context("failed to clean render test directory")?; + Ok(()) + } + + #[test] + fn render_height_has_status_bar_even_when_too_small() -> Result<()> { + let dir = + std::env::temp_dir().join(format!("glass-render-small-test-{}", std::process::id())); + fs::create_dir_all(&dir)?; + let file = dir.join("note.md"); + fs::write(&file, "one\n")?; + + let ansi = render_path_to_ansi(dir.clone(), Some(file), 80, Some(1))?; + + assert!(ansi.contains(" NORMAL note.md")); + + fs::remove_dir_all(dir).context("failed to clean render test directory")?; + Ok(()) + } + + #[test] + fn style_ansi_includes_rgb_and_modifiers() { + let ansi = style_ansi(CellStyle { + fg: Color::Rgb(1, 2, 3), + bg: Color::Reset, + modifier: Modifier::BOLD | Modifier::UNDERLINED, + }); + + assert!(ansi.contains("\x1b[38;2;1;2;3m")); + assert!(ansi.contains("\x1b[1m")); + assert!(ansi.contains("\x1b[4m")); + } + + #[test] + fn named_colors_match_crossterm_palette_indices() { + let ansi = style_ansi(CellStyle { + fg: Color::Black, + bg: Color::White, + modifier: Modifier::empty(), + }); + + assert!(ansi.contains("\x1b[38;5;0m")); + assert!(ansi.contains("\x1b[48;5;15m")); + } +} diff --git a/src/main.rs b/src/main.rs index e26cd23..76b41ef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,47 +1,145 @@ mod app; mod config; +mod debug_render; mod editor; mod fs; mod markdown; mod terminal; mod ui; -use std::{env, path::PathBuf}; +use std::{ + env, + io::{self, IsTerminal, Write}, + path::{Path, PathBuf}, +}; use anyhow::{Context, Result, bail}; -use crate::{app::App, terminal::TerminalSession}; +use crate::{app::App, debug_render::render_path_to_ansi, terminal::TerminalSession}; const VERSION: &str = env!("CARGO_PKG_VERSION"); +const DEFAULT_RENDER_WIDTH: u16 = 100; fn main() -> Result<()> { - let (notes_dir, initial_file) = parse_args()?; - let app = App::new(notes_dir, initial_file)?; - TerminalSession::run(app) + match parse_args()? { + LaunchMode::App { + notes_dir, + initial_file, + } => { + let app = App::new(notes_dir, initial_file)?; + TerminalSession::run(app) + } + LaunchMode::Render { + notes_dir, + initial_file, + width, + height, + } => { + let output = render_path_to_ansi(notes_dir, initial_file, width, height)?; + write_stdout(&output) + } + LaunchMode::Help { program } => write_stdout(&help_text(&program)), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum LaunchMode { + App { + notes_dir: PathBuf, + initial_file: Option, + }, + Render { + notes_dir: PathBuf, + initial_file: Option, + width: u16, + height: Option, + }, + Help { + program: String, + }, } -fn parse_args() -> Result<(PathBuf, Option)> { - let mut args = env::args_os(); +fn parse_args() -> Result { + parse_args_os(env::args_os().collect(), std::io::stdout().is_terminal()) +} + +fn parse_args_os(args: Vec, stdout_is_terminal: bool) -> Result { + let mut args = args.into_iter(); let program = args .next() - .and_then(|arg| arg.into_string().ok()) + .and_then(display_program_name) .unwrap_or_else(|| "glass".to_string()); let Some(arg) = args.next() else { - bail!("usage: {program} "); + return Ok(LaunchMode::Help { program }); }; - if args.next().is_some() { - bail!("usage: {program} "); + let arg_str = arg.to_string_lossy(); + let rest = args.collect::>(); + + if arg_str == "--help" || arg_str == "-h" { + if !rest.is_empty() { + bail!("usage: {program} --help"); + } + return Ok(LaunchMode::Help { program }); } - let arg_str = arg.to_string_lossy(); if arg_str == "--version" || arg_str == "-v" { println!("glass {VERSION}"); std::process::exit(0); } - parse_path_arg(PathBuf::from(arg)) + if arg_str == "--render" || arg_str == "--dump-render" { + let mut width = DEFAULT_RENDER_WIDTH; + let mut height = None; + let mut path = None; + let mut index = 0usize; + while index < rest.len() { + let value = rest[index].to_string_lossy(); + match value.as_ref() { + "--width" => { + index += 1; + width = parse_u16_arg(rest.get(index), "--width")?; + } + "--height" => { + index += 1; + height = Some(parse_u16_arg(rest.get(index), "--height")?); + } + _ if path.is_none() => path = Some(PathBuf::from(&rest[index])), + _ => bail!("usage: {program} --render [--width n] [--height n] "), + } + index += 1; + } + let Some(path) = path else { + bail!("usage: {program} --render [--width n] [--height n] "); + }; + let (notes_dir, initial_file) = parse_path_arg(path)?; + return Ok(LaunchMode::Render { + notes_dir, + initial_file, + width, + height, + }); + } + + if !rest.is_empty() { + bail!("usage: {program} "); + } + + let (notes_dir, initial_file) = parse_path_arg(PathBuf::from(arg))?; + if stdout_is_terminal { + Ok(LaunchMode::App { + notes_dir, + initial_file, + }) + } else { + Ok(LaunchMode::Render { + notes_dir, + initial_file, + width: DEFAULT_RENDER_WIDTH, + height: None, + }) + } } fn parse_path_arg(path: PathBuf) -> Result<(PathBuf, Option)> { @@ -59,6 +157,169 @@ fn parse_path_arg(path: PathBuf) -> Result<(PathBuf, Option)> { } } +fn parse_u16_arg(value: Option<&std::ffi::OsString>, label: &str) -> Result { + let Some(value) = value else { + bail!("missing value for {label}"); + }; + let parsed = value + .to_string_lossy() + .parse::() + .with_context(|| format!("invalid value for {label}"))?; + Ok(parsed.max(1)) +} + +fn display_program_name(arg: std::ffi::OsString) -> Option { + let raw = arg.into_string().ok()?; + Path::new(&raw) + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.is_empty()) + .map(ToOwned::to_owned) + .or(Some(raw)) +} + +fn write_stdout(output: &str) -> Result<()> { + let mut stdout = io::stdout().lock(); + match stdout.write_all(output.as_bytes()) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::BrokenPipe => Ok(()), + Err(error) => Err(error).context("failed to write output"), + } +} + +fn help_text(program: &str) -> String { + format!( + "\ +Glass {VERSION} + +A fast terminal Markdown editor with Vim-inspired movement, live Markdown +rendering, link navigation, command/search sheets, and ANSI render debugging. + +USAGE: + {program} + {program} --render [--width ] [--height ] + {program} --help + {program} --version + +ARGUMENTS: + + A notes directory or Markdown file. + + When is a directory, Glass opens the first note from that + directory's file tree when one is available. When is a file, + Glass opens that file and uses its parent directory for file navigation. + Missing files are allowed and are created only when saved. + +OPTIONS: + -h, --help + Print this help text and exit. + + -v, --version + Print the Glass version and exit. + + --render + Render a non-interactive ANSI snapshot of to stdout. The snapshot + uses the same Ratatui UI renderer as the editor, includes color/style + escape codes, renders the document body, and ends with the status bar. + This is intended for debugging visual regressions directly in a terminal. + + --dump-render + Alias for --render. + + --width + Width for --render output. Defaults to {DEFAULT_RENDER_WIDTH}. + + --height + Optional height for --render output. When omitted, --render renders the + full document height plus the status bar. When provided, the document body + is clipped to fit and the status bar is still rendered. + +OUTPUT: + When stdout is a terminal, {program} opens the interactive editor. + When stdout is redirected or piped, {program} automatically writes the + same ANSI render snapshot as --render. Use a terminal that preserves ANSI + color, or a pager such as less -R, when inspecting piped render output. + +INTERACTIVE MODES: + Normal + Navigate, select, follow links, open command/search sheets, and run + editing commands. + + Insert + Edit text directly. Esc returns to Normal mode. + + Visual + Select whole lines for line-oriented edits. + + Command line + Type ':' for commands or '/' for search. The command sheet provides fuzzy + completion for commands, files, and search results where relevant. + +NORMAL MODE KEYS: + i enter Insert mode + a append after cursor + v enter Visual mode + : open command line + / search the current file + h j k l move left/down/up/right + w b move word forward/backward + 0 ^ $ line start, first non-blank, line end + gg G document top/bottom + n N next/previous search result + dd delete current line + d + motion delete by motion + x delete character under cursor + u undo last edit + Enter toggle checkbox or follow link under cursor + gf follow link under cursor + Ctrl+C quit + +INSERT MODE KEYS: + Esc return to Normal mode + Tab insert four spaces + Backspace delete previous character + Option-Left move one word left + Option-Right move one word right + Cmd-Left move to line start + Cmd-Right move to line end + Cmd-Delete delete to line start + Cmd-ForwardDel delete to line end + +COMMANDS: + :w, :write + Save the current file. + + :q, :quit + Quit when there are no unsaved changes. + + :q!, :quit! + Quit and discard unsaved changes. + + :wq, :x + Save and quit. + + :e , :edit + Open a file path relative to the notes directory, or create it on save if + it does not exist yet. + +MOUSE: + Click move the cursor + Drag select text and copy it immediately + Wheel scroll through wrapped visual rows + Cmd-click open the link under the pointer + +EXAMPLES: + {program} . + {program} README.md + {program} README.md | less -R + {program} README.md > render.ansi + {program} --render README.md + {program} --render --width 90 benchmark.md + {program} --render --width 90 --height 24 benchmark.md +" + ) +} + #[cfg(test)] mod tests { use super::*; @@ -77,4 +338,118 @@ mod tests { std::fs::remove_dir(dir)?; Ok(()) } + + #[test] + fn render_args_default_to_full_height() -> Result<()> { + let mode = parse_args_from(["glass", "--render", "README.md"])?; + + assert!(matches!( + mode, + LaunchMode::Render { + width: DEFAULT_RENDER_WIDTH, + height: None, + .. + } + )); + Ok(()) + } + + #[test] + fn render_args_parse_optional_dimensions() -> Result<()> { + let mode = parse_args_from([ + "glass", + "--render", + "--width", + "80", + "--height", + "24", + "README.md", + ])?; + + assert!(matches!( + mode, + LaunchMode::Render { + width: 80, + height: Some(24), + .. + } + )); + Ok(()) + } + + #[test] + fn help_args_parse_without_path() -> Result<()> { + assert_eq!( + parse_args_from(["glass"])?, + LaunchMode::Help { + program: "glass".to_string() + } + ); + assert_eq!( + parse_args_from(["glass", "--help"])?, + LaunchMode::Help { + program: "glass".to_string() + } + ); + Ok(()) + } + + #[test] + fn path_args_auto_render_when_stdout_is_not_terminal() -> Result<()> { + let mode = parse_args_from_with_stdout(["glass", "README.md"], false)?; + + assert!(matches!( + mode, + LaunchMode::Render { + width: DEFAULT_RENDER_WIDTH, + height: None, + .. + } + )); + Ok(()) + } + + #[test] + fn path_args_open_app_when_stdout_is_terminal() -> Result<()> { + let mode = parse_args_from_with_stdout(["glass", "README.md"], true)?; + + assert!(matches!(mode, LaunchMode::App { .. })); + Ok(()) + } + + #[test] + fn help_text_documents_render_and_commands() { + let help = help_text("glass"); + + assert!(help.contains("USAGE:")); + assert!(help.contains("glass --render [--width ] [--height ] ")); + assert!(help.contains("ends with the status bar")); + assert!(help.contains("stdout is redirected or piped")); + assert!(help.contains(":e , :edit ")); + assert!(help.contains("Cmd-click")); + } + + #[test] + fn displayed_program_name_uses_executable_name() { + assert_eq!( + display_program_name(std::ffi::OsString::from("target/debug/glass")).as_deref(), + Some("glass") + ); + } + + fn parse_args_from(args: [&str; N]) -> Result { + parse_args_from_with_stdout(args, true) + } + + fn parse_args_from_with_stdout( + args: [&str; N], + stdout_is_terminal: bool, + ) -> Result { + parse_args_os( + args.iter() + .map(std::ffi::OsString::from) + .collect::>(), + stdout_is_terminal, + ) + } }