Compare commits

Author SHA1 Message Date
owen 05bf4c2a16 Implementing basic markdown previewer 2026-06-22 05:10:21 -05:00
12 changed files with 931 additions and 13 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 MiB

Generated
+18
View File
@@ -515,6 +515,7 @@ dependencies = [
"ignore", "ignore",
"lime-core", "lime-core",
"lime-syntax", "lime-syntax",
"pulldown-cmark",
"ratatui", "ratatui",
"serde", "serde",
"toml", "toml",
@@ -631,6 +632,17 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags",
"memchr",
"unicase",
]
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.45"
@@ -1061,6 +1073,12 @@ dependencies = [
"tree-sitter-language", "tree-sitter-language",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
+1
View File
@@ -32,3 +32,4 @@ serde = { version = "1", features = ["derive"] }
toml = "0.8" toml = "0.8"
dirs = "5" dirs = "5"
tempfile = "3" tempfile = "3"
pulldown-cmark = { version = "0.13", default-features = false }
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2026 Owen Copyright (c) 2026 Owen Qwen
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
+5 -10
View File
@@ -1,16 +1,10 @@
<p align="center"><strong>Codex CLI</strong> is a clean, modern terminal text editor written in Rust.</p> # Lime
<p align="center"> Lime is a clean, modern terminal text editor written in Rust.
<img
src=".github/assets/preview.gif"
alt="Lime editor preview"
width="80%"
/>
</p>
<p>It aims to be approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while keeping a standard non-modal editing model.</p> It aims to be approachable like Nano, efficient and clean like Vim, and easy to use like VS Code, while keeping a standard non-modal editing model.
--- This is a test edit.
## Status ## Status
@@ -53,6 +47,7 @@ lime --force huge.log
| `Ctrl-F` | Open file picker | | `Ctrl-F` | Open file picker |
| `Ctrl-R` | Search current file | | `Ctrl-R` | Search current file |
| `Ctrl-G` | Go to line | | `Ctrl-G` | Go to line |
| `Ctrl-P` | Toggle markdown preview |
| `Ctrl-Z` | Undo | | `Ctrl-Z` | Undo |
| `Ctrl-Y` | Redo | | `Ctrl-Y` | Redo |
| `Ctrl-A` | Start of line | | `Ctrl-A` | Start of line |
+1
View File
@@ -15,3 +15,4 @@ dirs.workspace = true
unicode-width.workspace = true unicode-width.workspace = true
lime-core = { path = "../lime-core" } lime-core = { path = "../lime-core" }
lime-syntax = { path = "../lime-syntax" } lime-syntax = { path = "../lime-syntax" }
pulldown-cmark.workspace = true
+71 -2
View File
@@ -18,7 +18,8 @@ use crate::config::Config;
use crate::editor_view::{gutter_width, render_editor, Viewport}; use crate::editor_view::{gutter_width, render_editor, Viewport};
use crate::file_picker::FilePickerState; use crate::file_picker::FilePickerState;
use crate::input::{map_editing_key, AppAction}; use crate::input::{map_editing_key, AppAction};
use crate::layout::{app_layout, centered_rect}; use crate::layout::{app_layout, centered_rect, with_preview};
use crate::markdown_preview::render_preview;
use crate::prompt::{ConfirmKind, ConfirmState, PromptKind, PromptState}; use crate::prompt::{ConfirmKind, ConfirmState, PromptKind, PromptState};
use crate::status_bar::{render_status_bar, StatusInfo}; use crate::status_bar::{render_status_bar, StatusInfo};
use crate::terminal::TerminalSession; use crate::terminal::TerminalSession;
@@ -65,6 +66,9 @@ pub struct App {
highlights: Vec<HighlightSpan>, highlights: Vec<HighlightSpan>,
highlighted_revision: Option<u64>, highlighted_revision: Option<u64>,
highlighted_language: Language, highlighted_language: Language,
preview_open: bool,
preview_top_line: usize,
preview_revision: Option<u64>,
} }
impl App { impl App {
@@ -90,6 +94,9 @@ impl App {
highlights: Vec::new(), highlights: Vec::new(),
highlighted_revision: None, highlighted_revision: None,
highlighted_language: Language::PlainText, highlighted_language: Language::PlainText,
preview_open: false,
preview_top_line: 0,
preview_revision: None,
}; };
if let Some(path) = options.path { if let Some(path) = options.path {
@@ -181,7 +188,13 @@ impl App {
fn draw(&mut self, frame: &mut Frame<'_>) { fn draw(&mut self, frame: &mut Frame<'_>) {
let area = frame.area(); let area = frame.area();
let layout = app_layout(area); let base_layout = app_layout(area);
let show_preview = self.preview_open && self.language == Language::Markdown;
let layout = if show_preview {
with_preview(base_layout, 55)
} else {
base_layout
};
let total_lines = self.editor.line_count(); let total_lines = self.editor.line_count();
let gutter = gutter_width(total_lines, self.config.show_line_numbers); let gutter = gutter_width(total_lines, self.config.show_line_numbers);
let text_width = layout.editor.width.saturating_sub(gutter) as usize; let text_width = layout.editor.width.saturating_sub(gutter) as usize;
@@ -202,6 +215,24 @@ impl App {
&self.highlights, &self.highlights,
); );
if let Some(preview_area) = layout.preview {
let revision = self.editor.revision();
if self.preview_revision != Some(revision) {
self.preview_revision = Some(revision);
}
let preview_text = self.editor.text();
let preview_lines = crate::markdown_preview::render_lines(&preview_text, &self.theme);
let height = preview_area.height.saturating_sub(2) as usize;
self.ensure_preview_visible(preview_lines.len(), height);
render_preview(
frame,
preview_area,
&preview_text,
self.preview_top_line,
&self.theme,
);
}
let file_name = self.file_label(); let file_name = self.file_label();
render_status_bar( render_status_bar(
frame, frame,
@@ -256,6 +287,10 @@ impl App {
self.mode = Mode::Prompt(PromptState::new(PromptKind::Search, "Search")); self.mode = Mode::Prompt(PromptState::new(PromptKind::Search, "Search"));
Ok(()) Ok(())
} }
AppAction::ToggleMarkdownPreview => {
self.toggle_preview();
Ok(())
}
AppAction::ClosePopup | AppAction::Cancel => { AppAction::ClosePopup | AppAction::Cancel => {
self.mode = Mode::Editing; self.mode = Mode::Editing;
Ok(()) Ok(())
@@ -589,6 +624,9 @@ impl App {
self.language = language; self.language = language;
self.highlighted_revision = None; self.highlighted_revision = None;
self.highlighter.clear(); self.highlighter.clear();
if language != Language::Markdown {
self.preview_open = false;
}
} }
} }
@@ -605,6 +643,37 @@ impl App {
self.highlighted_language = self.language; self.highlighted_language = self.language;
} }
fn toggle_preview(&mut self) {
if self.language != Language::Markdown {
self.set_status("Preview available for markdown only");
return;
}
self.preview_open = !self.preview_open;
if self.preview_open {
self.preview_top_line = 0;
self.preview_revision = None;
self.set_status("Preview opened");
} else {
self.set_status("Preview closed");
}
}
fn ensure_preview_visible(&mut self, total_preview_lines: usize, viewport_height: usize) {
if viewport_height == 0 {
return;
}
let cursor_line = self.editor.cursor().position.line;
let margin = 2usize.min(viewport_height / 3);
if cursor_line < self.preview_top_line + margin {
self.preview_top_line = cursor_line.saturating_sub(margin);
} else if cursor_line >= self.preview_top_line + viewport_height.saturating_sub(margin) {
self.preview_top_line =
cursor_line.saturating_sub(viewport_height.saturating_sub(margin + 1));
}
let max_top = total_preview_lines.saturating_sub(viewport_height);
self.preview_top_line = self.preview_top_line.min(max_top);
}
fn file_label(&self) -> String { fn file_label(&self) -> String {
self.editor self.editor
.buffer() .buffer()
+8
View File
@@ -32,6 +32,14 @@ pub fn render_help_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme) {
"Go line ", "Go line ",
theme.normal().fg(theme.help_fg).bg(theme.help_bg), theme.normal().fg(theme.help_fg).bg(theme.help_bg),
), ),
Span::styled(
" Ctrl-P ",
theme.normal().fg(theme.heading).bg(theme.help_bg),
),
Span::styled(
"Preview ",
theme.normal().fg(theme.help_fg).bg(theme.help_bg),
),
Span::styled( Span::styled(
" Ctrl-Q ", " Ctrl-Q ",
theme.normal().fg(theme.heading).bg(theme.help_bg), theme.normal().fg(theme.heading).bg(theme.help_bg),
+8
View File
@@ -9,6 +9,7 @@ pub enum AppAction {
OpenFilePicker, OpenFilePicker,
OpenGoToLine, OpenGoToLine,
OpenSearch, OpenSearch,
ToggleMarkdownPreview,
ClosePopup, ClosePopup,
Confirm, Confirm,
Cancel, Cancel,
@@ -26,6 +27,7 @@ pub fn map_editing_key(key: KeyEvent) -> AppAction {
KeyCode::Char('f') | KeyCode::Char('F') => AppAction::OpenFilePicker, KeyCode::Char('f') | KeyCode::Char('F') => AppAction::OpenFilePicker,
KeyCode::Char('g') | KeyCode::Char('G') => AppAction::OpenGoToLine, KeyCode::Char('g') | KeyCode::Char('G') => AppAction::OpenGoToLine,
KeyCode::Char('r') | KeyCode::Char('R') => AppAction::OpenSearch, KeyCode::Char('r') | KeyCode::Char('R') => AppAction::OpenSearch,
KeyCode::Char('p') | KeyCode::Char('P') => AppAction::ToggleMarkdownPreview,
KeyCode::Char('z') | KeyCode::Char('Z') => AppAction::Editor(EditorCommand::Undo), KeyCode::Char('z') | KeyCode::Char('Z') => AppAction::Editor(EditorCommand::Undo),
KeyCode::Char('y') | KeyCode::Char('Y') => AppAction::Editor(EditorCommand::Redo), KeyCode::Char('y') | KeyCode::Char('Y') => AppAction::Editor(EditorCommand::Redo),
KeyCode::Char('a') | KeyCode::Char('A') => { KeyCode::Char('a') | KeyCode::Char('A') => {
@@ -77,4 +79,10 @@ mod tests {
let action = map_editing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)); let action = map_editing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
assert_eq!(action, AppAction::Editor(EditorCommand::InsertChar('x'))); assert_eq!(action, AppAction::Editor(EditorCommand::InsertChar('x')));
} }
#[test]
fn maps_preview_toggle_shortcut() {
let action = map_editing_key(KeyEvent::new(KeyCode::Char('p'), KeyModifiers::CONTROL));
assert_eq!(action, AppAction::ToggleMarkdownPreview);
}
} }
+50
View File
@@ -3,10 +3,13 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub struct AppLayout { pub struct AppLayout {
pub editor: Rect, pub editor: Rect,
pub preview: Option<Rect>,
pub status: Rect, pub status: Rect,
pub help: Rect, pub help: Rect,
} }
/// Split `area` into the editor viewport, an optional markdown preview pane
/// on the right, the status bar, and the help bar.
pub fn app_layout(area: Rect) -> AppLayout { pub fn app_layout(area: Rect) -> AppLayout {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -19,11 +22,36 @@ pub fn app_layout(area: Rect) -> AppLayout {
AppLayout { AppLayout {
editor: chunks[0], editor: chunks[0],
preview: None,
status: chunks[1], status: chunks[1],
help: chunks[2], help: chunks[2],
} }
} }
/// Split the editor region horizontally to make room for a preview pane on
/// the right. `editor_pct` is the width percentage (0-100) allocated to the
/// editor; the remainder goes to the preview, with a one-column divider.
pub fn with_preview(layout: AppLayout, editor_pct: u16) -> AppLayout {
let editor_pct = editor_pct.clamp(20, 94);
let div = if layout.editor.width > 30 { 1 } else { 0 };
let constraints = [
Constraint::Percentage(editor_pct),
Constraint::Length(div),
Constraint::Percentage(100u16.saturating_sub(editor_pct)),
];
let split = Layout::default()
.direction(Direction::Horizontal)
.constraints(constraints)
.split(layout.editor);
AppLayout {
editor: split[0],
preview: Some(split[2]),
status: layout.status,
help: layout.help,
}
}
pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let popup_layout = Layout::default() let popup_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -43,3 +71,25 @@ pub fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
]) ])
.split(popup_layout[1])[1] .split(popup_layout[1])[1]
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn preview_split_gives_two_panes() {
let area = Rect::new(0, 0, 100, 24);
let base = app_layout(area);
let split = with_preview(base, 55);
assert!(split.preview.is_some());
assert!(split.editor.width < base.editor.width);
assert!(split.preview.unwrap().width > 0);
}
#[test]
fn preview_disabled_has_no_pane() {
let area = Rect::new(0, 0, 80, 24);
let layout = app_layout(area);
assert!(layout.preview.is_none());
}
}
+1
View File
@@ -5,6 +5,7 @@ pub mod editor_view;
pub mod file_picker; pub mod file_picker;
pub mod input; pub mod input;
pub mod layout; pub mod layout;
pub mod markdown_preview;
pub mod prompt; pub mod prompt;
pub mod status_bar; pub mod status_bar;
pub mod terminal; pub mod terminal;
+767
View File
@@ -0,0 +1,767 @@
//! Render markdown source into styled ratatui lines for the preview pane.
//!
//! This is a pure converter: feed it the buffer text and a theme, get back a
//! `Vec<Line>` that the caller can slice to the visible viewport. Parsing uses
//! `pulldown-cmark` so we get correct CommonMark handling for free.
use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::{layout::Rect, Frame};
use crate::theme::UiTheme;
/// Render the full markdown document into styled lines.
pub fn render_lines(text: &str, theme: &UiTheme) -> Vec<Line<'static>> {
let opts = Options::ENABLE_TABLES
| Options::ENABLE_STRIKETHROUGH
| Options::ENABLE_TASKLISTS
| Options::ENABLE_GFM;
let parser = Parser::new_ext(text, opts);
let mut renderer = Renderer::new(theme);
for event in parser {
renderer.handle(event);
}
renderer.finish();
renderer.lines
}
/// Draw a slice of the rendered markdown into the given area, scrolling by
/// `top_line` lines from the top of the document.
pub fn render_preview(
frame: &mut Frame<'_>,
area: Rect,
text: &str,
top_line: usize,
theme: &UiTheme,
) {
let block = Block::default()
.title(" Preview ")
.borders(Borders::ALL)
.border_style(Style::default().fg(theme.popup_border))
.style(theme.normal().bg(theme.background));
let inner = block.inner(area);
frame.render_widget(block, area);
let all_lines = render_lines(text, theme);
let height = inner.height as usize;
let start = top_line.min(all_lines.len());
let end = (start + height).min(all_lines.len());
let visible: Vec<Line<'_>> = all_lines
.iter()
.skip(start)
.take(end.saturating_sub(start))
.cloned()
.collect();
frame.render_widget(
Paragraph::new(visible)
.style(theme.normal().bg(theme.background))
.wrap(Wrap { trim: false }),
inner,
);
}
struct Renderer<'a> {
theme: &'a UiTheme,
lines: Vec<Line<'static>>,
/// Spans accumulated for the line currently being built.
line_spans: Vec<Span<'static>>,
/// Pending text run waiting to be flushed into a span.
run: String,
/// Style applied to the current text run.
run_style: Style,
/// Base style for the current block (heading, code, paragraph, ...).
base_style: Style,
/// Saved `run_style` values for nested inline tags (bold/italic/...).
inline_saves: Vec<Style>,
/// Prefix strings contributed by enclosing blocks (blockquote bar, list
/// indent, ...). Concatenated to form the left margin of each line.
indent_parts: Vec<&'static str>,
/// One entry per open list. `Some(n)` is an ordered list whose next item
/// marker is `n.`; `None` is an unordered list using `-`.
list_stack: Vec<Option<u64>>,
/// Marker to emit at the start of the current list item's first line.
item_marker: Option<String>,
/// Whether the next inline content should begin a fresh line.
at_line_start: bool,
/// Whether a blank separator line should be inserted before the next line.
pending_blank: bool,
/// Block-level state for fenced/indented code blocks.
in_code_block: bool,
/// Buffered lines for the current code block, emitted as a full box at
/// `TagEnd::CodeBlock` once the width and line count are known.
code_block_lines: Vec<String>,
/// Partial line being accumulated for the current code block.
code_block_current: String,
/// Language label for the current fenced code block, if any.
code_block_lang: Option<String>,
/// Block-level state for blockquotes (affects prefix only).
quote_depth: usize,
/// Pending link destination URLs, one per nested `Link`/`Image` tag.
link_urls: Vec<String>,
/// Minimal table support: track current row cell separator state.
table_cell_index: usize,
in_table_row: bool,
}
impl<'a> Renderer<'a> {
fn new(theme: &'a UiTheme) -> Self {
Self {
theme,
lines: Vec::new(),
line_spans: Vec::new(),
run: String::new(),
run_style: theme.normal(),
base_style: theme.normal(),
inline_saves: Vec::new(),
indent_parts: Vec::new(),
list_stack: Vec::new(),
item_marker: None,
at_line_start: true,
pending_blank: false,
in_code_block: false,
code_block_lines: Vec::new(),
code_block_current: String::new(),
code_block_lang: None,
quote_depth: 0,
link_urls: Vec::new(),
table_cell_index: 0,
in_table_row: false,
}
}
fn handle(&mut self, event: Event<'_>) {
match event {
Event::Start(tag) => self.start_tag(tag),
Event::End(end) => self.end_tag(end),
Event::Text(text) => self.text(&text),
Event::Code(code) => self.inline_code(&code),
Event::Html(html) | Event::InlineHtml(html) => self.text(&html),
Event::SoftBreak => self.soft_break(),
Event::HardBreak => self.hard_break(),
Event::Rule => self.rule(),
Event::TaskListMarker(checked) => self.task_marker(checked),
Event::InlineMath(math) | Event::DisplayMath(math) => self.inline_code(&math),
Event::FootnoteReference(label) => {
let s = format!("[^{label}]");
self.text(&s);
}
}
}
fn start_tag(&mut self, tag: Tag<'_>) {
match tag {
Tag::Paragraph => {
self.end_line();
self.reset_block_style();
}
Tag::Heading { level, .. } => {
self.end_line();
self.request_blank();
self.base_style = self.heading_style(level);
self.run_style = self.base_style;
let hashes = "#".repeat(level as usize);
self.line_spans
.push(Span::styled(format!("{hashes} "), self.base_style));
self.at_line_start = false;
}
Tag::CodeBlock(kind) => {
self.end_line();
self.request_blank();
self.in_code_block = true;
self.base_style = self.code_style();
self.run_style = self.base_style;
self.code_block_lines.clear();
self.code_block_current.clear();
self.code_block_lang = match kind {
CodeBlockKind::Fenced(lang) if !lang.is_empty() => Some(lang.into_string()),
_ => None,
};
}
Tag::BlockQuote(_) => {
self.end_line();
self.indent_parts.push("│ ");
self.quote_depth += 1;
self.reset_block_style();
}
Tag::List(start) => {
self.end_line();
if !self.list_stack.is_empty() {
self.indent_parts.push(" ");
}
self.list_stack.push(start);
self.reset_block_style();
}
Tag::Item => {
self.end_line();
self.item_marker = Some(self.next_item_marker());
self.reset_block_style();
}
Tag::Emphasis => self.push_inline(self.italic_style()),
Tag::Strong => self.push_inline(self.bold_style()),
Tag::Strikethrough => self.push_inline(self.strike_style()),
Tag::Link { dest_url, .. } => {
self.link_urls.push(dest_url.into_string());
self.push_inline(self.link_style());
}
Tag::Image { dest_url, .. } => {
self.link_urls.push(dest_url.into_string());
self.line_spans
.push(Span::styled("image: ", self.theme.dim()));
self.push_inline(self.link_style());
}
Tag::Table(_) => {
self.end_line();
self.request_blank();
}
Tag::TableHead | Tag::TableRow => {
self.end_line();
self.in_table_row = true;
self.table_cell_index = 0;
self.reset_block_style();
}
Tag::TableCell => {
if self.table_cell_index > 0 {
self.line_spans.push(Span::styled(" │ ", self.theme.dim()));
}
self.table_cell_index += 1;
self.at_line_start = false;
}
Tag::HtmlBlock => {
self.end_line();
self.request_blank();
self.base_style = self.theme.dim();
self.run_style = self.base_style;
}
Tag::FootnoteDefinition(_)
| Tag::DefinitionList
| Tag::DefinitionListTitle
| Tag::DefinitionListDefinition
| Tag::MetadataBlock(_)
| Tag::Superscript
| Tag::Subscript => {
self.reset_block_style();
}
}
}
fn end_tag(&mut self, end: TagEnd) {
match end {
TagEnd::Paragraph => {
self.end_line();
self.request_blank();
}
TagEnd::Heading(_) => {
self.end_line();
self.request_blank();
self.reset_block_style();
}
TagEnd::CodeBlock => {
self.in_code_block = false;
if !self.code_block_current.is_empty() {
self.code_block_lines
.push(std::mem::take(&mut self.code_block_current));
}
self.render_code_box();
self.request_blank();
self.reset_block_style();
}
TagEnd::BlockQuote(_) => {
self.end_line();
if !self.indent_parts.is_empty() {
self.indent_parts.pop();
}
self.quote_depth = self.quote_depth.saturating_sub(1);
self.request_blank();
self.reset_block_style();
}
TagEnd::List(_) => {
self.end_line();
let was_nested = !self.list_stack.is_empty();
self.list_stack.pop();
if was_nested {
if !self.indent_parts.is_empty() {
self.indent_parts.pop();
}
} else {
self.request_blank();
}
self.reset_block_style();
}
TagEnd::Item => {
self.end_line();
self.item_marker = None;
self.reset_block_style();
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline(),
TagEnd::Link | TagEnd::Image => {
self.pop_inline();
if let Some(url) = self.link_urls.pop() {
if !url.is_empty() {
self.ensure_line_started();
self.flush_run();
self.line_spans
.push(Span::styled(format!(" ({url})"), self.theme.dim()));
}
}
}
TagEnd::Table => {
self.end_line();
self.request_blank();
}
TagEnd::TableHead | TagEnd::TableRow => {
self.in_table_row = false;
self.table_cell_index = 0;
}
TagEnd::TableCell => {
// Cell content already emitted inline; nothing to do.
}
TagEnd::HtmlBlock => {
self.end_line();
self.request_blank();
self.reset_block_style();
}
_ => {}
}
}
fn text(&mut self, text: &str) {
if self.in_code_block {
self.code_block_text(text);
return;
}
self.ensure_line_started();
self.run.push_str(text);
}
fn code_block_text(&mut self, text: &str) {
for (i, part) in text.split('\n').enumerate() {
if i > 0 {
self.code_block_lines
.push(std::mem::take(&mut self.code_block_current));
}
self.code_block_current.push_str(part);
}
self.at_line_start = false;
}
fn render_code_box(&mut self) {
let lines = std::mem::take(&mut self.code_block_lines);
if lines.is_empty() {
return;
}
let prefix = self.current_prefix();
let dim = self.theme.dim();
let code_style = self.code_style();
let line_num_width = lines.len().to_string().len();
let max_code_len = lines
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
.max(1);
// Layout per line: "│ " + {n:>w} + " │ " + {code:<pad} + " │"
let box_width = 2 + line_num_width + 3 + max_code_len + 2;
let push_prefix = |spans: &mut Vec<Span<'static>>, p: &str| {
if !p.is_empty() {
spans.push(Span::styled(p.to_string(), dim));
}
};
// Top border
let top = {
let header = match &self.code_block_lang {
Some(lang) => format!("┌─ {lang} "),
None => "┌".to_string(),
};
let header_len = header.chars().count();
let fill = box_width.saturating_sub(header_len + 1);
format!("{}{}┐", header, "─".repeat(fill))
};
let mut spans = Vec::new();
push_prefix(&mut spans, &prefix);
spans.push(Span::styled(top, dim));
self.lines.push(Line::from(spans));
// Code lines with line numbers
for (idx, code) in lines.iter().enumerate() {
let num = format!("{:>width$}", idx + 1, width = line_num_width);
let code_len = code.chars().count();
let mut spans = Vec::new();
push_prefix(&mut spans, &prefix);
spans.push(Span::styled("│ ", dim));
spans.push(Span::styled(num, dim));
spans.push(Span::styled(" │ ", dim));
spans.push(Span::styled(code.clone(), code_style));
let pad = max_code_len.saturating_sub(code_len);
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), code_style));
}
spans.push(Span::styled(" │", dim));
self.lines.push(Line::from(spans));
}
// Bottom border
let bottom = format!("└{}┘", "─".repeat(box_width.saturating_sub(2)));
let mut spans = Vec::new();
push_prefix(&mut spans, &prefix);
spans.push(Span::styled(bottom, dim));
self.lines.push(Line::from(spans));
}
fn inline_code(&mut self, code: &str) {
self.ensure_line_started();
self.flush_run();
let style = self.code_style();
self.line_spans.push(Span::styled(code.to_string(), style));
}
fn soft_break(&mut self) {
if self.in_code_block {
return;
}
self.end_line();
self.at_line_start = true;
// Continuation of the same block: reuse the current prefix/marker flow
// but do not re-emit the item marker (it was consumed on the first
// line of the item).
}
fn hard_break(&mut self) {
if self.in_code_block {
return;
}
self.end_line();
self.end_line();
}
fn rule(&mut self) {
self.end_line();
self.request_blank();
let prefix = self.current_prefix();
if !prefix.is_empty() {
self.line_spans.push(Span::styled(prefix, self.theme.dim()));
}
let width = 40usize;
let rule = "─".repeat(width);
self.line_spans.push(Span::styled(rule, self.theme.dim()));
self.flush_line();
self.request_blank();
}
fn task_marker(&mut self, checked: bool) {
// Task list markers replace the bullet marker.
self.item_marker = None;
self.ensure_line_started();
self.flush_run();
let glyph = if checked { "[x] " } else { "[ ] " };
self.line_spans.push(Span::styled(glyph, self.theme.dim()));
}
fn ensure_line_started(&mut self) {
if self.at_line_start {
self.begin_line();
self.at_line_start = false;
}
}
fn begin_line(&mut self) {
if self.pending_blank {
self.pending_blank = false;
self.lines.push(Line::default());
}
let prefix = self.current_prefix();
if !prefix.is_empty() {
self.line_spans.push(Span::styled(prefix, self.theme.dim()));
}
if let Some(marker) = self.item_marker.take() {
self.line_spans
.push(Span::styled(marker, self.theme.normal()));
}
}
fn end_line(&mut self) {
self.flush_run();
if !self.line_spans.is_empty() || self.in_code_block {
self.lines
.push(Line::from(std::mem::take(&mut self.line_spans)));
}
self.at_line_start = true;
}
fn flush_line(&mut self) {
self.flush_run();
self.lines
.push(Line::from(std::mem::take(&mut self.line_spans)));
self.at_line_start = true;
}
fn flush_run(&mut self) {
if !self.run.is_empty() {
let span = Span::styled(self.run.clone(), self.run_style);
self.line_spans.push(span);
self.run.clear();
}
}
fn request_blank(&mut self) {
if !self.lines.is_empty() {
self.pending_blank = true;
}
}
fn current_prefix(&self) -> String {
let mut s = String::new();
for part in &self.indent_parts {
s.push_str(part);
}
s
}
fn next_item_marker(&mut self) -> String {
match self.list_stack.last_mut() {
Some(Some(n)) => {
let marker = format!("{}. ", n);
*n += 1;
marker
}
_ => "- ".to_string(),
}
}
fn push_inline(&mut self, style: Style) {
self.ensure_line_started();
self.flush_run();
self.inline_saves.push(self.run_style);
self.run_style = style;
}
fn pop_inline(&mut self) {
self.flush_run();
if let Some(saved) = self.inline_saves.pop() {
self.run_style = saved;
}
}
fn reset_block_style(&mut self) {
self.base_style = self.theme.normal();
self.run_style = self.base_style;
}
fn finish(&mut self) {
self.end_line();
// Trim trailing blank lines.
while self.lines.last().is_some_and(|line| line.spans.is_empty()) {
self.lines.pop();
}
}
fn heading_style(&self, level: HeadingLevel) -> Style {
let color = match level {
HeadingLevel::H1 | HeadingLevel::H2 => self.theme.heading,
HeadingLevel::H3 | HeadingLevel::H4 => self.theme.function,
HeadingLevel::H5 | HeadingLevel::H6 => self.theme.type_name,
};
let mut style = Style::default().fg(color).bg(self.theme.background);
if matches!(level, HeadingLevel::H1 | HeadingLevel::H2) {
style = style.add_modifier(Modifier::BOLD);
}
style
}
fn code_style(&self) -> Style {
Style::default()
.fg(self.theme.string)
.bg(self.theme.selection_bg)
}
fn bold_style(&self) -> Style {
self.run_style.add_modifier(Modifier::BOLD)
}
fn italic_style(&self) -> Style {
self.run_style.add_modifier(Modifier::ITALIC)
}
fn strike_style(&self) -> Style {
self.run_style.add_modifier(Modifier::CROSSED_OUT)
}
fn link_style(&self) -> Style {
Style::default()
.fg(self.theme.keyword)
.bg(self.theme.background)
.add_modifier(Modifier::UNDERLINED)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<Vec<_>>()
.join("")
}
#[test]
fn renders_heading_with_hashes() {
let theme = UiTheme::default();
let lines = render_lines("# Title\n\nBody", &theme);
assert!(line_text(&lines[0]).starts_with("# Title"));
assert!(line_text(&lines[1]).is_empty());
assert_eq!(line_text(&lines[2]), "Body");
}
#[test]
fn renders_unordered_list() {
let theme = UiTheme::default();
let lines = render_lines("- one\n- two\n- three", &theme);
assert_eq!(line_text(&lines[0]), "- one");
assert_eq!(line_text(&lines[1]), "- two");
assert_eq!(line_text(&lines[2]), "- three");
}
#[test]
fn renders_ordered_list_with_increasing_numbers() {
let theme = UiTheme::default();
let lines = render_lines("1. first\n2. second\n3. third", &theme);
assert_eq!(line_text(&lines[0]), "1. first");
assert_eq!(line_text(&lines[1]), "2. second");
assert_eq!(line_text(&lines[2]), "3. third");
}
#[test]
fn renders_nested_list_with_indent() {
let theme = UiTheme::default();
let lines = render_lines("- top\n - nested\n- back", &theme);
assert_eq!(line_text(&lines[0]), "- top");
assert_eq!(line_text(&lines[1]), " - nested");
assert_eq!(line_text(&lines[2]), "- back");
}
#[test]
fn renders_blockquote_with_bar_prefix() {
let theme = UiTheme::default();
let lines = render_lines("> quoted text", &theme);
assert_eq!(line_text(&lines[0]), "│ quoted text");
}
#[test]
fn renders_fenced_code_block_with_box_and_line_numbers() {
let theme = UiTheme::default();
let md = "```rust\nfn main() {}\n```\n";
let lines = render_lines(md, &theme);
let combined: Vec<String> = lines.iter().map(|l| line_text(l)).collect();
// Top border with language label
assert!(
combined.iter().any(|s| s.starts_with("┌─ rust ")),
"missing top border: {combined:?}"
);
// Code line with line number and side borders
assert!(
combined
.iter()
.any(|s| s.contains("│ 1 │") && s.contains("fn main()")),
"missing numbered code line: {combined:?}"
);
// Bottom border
assert!(
combined.iter().any(|s| s.starts_with('└')),
"missing bottom border: {combined:?}"
);
}
#[test]
fn renders_code_block_line_number_padding() {
let theme = UiTheme::default();
let md = "```\na\nb\nc\nd\ne\nf\ng\nh\ni\nj\n```\n";
let lines = render_lines(md, &theme);
let combined: Vec<String> = lines.iter().map(|l| line_text(l)).collect();
// Single-digit line numbers should be right-aligned to width 2
assert!(
combined.iter().any(|s| s.contains("│ 1 │")),
"line 1 not padded: {combined:?}"
);
// Double-digit line numbers should have no extra padding
assert!(
combined.iter().any(|s| s.contains("│ 10 │")),
"line 10 not correct: {combined:?}"
);
}
#[test]
fn renders_code_block_without_language() {
let theme = UiTheme::default();
let md = "```\nhello\n```\n";
let lines = render_lines(md, &theme);
let combined: Vec<String> = lines.iter().map(|l| line_text(l)).collect();
assert!(
combined
.iter()
.any(|s| s.starts_with('┌') && !s.contains("┌─ ")),
"plain code block should have bare top border: {combined:?}"
);
assert!(
combined.iter().any(|s| s.contains("hello")),
"missing code content: {combined:?}"
);
}
#[test]
fn renders_horizontal_rule() {
let theme = UiTheme::default();
let lines = render_lines("above\n\n---\n\nbelow", &theme);
let combined: String = lines
.iter()
.map(|l| line_text(l))
.collect::<Vec<_>>()
.join("\n");
assert!(combined.contains("───"), "{combined}");
assert!(combined.contains("above"));
assert!(combined.contains("below"));
}
#[test]
fn renders_link_with_url() {
let theme = UiTheme::default();
let lines = render_lines("[Lime](https://example.com)", &theme);
let text = line_text(&lines[0]);
assert!(text.contains("Lime"), "{text}");
assert!(text.contains("(https://example.com)"), "{text}");
}
#[test]
fn renders_task_list_markers() {
let theme = UiTheme::default();
let lines = render_lines("- [ ] todo\n- [x] done", &theme);
assert_eq!(line_text(&lines[0]), "[ ] todo");
assert_eq!(line_text(&lines[1]), "[x] done");
}
#[test]
fn renders_bold_and_italic_text() {
let theme = UiTheme::default();
let lines = render_lines("**bold** and _italic_", &theme);
let text = line_text(&lines[0]);
assert!(text.contains("bold"), "{text}");
assert!(text.contains("italic"), "{text}");
}
#[test]
fn empty_input_yields_no_lines() {
let theme = UiTheme::default();
let lines = render_lines("", &theme);
assert!(lines.is_empty());
}
}