diff --git a/README.md b/README.md index f393aee..d19ce7b 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,15 @@ cargo install --path . glass ``` +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. + ## Keybindings ### Normal mode diff --git a/src/debug_render.rs b/src/debug_render.rs new file mode 100644 index 0000000..5bf2184 --- /dev/null +++ b/src/debug_render.rs @@ -0,0 +1,240 @@ +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 base = if foreground { 30 } else { 40 }; + let bright_base = if foreground { 90 } else { 100 }; + match color { + Color::Reset => format!("\x1b[{}m", if foreground { 39 } else { 49 }), + Color::Black => format!("\x1b[{base}m"), + Color::Red => format!("\x1b[{}m", base + 1), + Color::Green => format!("\x1b[{}m", base + 2), + Color::Yellow => format!("\x1b[{}m", base + 3), + Color::Blue => format!("\x1b[{}m", base + 4), + Color::Magenta => format!("\x1b[{}m", base + 5), + Color::Cyan => format!("\x1b[{}m", base + 6), + Color::Gray | Color::White => format!("\x1b[{}m", base + 7), + Color::DarkGray => format!("\x1b[{bright_base}m"), + Color::LightRed => format!("\x1b[{}m", bright_base + 1), + Color::LightGreen => format!("\x1b[{}m", bright_base + 2), + Color::LightYellow => format!("\x1b[{}m", bright_base + 3), + Color::LightBlue => format!("\x1b[{}m", bright_base + 4), + Color::LightMagenta => format!("\x1b[{}m", bright_base + 5), + Color::LightCyan => format!("\x1b[{}m", bright_base + 6), + Color::Rgb(r, g, b) => { + format!("\x1b[{};2;{r};{g};{b}m", if foreground { 38 } else { 48 }) + } + Color::Indexed(index) => { + format!("\x1b[{};5;{index}m", if foreground { 38 } else { 48 }) + } + } +} + +#[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")); + } +} diff --git a/src/main.rs b/src/main.rs index e26cd23..b4a33dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod app; mod config; +mod debug_render; mod editor; mod fs; mod markdown; @@ -10,18 +11,55 @@ use std::{env, 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, + } => { + print!( + "{}", + render_path_to_ansi(notes_dir, initial_file, width, height)? + ); + Ok(()) + } + } +} + +#[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, + }, } -fn parse_args() -> Result<(PathBuf, Option)> { - let mut args = env::args_os(); +fn parse_args() -> Result { + parse_args_os(env::args_os().collect()) +} + +fn parse_args_os(args: Vec) -> Result { + let mut args = args.into_iter(); let program = args .next() .and_then(|arg| arg.into_string().ok()) @@ -31,17 +69,55 @@ fn parse_args() -> Result<(PathBuf, Option)> { bail!("usage: {program} "); }; - if args.next().is_some() { - bail!("usage: {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)) + let rest = args.collect::>(); + 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))?; + Ok(LaunchMode::App { + notes_dir, + initial_file, + }) } fn parse_path_arg(path: PathBuf) -> Result<(PathBuf, Option)> { @@ -59,6 +135,17 @@ 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)) +} + #[cfg(test)] mod tests { use super::*; @@ -77,4 +164,50 @@ 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(()) + } + + fn parse_args_from(args: [&str; N]) -> Result { + parse_args_os( + args.iter() + .map(std::ffi::OsString::from) + .collect::>(), + ) + } }