From 05bf4c2a168c06633f9b34687f7e75bd1a60972a Mon Sep 17 00:00:00 2001 From: Owen Qwen Date: Mon, 22 Jun 2026 05:10:21 -0500 Subject: [PATCH] Implementing basic markdown previewer --- Cargo.lock | 18 + Cargo.toml | 1 + LICENSE | 2 +- README.md | 3 + crates/lime-ui/Cargo.toml | 1 + crates/lime-ui/src/app.rs | 73 ++- crates/lime-ui/src/command_bar.rs | 8 + crates/lime-ui/src/input.rs | 8 + crates/lime-ui/src/layout.rs | 50 ++ crates/lime-ui/src/lib.rs | 1 + crates/lime-ui/src/markdown_preview.rs | 767 +++++++++++++++++++++++++ 11 files changed, 929 insertions(+), 3 deletions(-) create mode 100644 crates/lime-ui/src/markdown_preview.rs diff --git a/Cargo.lock b/Cargo.lock index c4788d6..b443e80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/Cargo.toml b/Cargo.toml index 711d840..3f1ca48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,4 @@ serde = { version = "1", features = ["derive"] } toml = "0.8" dirs = "5" tempfile = "3" +pulldown-cmark = { version = "0.13", default-features = false } diff --git a/LICENSE b/LICENSE index e09d591..2cb4731 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/README.md b/README.md index dd3ae6e..872043d 100644 --- a/README.md +++ b/README.md @@ -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 | diff --git a/crates/lime-ui/Cargo.toml b/crates/lime-ui/Cargo.toml index 733a4f3..2c5e051 100644 --- a/crates/lime-ui/Cargo.toml +++ b/crates/lime-ui/Cargo.toml @@ -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 diff --git a/crates/lime-ui/src/app.rs b/crates/lime-ui/src/app.rs index affe7af..9dc81ad 100644 --- a/crates/lime-ui/src/app.rs +++ b/crates/lime-ui/src/app.rs @@ -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, highlighted_revision: Option, highlighted_language: Language, + preview_open: bool, + preview_top_line: usize, + preview_revision: Option, } 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() diff --git a/crates/lime-ui/src/command_bar.rs b/crates/lime-ui/src/command_bar.rs index 299ffa4..05f10f9 100644 --- a/crates/lime-ui/src/command_bar.rs +++ b/crates/lime-ui/src/command_bar.rs @@ -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), diff --git a/crates/lime-ui/src/input.rs b/crates/lime-ui/src/input.rs index ed5d5f7..7a2d424 100644 --- a/crates/lime-ui/src/input.rs +++ b/crates/lime-ui/src/input.rs @@ -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); + } } diff --git a/crates/lime-ui/src/layout.rs b/crates/lime-ui/src/layout.rs index c954376..b7d813e 100644 --- a/crates/lime-ui/src/layout.rs +++ b/crates/lime-ui/src/layout.rs @@ -3,10 +3,13 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; #[derive(Debug, Clone, Copy)] pub struct AppLayout { pub editor: Rect, + pub preview: Option, 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()); + } +} diff --git a/crates/lime-ui/src/lib.rs b/crates/lime-ui/src/lib.rs index fd8e371..2442b62 100644 --- a/crates/lime-ui/src/lib.rs +++ b/crates/lime-ui/src/lib.rs @@ -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; diff --git a/crates/lime-ui/src/markdown_preview.rs b/crates/lime-ui/src/markdown_preview.rs new file mode 100644 index 0000000..a501609 --- /dev/null +++ b/crates/lime-ui/src/markdown_preview.rs @@ -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` 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> { + 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> = 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>, + /// Spans accumulated for the line currently being built. + line_spans: Vec>, + /// 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