Implementing basic markdown previewer
This commit is contained in:
Generated
+18
@@ -515,6 +515,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"lime-core",
|
||||
"lime-syntax",
|
||||
"pulldown-cmark",
|
||||
"ratatui",
|
||||
"serde",
|
||||
"toml",
|
||||
@@ -631,6 +632,17 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -1061,6 +1073,12 @@ dependencies = [
|
||||
"tree-sitter-language",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.24"
|
||||
|
||||
@@ -32,3 +32,4 @@ serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
dirs = "5"
|
||||
tempfile = "3"
|
||||
pulldown-cmark = { version = "0.13", default-features = false }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Owen
|
||||
Copyright (c) 2026 Owen Qwen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -4,6 +4,8 @@ Lime is a clean, modern terminal text editor written in Rust.
|
||||
|
||||
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
|
||||
|
||||
This repository contains the first usable implementation:
|
||||
@@ -45,6 +47,7 @@ lime --force huge.log
|
||||
| `Ctrl-F` | Open file picker |
|
||||
| `Ctrl-R` | Search current file |
|
||||
| `Ctrl-G` | Go to line |
|
||||
| `Ctrl-P` | Toggle markdown preview |
|
||||
| `Ctrl-Z` | Undo |
|
||||
| `Ctrl-Y` | Redo |
|
||||
| `Ctrl-A` | Start of line |
|
||||
|
||||
@@ -15,3 +15,4 @@ dirs.workspace = true
|
||||
unicode-width.workspace = true
|
||||
lime-core = { path = "../lime-core" }
|
||||
lime-syntax = { path = "../lime-syntax" }
|
||||
pulldown-cmark.workspace = true
|
||||
|
||||
@@ -18,7 +18,8 @@ use crate::config::Config;
|
||||
use crate::editor_view::{gutter_width, render_editor, Viewport};
|
||||
use crate::file_picker::FilePickerState;
|
||||
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::status_bar::{render_status_bar, StatusInfo};
|
||||
use crate::terminal::TerminalSession;
|
||||
@@ -65,6 +66,9 @@ pub struct App {
|
||||
highlights: Vec<HighlightSpan>,
|
||||
highlighted_revision: Option<u64>,
|
||||
highlighted_language: Language,
|
||||
preview_open: bool,
|
||||
preview_top_line: usize,
|
||||
preview_revision: Option<u64>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -90,6 +94,9 @@ impl App {
|
||||
highlights: Vec::new(),
|
||||
highlighted_revision: None,
|
||||
highlighted_language: Language::PlainText,
|
||||
preview_open: false,
|
||||
preview_top_line: 0,
|
||||
preview_revision: None,
|
||||
};
|
||||
|
||||
if let Some(path) = options.path {
|
||||
@@ -181,7 +188,13 @@ impl App {
|
||||
|
||||
fn draw(&mut self, frame: &mut Frame<'_>) {
|
||||
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 gutter = gutter_width(total_lines, self.config.show_line_numbers);
|
||||
let text_width = layout.editor.width.saturating_sub(gutter) as usize;
|
||||
@@ -202,6 +215,24 @@ impl App {
|
||||
&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();
|
||||
render_status_bar(
|
||||
frame,
|
||||
@@ -256,6 +287,10 @@ impl App {
|
||||
self.mode = Mode::Prompt(PromptState::new(PromptKind::Search, "Search"));
|
||||
Ok(())
|
||||
}
|
||||
AppAction::ToggleMarkdownPreview => {
|
||||
self.toggle_preview();
|
||||
Ok(())
|
||||
}
|
||||
AppAction::ClosePopup | AppAction::Cancel => {
|
||||
self.mode = Mode::Editing;
|
||||
Ok(())
|
||||
@@ -589,6 +624,9 @@ impl App {
|
||||
self.language = language;
|
||||
self.highlighted_revision = None;
|
||||
self.highlighter.clear();
|
||||
if language != Language::Markdown {
|
||||
self.preview_open = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -605,6 +643,37 @@ impl App {
|
||||
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 {
|
||||
self.editor
|
||||
.buffer()
|
||||
|
||||
@@ -32,6 +32,14 @@ pub fn render_help_bar(frame: &mut Frame<'_>, area: Rect, theme: &UiTheme) {
|
||||
"Go line ",
|
||||
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(
|
||||
" Ctrl-Q ",
|
||||
theme.normal().fg(theme.heading).bg(theme.help_bg),
|
||||
|
||||
@@ -9,6 +9,7 @@ pub enum AppAction {
|
||||
OpenFilePicker,
|
||||
OpenGoToLine,
|
||||
OpenSearch,
|
||||
ToggleMarkdownPreview,
|
||||
ClosePopup,
|
||||
Confirm,
|
||||
Cancel,
|
||||
@@ -26,6 +27,7 @@ pub fn map_editing_key(key: KeyEvent) -> AppAction {
|
||||
KeyCode::Char('f') | KeyCode::Char('F') => AppAction::OpenFilePicker,
|
||||
KeyCode::Char('g') | KeyCode::Char('G') => AppAction::OpenGoToLine,
|
||||
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('y') | KeyCode::Char('Y') => AppAction::Editor(EditorCommand::Redo),
|
||||
KeyCode::Char('a') | KeyCode::Char('A') => {
|
||||
@@ -77,4 +79,10 @@ mod tests {
|
||||
let action = map_editing_key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,13 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct AppLayout {
|
||||
pub editor: Rect,
|
||||
pub preview: Option<Rect>,
|
||||
pub status: 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 {
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
@@ -19,11 +22,36 @@ pub fn app_layout(area: Rect) -> AppLayout {
|
||||
|
||||
AppLayout {
|
||||
editor: chunks[0],
|
||||
preview: None,
|
||||
status: chunks[1],
|
||||
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 {
|
||||
let popup_layout = Layout::default()
|
||||
.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]
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ pub mod editor_view;
|
||||
pub mod file_picker;
|
||||
pub mod input;
|
||||
pub mod layout;
|
||||
pub mod markdown_preview;
|
||||
pub mod prompt;
|
||||
pub mod status_bar;
|
||||
pub mod terminal;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user