diff --git a/Cargo.lock b/Cargo.lock index 55fcc0d..351b199 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1421,6 +1421,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "egui-mcp-bridge" +version = "0.1.0" +dependencies = [ + "egui", + "serde", + "serde_json", + "thiserror 2.0.20", + "tokio", + "tracing", +] + [[package]] name = "egui-wgpu" version = "0.33.3" @@ -3350,6 +3362,7 @@ dependencies = [ "clap", "eframe", "egui", + "egui-mcp-bridge", "egui_commonmark_extended", "env_logger", "fontique", @@ -3504,6 +3517,17 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "mitex" version = "0.2.4" @@ -3713,7 +3737,7 @@ dependencies = [ "kqueue", "libc", "log", - "mio", + "mio 0.8.11", "walkdir", "windows-sys 0.48.0", ] @@ -5512,6 +5536,16 @@ dependencies = [ "serde", ] +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "spin" version = "0.9.9" @@ -5899,6 +5933,34 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "toml" version = "0.8.23" diff --git a/Cargo.toml b/Cargo.toml index 08ef945..15e0c41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,7 +32,7 @@ eframe = { version = "0.33", default-features = false, features = [ egui = { version = "0.33", features = ["accesskit"] } # MCP bridge for E2E testing (optional, local dev only - not on crates.io) -# egui-mcp-bridge = { path = "/home/ahmet/dev/mcp/egui-mcp/crates/egui-mcp-bridge", optional = true } +egui-mcp-bridge = { path = "/home/ahmet/dev/mcp/egui-mcp/crates/egui-mcp-bridge", optional = true } # Markdown rendering with syntax highlighting (fork with typography support) egui_commonmark_extended = { version = "0.28.2", features = [ @@ -73,7 +73,7 @@ percent-encoding = "2.3" [features] default = [] -# mcp = ["dep:egui-mcp-bridge"] +mcp = ["dep:egui-mcp-bridge"] [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(feature, values("mcp"))'] } diff --git a/base.png b/base.png new file mode 100644 index 0000000..9d30108 Binary files /dev/null and b/base.png differ diff --git a/crates/egui_commonmark/egui_commonmark/src/lib.rs b/crates/egui_commonmark/egui_commonmark/src/lib.rs index ddad6c8..432aa08 100644 --- a/crates/egui_commonmark/egui_commonmark/src/lib.rs +++ b/crates/egui_commonmark/egui_commonmark/src/lib.rs @@ -94,7 +94,14 @@ pub use egui_commonmark_backend_extended::RenderHtmlFn; pub use egui_commonmark_backend_extended::RenderMathFn; pub use egui_commonmark_backend_extended::alerts::{Alert, AlertBundle}; pub use egui_commonmark_backend_extended::misc::{ - header_position_key, CommonMarkCache, STRONG_FONT_FAMILY, + header_position_key, CommonMarkCache, EditFeedback, EditRegionConfig, STRONG_FONT_FAMILY, +}; +pub use egui_commonmark_backend_extended::misc::{BlockBoundary, top_level_block_spans}; +pub use egui_commonmark_backend_extended::misc::{ + EditSessionConfig, SessionBlock, SessionBlockFeedback, +}; +pub use egui_commonmark_backend_extended::styler::{ + EditBlockKind, MarkdownEditStyle, markdown_block_job, }; pub use egui_commonmark_backend_extended::typography::{Measurement, TypographyConfig}; #[cfg(feature = "math")] @@ -481,6 +488,32 @@ impl<'f> CommonMarkViewer<'f> { self } + /// Live-preview editing: replace the given top-level block byte range + /// with an inline TextEdit for this frame. The editor's working buffer + /// lives in egui temp state under `EditRegionConfig::id`; collect it via + /// [`CommonMarkCache::take_edit_feedback`] right after painting and splice + /// changed text back into your own buffer. + pub fn edit_region(mut self, region: Option) -> Self { + self.options.edit_region = region; + self + } + + /// Persistent editing session: every text block in `blocks` paints as a + /// styled TextEdit bound to per-block temp buffers; other blocks render + /// normally. Collect per-block results via + /// [`CommonMarkCache::take_session_feedback`] right after painting. + pub fn edit_session(mut self, session: Option) -> Self { + self.options.edit_session = session; + self + } + + /// Record block boundaries while painting so the caller can hit-test + /// clicks into byte ranges via [`CommonMarkCache::block_span_at_content_y`]. + pub fn record_block_layout(mut self, on: bool) -> Self { + self.options.record_block_layout = on; + self + } + /// Paint the complete document this frame so off-screen target positions /// can be measured. Keep this disabled for scrolling to a cached position. pub fn force_full_render(mut self, force: bool) -> Self { diff --git a/crates/egui_commonmark/egui_commonmark/src/parsers/pulldown.rs b/crates/egui_commonmark/egui_commonmark/src/parsers/pulldown.rs index 2cb5d88..4d1b24b 100644 --- a/crates/egui_commonmark/egui_commonmark/src/parsers/pulldown.rs +++ b/crates/egui_commonmark/egui_commonmark/src/parsers/pulldown.rs @@ -15,6 +15,7 @@ use egui_commonmark_backend_extended::elements::{ }; use egui_commonmark_backend_extended::misc::*; use egui_commonmark_backend_extended::pulldown::*; + use pulldown_cmark::{CowStr, HeadingLevel}; use unicode_segmentation::UnicodeSegmentation; @@ -1695,13 +1696,198 @@ impl CommonMarkViewerInternal { .expect("events just cached") .to_vec(); let event_count = events_data.len(); + if crate::misc::edit_debug() { + eprintln!( + "[show] session={:?} region={} events={}", + options.edit_session.as_ref().map(|s| s.blocks.len()), + options.edit_region.is_some(), + event_count + ); + } let mut events = events_data .into_iter() .enumerate() .peekable(); + // Live-preview editing state for this frame. + let mut session_painted: std::collections::HashSet = + std::collections::HashSet::new(); + let mut session_fb: HashMap> = + HashMap::new(); + let _ = cache.take_edit_feedback(); + + if crate::misc::edit_debug() { + eprintln!( + "[show] mode: session={:?} edit_region={:?} events={}", + options.edit_session.as_ref().map(|s| s.blocks.len()), + options.edit_region.is_some(), + event_count + ); + } + while let Some((index, (e, src_span))) = events.next() { let start_position = ui.next_widget_position(); + let src_span_end = src_span.end; + + if crate::misc::edit_debug() && std::env::var("MDV_PROBE").is_ok() { + eprintln!( + "[probe] session_blocks={:?} span={}..{}", + options.edit_session.as_ref().map(|s| s.blocks.len()), + src_span.start, + src_span.end + ); + } + // ---- Persistent editing session ---- + // Every text block paints as a styled TextEdit bound to its + // own temp buffer; non-text blocks fall through to normal + // rendering. Per-block feedback is stashed for the caller. + if let Some(session) = &options.edit_session { + let block_idx = session.blocks.iter().position(|b| { + src_span.start >= b.src.start && src_span.end <= b.src.end + }); + if let Some(bi) = block_idx { + let blk = &session.blocks[bi]; + let text_kind = matches!( + blk.kind, + crate::styler::EditBlockKind::Heading(_) + | crate::styler::EditBlockKind::Paragraph + | crate::styler::EditBlockKind::Quote + | crate::styler::EditBlockKind::ListItem + ); + if text_kind && !session_painted.contains(&bi) { + session_painted.insert(bi); + let editor_id = session.id_salt.with(("blk", bi)); + // Seed from source on first paint of this + // block; later frames read the live buffer. + let (mut buf, seeded) = ui.ctx().data_mut(|d| { + match d.get_temp::(editor_id) { + Some(b) => (b, false), + None => (text.get(blk.src.clone()).unwrap_or("").to_string(), true), + } + }); + let _ = seeded; + let caret_char = ui + .ctx() + .data_mut(|d| d.get_temp::(editor_id.with("caret"))); + let reveal_line = caret_char + .map(|ci| buf.chars().take(ci).filter(|&c| c == '\n').count()); + + let sty = crate::styler::MarkdownEditStyle::from_ui(ui); + let kind = blk.kind; + let sty_for_measure = sty.clone(); + let measure_text = buf.clone(); + let content_h = { + let job = crate::styler::markdown_block_job( + &measure_text, + kind, + &sty_for_measure, + reveal_line, + max_width, + ); + ui.fonts_mut(|f| f.layout_job(job)).size().y + }; + let box_h = (content_h + 12.0).max(sty.body_size * 1.5); + let mut layouter = move |ui: &egui::Ui, + text: &dyn egui::TextBuffer, + wrap: f32| + -> std::sync::Arc { + let job = crate::styler::markdown_block_job( + text.as_str(), + kind, + &sty, + reveal_line, + wrap, + ); + ui.fonts_mut(|f| f.layout_job(job)) + }; + + // Explicit-size allocation: the surrounding + // paint layout is INLINE-FLOW (LTR + wrap), so an + // unconstrained multiline TextEdit would grab all + // remaining width x height as a narrow sliver. + // We pre-measured above; allocate exact height at + // full width and end the row. + let response = ui + .allocate_ui_with_layout( + egui::vec2(max_width, box_h), + egui::Layout::top_down_justified(egui::Align::LEFT), + |ui| { + egui::Frame::NONE + .inner_margin(egui::Margin::symmetric(6, 2)) + .show(ui, |ui| { + egui::TextEdit::multiline(&mut buf) + .id(editor_id) + .layouter(&mut layouter) + .desired_width(max_width) + .min_size(egui::vec2( + max_width, + content_h + 4.0, + )) + .show(ui) + }) + .inner + }, + ) + .inner; + ui.end_row(); + + // Typography rhythm matching the rendered view: + // heading above/below + paragraph spacing. + { + let body_h = sty_for_measure.body_size; + match kind { + crate::styler::EditBlockKind::Heading(l) => { + let below = body_h + * sty_for_measure + .heading_below_scale + [(l as usize - 1).min(5)]; + ui.add_space(below); + } + crate::styler::EditBlockKind::Paragraph => { + ui.add_space(sty_for_measure.paragraph_gap); + } + _ => {} + } + } + if crate::misc::edit_debug() { + eprintln!( + "[session] painted blk#{bi} kind={kind:?} rect={:?} chars={}", + response.response.rect, + buf.chars().count() + ); + } + + // Persist caret char index for next frame's reveal. + if let Some(cr) = response.state.cursor.char_range() { + let idx = cr.primary.index.min(buf.chars().count()); + ui.ctx().data_mut(|d| { + d.insert_temp(editor_id.with("caret"), idx) + }); + } + ui.ctx() + .data_mut(|d| d.insert_temp(editor_id, buf.clone())); + session_fb.entry(session.id_salt).or_default().push( + crate::misc::SessionBlockFeedback { + index: bi, + text: buf, + changed: response.response.changed(), + }, + ); + + // Consume remaining events of this block. + while let Some((_, (_, sp_next))) = + events.next_if(|&(_, ref pair)| { + pair.1.start >= blk.src.start + && pair.1.end <= blk.src.end + }) + { + let _ = sp_next; + } + continue; + } + } + } + // Add a viewport-skip waypoint at every block-level end (not // just list-internal ends as the original code did). Without // this, docs whose content is mostly headings + paragraphs @@ -1754,6 +1940,11 @@ impl CommonMarkViewerInternal { let scroll_cache = scroll_cache(cache, &source_id); let end_position = ui.next_widget_position(); + // Split points and boundaries dedupe independently: + // record=false frames (Rendered mode) still fill + // split_points, so a later Live frame must not skip + // boundary recording just because the split point + // already exists. let split_index = if is_atomic_table { events .peek() @@ -1784,6 +1975,38 @@ impl CommonMarkViewerInternal { relative_end, )); } + + if options.record_block_layout { + // Record RAW screen y every frame (bootstrap full + // paint guarantees each boundary is visited); + // show_scrollable converts to content space after + // the ScrollArea reports viewport top + scroll. + let existing = scroll_cache + .boundaries + .iter_mut() + .find(|b| b.event_index == index); + match existing { + Some(b) => b.top_y_raw = end_position.y, + None => { + scroll_cache + .boundaries + .push(crate::misc::BlockBoundary { + event_index: index, + top_y_raw: end_position.y, + top_y: f32::NAN, // converted post-paint + next_start: src_span_end, + }); + if crate::misc::edit_debug() { + eprintln!( + "[mdv-fork] boundary pushed idx={index} y={:.1} next={} total={}", + end_position.y, + src_span_end, + scroll_cache.boundaries.len() + ); + } + } + } + } } } @@ -1803,6 +2026,21 @@ impl CommonMarkViewerInternal { left_offset: content_origin_x - scroll_area_left, }); } + // Flush persistent-session feedback collected this frame. + for (salt, mut fb) in session_fb { + if crate::misc::edit_debug() && std::env::var("MDV_PROBE").is_ok() { + eprintln!( + "[flush] salt_changed={:?} n={}", + fb.iter().map(|f| (f.index, f.changed)).collect::>(), + fb.len() + ); + } + // `changed` must survive the stash: the app folds buffers + // only for entries where it is true (the frame the keystroke + // landed). Entries are rebuilt from `response.changed()` + // every frame, so no stale true can leak across frames. + cache.stash_session_feedback(&salt, fb); + } }); (re, std::mem::take(&mut self.checkbox_events)) @@ -1863,6 +2101,7 @@ impl CommonMarkViewerInternal { // post-change frame falls into the bootstrap branch below. sc.page_size = None; sc.split_points.clear(); + sc.boundaries.clear(); } // Width/zoom/theme change: y-coordinates are invalid for the // new layout, even though parsed events are still good. @@ -1871,6 +2110,7 @@ impl CommonMarkViewerInternal { sc.layout_signature = layout_sig; sc.page_size = None; sc.split_points.clear(); + sc.boundaries.clear(); sc.available_size = available_size; } if sc.layout_revision != layout_revision { @@ -1878,6 +2118,17 @@ impl CommonMarkViewerInternal { sc.layout_revision = layout_revision; sc.page_size = None; sc.split_points.clear(); + sc.boundaries.clear(); + } + if crate::misc::edit_debug() { + eprintln!( + "[mdv-fork] frame: record={} boundaries={} splits={} events={} version={}", + options.record_block_layout, + sc.boundaries.len(), + sc.split_points.len(), + sc.events.len(), + sc.content_version + ); } // An unknown navigation target may require painting every event // so its precise position can be measured. Scrolling to an @@ -1889,7 +2140,17 @@ impl CommonMarkViewerInternal { // to paint every event for the jump, not to recompute geometry. // The push site deduplicates by event index, so the full render // can still refresh page_size without rebuilding the split list. - if force_full_render { + // Editing modes must paint every event each frame: session + // feedback folds only blocks that actually paint, and boundary + // recording has to cover the whole document for click-to-edit + // hit-testing. A record_block_layout frame backfills ALL + // boundaries after Rendered-mode slice frames visited only part + // of the document. Re-bootstrap on those frames; plain rendered + // frames keep the viewport-slice fast path. + let editing_frame = options.edit_session.is_some() + || options.edit_region.is_some() + || options.record_block_layout; + if force_full_render || editing_frame { sc.page_size = None; } } @@ -1930,8 +2191,11 @@ impl CommonMarkViewerInternal { page_size.y = out.content_size.y; } sc.available_size = available_size; + convert_boundaries_to_content_space(&mut sc.boundaries, &out); return out; } + // Kept for future restoration once skip-paint is bug-free. + #[allow(unreachable_code)] let page_size_opt = scroll_cache(cache, &source_id).page_size; let Some(page_size) = page_size_opt else { unreachable!() diff --git a/crates/egui_commonmark/egui_commonmark/tests/live_preview.rs b/crates/egui_commonmark/egui_commonmark/tests/live_preview.rs new file mode 100644 index 0000000..4d34e58 --- /dev/null +++ b/crates/egui_commonmark/egui_commonmark/tests/live_preview.rs @@ -0,0 +1,338 @@ +//! Headless end-to-end verification of live-preview editing: +//! paint → record boundaries → simulated click → inline editor appears → +//! simulated keystroke → feedback reports changed text. +//! +//! Runs entirely on CPU via `Context::run` — no window system needed. + +use std::cell::RefCell; + +use egui::{ + scroll_area::ScrollSource, CentralPanel, Context, Event, PointerButton, Pos2, RawInput, Rect, + Vec2, +}; +use egui_commonmark_extended::{ + CommonMarkCache, CommonMarkViewer, EditFeedback, EditRegionConfig, EditSessionConfig, + SessionBlockFeedback, +}; + +const MARKDOWN: &str = + "# Heading One\n\nFirst paragraph to click.\n\n## Heading Two\n\nSecond paragraph here.\n"; + +#[derive(Clone, Copy)] +struct FrameGeom { + inner_min_y: f32, + scroll_offset_y: f32, +} + +/// Paint one frame of the document through `show_scrollable`, exactly the way +/// `render_tab_content` configures it in Live mode. Returns viewport geometry +/// plus the inline-editor feedback stashed by this frame, if any. +fn run_frame( + ctx: &Context, + markdown: &str, + cache: &RefCell, + edit_region: Option, + record_layout: bool, + events: Vec, +) -> (FrameGeom, Option) { + let geom = std::cell::Cell::new(FrameGeom { + inner_min_y: 0.0, + scroll_offset_y: 0.0, + }); + let feedback = std::cell::Cell::new(None); + let source_id = egui::Id::new("test-doc"); + + ctx.run( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + events, + ..Default::default() + }, + |ctx| { + let mut cache = cache.borrow_mut(); + let mut viewer = CommonMarkViewer::new() + .record_block_layout(record_layout) + .scroll_source(ScrollSource { + scroll_bar: true, + drag: false, + mouse_wheel: true, + }); + if let Some(cfg) = edit_region.clone() { + viewer = viewer.edit_region(Some(cfg)); + } + CentralPanel::default().show(ctx, |ui| { + let out = viewer.show_scrollable(source_id, ui, &mut cache, markdown); + geom.set(FrameGeom { + inner_min_y: out.inner_rect.min.y, + scroll_offset_y: out.state.offset.y, + }); + }); + feedback.set(cache.take_edit_feedback()); + }, + ); + (geom.get(), feedback.into_inner()) +} + +/// Paint one Live-mode frame through `show_scrollable` with the given +/// persistent-editing session. Returns viewport geometry plus the per-block +/// feedback stashed by this frame. +fn run_session_frame( + ctx: &Context, + markdown: &str, + cache: &RefCell, + session: Option, + events: Vec, +) -> (FrameGeom, Option>) { + let geom = std::cell::Cell::new(FrameGeom { + inner_min_y: 0.0, + scroll_offset_y: 0.0, + }); + let feedback = std::cell::Cell::new(None); + let source_id = egui::Id::new("test-doc"); + + ctx.run( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + events, + ..Default::default() + }, + |ctx| { + let mut cache = cache.borrow_mut(); + let mut viewer = CommonMarkViewer::new() + .record_block_layout(true) + .scroll_source(ScrollSource { + scroll_bar: true, + drag: false, + mouse_wheel: true, + }); + if let Some(cfg) = &session { + viewer = viewer.edit_session(Some(cfg.clone())); + } + CentralPanel::default().show(ctx, |ui| { + let out = viewer.show_scrollable(source_id, ui, &mut cache, markdown); + geom.set(FrameGeom { + inner_min_y: out.inner_rect.min.y, + scroll_offset_y: out.state.offset.y, + }); + }); + let salt = session.as_ref().map(|s| s.id_salt); + let fb = salt.map(|salt| cache.take_session_feedback(&salt)); + feedback.set(fb); + }, + ); + (geom.get(), feedback.into_inner()) +} + +/// Screen-space position of the vertical middle of block `index`, found by +/// probing the hit-tester (same mapping the app performs for real clicks). +fn screen_pos_of_block( + ctx: &Context, + cache: &RefCell, + id: egui::Id, + index: usize, + geom: FrameGeom, +) -> Pos2 { + let target_start = cache + .borrow_mut() + .top_level_block_spans(&id)[index] + .start; + for step in 0..2000u32 { + let content_y = step as f32 * 4.0; + let hit = cache + .borrow_mut() + .block_span_at_content_y(&id, content_y) + .map(|s| s.start == target_start) + .unwrap_or(false); + if hit { + return Pos2::new( + 400.0, + geom.inner_min_y + content_y - geom.scroll_offset_y, + ); + } + } + panic!("no y resolves to block {index}"); +} + +/// Session-based port of the original click→activate→type story: a click +/// resolves to the clicked paragraph via the recorded boundaries, the +/// per-block session editor receives a keystroke, and the feedback reports +/// exactly that block as changed. (The legacy `edit_region` single-editor +/// option never had a feedback producer and is superseded by sessions.) +#[test] +fn live_session_click_types_into_block() { + use egui_commonmark_extended::{EditBlockKind, SessionBlock}; + + let ctx = Context::default(); + let cache = RefCell::new(CommonMarkCache::default()); + let doc_id = egui::Id::new("test-doc"); + let salt = doc_id.with("pse"); + + // Frame 1: Rendered-style paint records block layout. + let (geom, _) = run_frame(&ctx, MARKDOWN, &cache, None, true, vec![]); + let spans = cache.borrow_mut().top_level_block_spans(&doc_id); + assert_eq!(spans.len(), 4, "h1, para, h2, para: {spans:?}"); + + // Build the session the way the app seeds it: one block per span, kind + // sniffed from the first line. + let blocks: Vec = spans + .iter() + .enumerate() + .map(|(_, span)| SessionBlock { + src: span.clone(), + kind: if MARKDOWN[span.clone()].starts_with("# ") { + EditBlockKind::Heading(1) + } else if MARKDOWN[span.clone()].starts_with("## ") { + EditBlockKind::Heading(2) + } else { + EditBlockKind::Paragraph + }, + }) + .collect(); + assert_eq!(blocks[0].kind, EditBlockKind::Heading(1)); + assert_eq!(blocks[1].kind, EditBlockKind::Paragraph); + let session = Some(EditSessionConfig { + id_salt: salt, + blocks, + }); + + // Frame 2: Live paint. All four text blocks seed; nothing changed yet. + let (_, fb) = run_session_frame(&ctx, MARKDOWN, &cache, session.clone(), vec![]); + let fb = fb.expect("session feedback stashed on the first Live frame"); + assert_eq!(fb.len(), 4, "h1, para, h2, para paint editors: {fb:?}"); + assert!(fb.iter().all(|f| !f.changed), "seed frame is not a change"); + + // Click the FIRST PARAGRAPH (block index 1) — same math as the app: + // content_y = pointer.y - inner_rect.min.y + scroll_offset_y. + let click_screen = screen_pos_of_block(&ctx, &cache, doc_id, 1, geom); + let content_y = click_screen.y - geom.inner_min_y + geom.scroll_offset_y; + let hit = cache + .borrow_mut() + .block_span_at_content_y(&doc_id, content_y) + .expect("click maps to a block"); + assert_eq!(hit, spans[1], "click resolves to the clicked paragraph"); + drop(spans); + + // Frame 3: focus the clicked block's editor the way a real click would. + let _ = run_session_frame(&ctx, MARKDOWN, &cache, session.clone(), vec![]); + ctx.memory_mut(|mem| mem.request_focus(salt.with(("blk", 1)))); + + // Frame 4: keystroke lands in the focused block editor only. + let (_, fb) = run_session_frame( + &ctx, + MARKDOWN, + &cache, + session.clone(), + vec![Event::Text("Z".into())], + ); + let fb = fb.expect("editors still painted"); + eprintln!("DEBUG fb={fb:?}"); + let typed = fb + .iter() + .find(|f| f.index == 1) + .expect("clicked block reported"); + assert!(typed.changed, "keystroke reported as change"); + assert!(typed.text.contains('Z'), "keystroke reached buffer: {typed:?}"); + assert!( + fb.iter().filter(|f| f.index != 1).all(|f| !f.changed), + "other blocks untouched: {fb:?}" + ); + + // The working buffer diverged from the source slice — the app would now + // splice `typed.text` back into its String. + let edited = ctx + .data_mut(|d| d.get_temp::(salt.with(("blk", 1)))) + .unwrap(); + assert_ne!(edited, MARKDOWN[hit.clone()], "buffer diverged from disk"); +} + +/// Regression: the app passes an arbitrary pre-built `egui::Id` as the +/// source id. `show_scrollable` wraps it via `Id::new(source_id)` for cache +/// keying — the public accessors must apply the identical wrapping or they +/// read a different, always-empty entry (clicks then never resolve). +#[test] +fn accessors_match_show_scrollable_keying_for_arbitrary_ids() { + let ctx = Context::default(); + // Deliberately NOT derived from a &str the way the other test does: + // this is exactly how src/main.rs builds Tab::id from a PathBuf. + let tab_id = egui::Id::new(std::path::PathBuf::from("/some/absolute/note.md")); + let cache = RefCell::new(CommonMarkCache::default()); + + run_frame_with_source_id(&ctx, MARKDOWN, &cache, tab_id); + + assert!( + cache.borrow_mut().has_block_layout(&tab_id), + "accessors must see boundaries recorded under show_scrollable's wrapped key" + ); + let spans = cache.borrow_mut().top_level_block_spans(&tab_id); + assert_eq!(spans.len(), 4); +} + +fn run_frame_with_source_id( + ctx: &Context, + markdown: &str, + cache: &RefCell, + source_id: egui::Id, +) { + run_frame_with_source_id_record(ctx, markdown, cache, source_id, true) +} + +fn run_frame_with_source_id_record( + ctx: &Context, + markdown: &str, + cache: &RefCell, + source_id: egui::Id, + record: bool, +) { + ctx.run( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + ..Default::default() + }, + |ctx| { + let mut cache = cache.borrow_mut(); + CentralPanel::default().show(ctx, |ui| { + CommonMarkViewer::new() + .record_block_layout(record) + .show_scrollable(source_id, ui, &mut cache, markdown); + }); + }, + ); +} + +/// Regression for the mode-transition bug: Rendered-mode frames +/// (`record=false`) fill split_points; when the app switches to Live +/// (`record=true`) every split point already exists, which used to skip +/// boundary recording entirely — clicks then never resolved. Boundaries must +/// dedupe independently and backfill on the first recorded frame. +#[test] +fn boundaries_backfill_after_switching_to_live() { + let ctx = Context::default(); + let tab_id = egui::Id::new(std::path::PathBuf::from("/mode/switch/note.md")); + let cache = RefCell::new(CommonMarkCache::default()); + + // Rendered phase: no boundary recording requested yet. + for _ in 0..2 { + run_frame_with_source_id_record(&ctx, MARKDOWN, &cache, tab_id, false); + } + assert!( + !cache.borrow_mut().has_block_layout(&tab_id), + "no boundaries while recording is off" + ); + + // Switch to Live: first recorded frame must backfill ALL boundaries. + run_frame_with_source_id_record(&ctx, MARKDOWN, &cache, tab_id, true); + + assert!( + cache.borrow_mut().has_block_layout(&tab_id), + "boundaries must appear on the first Live frame" + ); + let spans = cache.borrow_mut().top_level_block_spans(&tab_id); + assert_eq!(spans.len(), 4); + + // And the click path resolves against them immediately. + let mid = cache + .borrow_mut() + .block_span_at_content_y(&tab_id, 10.0) + .expect("hit-test works right after switch"); + assert_eq!(mid.start, spans[0].start, "topmost click hits first block"); +} diff --git a/crates/egui_commonmark/egui_commonmark/tests/live_session.rs b/crates/egui_commonmark/egui_commonmark/tests/live_session.rs new file mode 100644 index 0000000..0c8a6cb --- /dev/null +++ b/crates/egui_commonmark/egui_commonmark/tests/live_session.rs @@ -0,0 +1,156 @@ +//! Headless verification of persistent-editing sessions: every text block +//! paints as its own styled editor, buffers round-trip through feedback, +//! keystrokes land in the focused block only. + +use std::cell::RefCell; + +use egui::{CentralPanel, Context, Event, Pos2, RawInput, Rect, Vec2}; +use egui_commonmark_extended::{ + CommonMarkCache, CommonMarkViewer, EditBlockKind, EditSessionConfig, SessionBlock, +}; + +const MARKDOWN: &str = "# Heading One\n\nFirst paragraph.\n\n## Heading Two\n\nSecond paragraph.\n"; + +fn kinds_for(spans: &[std::ops::Range]) -> Vec { + spans + .iter() + .map(|r| { + if MARKDOWN[r.clone()].starts_with('#') { + EditBlockKind::Heading(1) + } else { + EditBlockKind::Paragraph + } + }) + .collect() +} + +fn run_frame(ctx: &Context, markdown: &str, cache: &RefCell, salt: egui::Id) { + let mut cache = cache.borrow_mut(); + let spans = cache.top_level_block_spans(&salt); + let blocks: Vec = spans + .iter() + .enumerate() + .map(|(i, r)| SessionBlock { + src: r.clone(), + kind: kinds_for(&spans)[i], + }) + .collect(); + let session = if blocks.is_empty() { + None + } else { + Some(EditSessionConfig { + id_salt: salt, + blocks, + }) + }; + + ctx.run( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + ..Default::default() + }, + |ctx| { + CentralPanel::default().show(ctx, |ui| { + let viewer = CommonMarkViewer::new().edit_session(session.clone()); + viewer.show_scrollable(salt, ui, &mut cache, MARKDOWN); + }); + }, + ); +} + +#[test] +fn session_paints_editors_and_routes_keystrokes() { + let ctx = Context::default(); + let cache = RefCell::new(CommonMarkCache::default()); + let salt = egui::Id::new("sess"); + + // Frame 1..2: seed segmentation. + run_frame(&ctx, MARKDOWN, &cache, salt); + run_frame(&ctx, MARKDOWN, &cache, salt); + + { + let mut c = cache.borrow_mut(); + let spans_len = c.top_level_block_spans(&salt).len(); + let bounds_len = c.block_bounds(&salt).len(); + eprintln!("PROBE spans={spans_len} bounds={bounds_len}"); + let fb_probe = c.get_session_feedback(&salt); + eprintln!("PROBE fb={} idxs={:?}", + fb_probe.len(), + fb_probe.iter().map(|f| f.index).collect::>()); + // re-stash so subsequent assertions still see them + c.stash_session_feedback(&salt, fb_probe); + } + let fb = { cache.borrow().get_session_feedback(&salt) }; + assert_eq!(fb.len(), 4, "four text editors painted"); + assert_eq!( + cache.borrow_mut().top_level_block_spans(&salt).len(), + 4, + "segmentation intact" + ); + + // Focus the SECOND block's editor and type into it. + let target = salt.with(("blk", 1usize)); + ctx.memory_mut(|mem| mem.request_focus(target)); + run_frame(&ctx, MARKDOWN, &cache, salt); + // Buffers are canonical while Live: block 1 holds the typed text, + // others their originals. + let fb = run_frame_typed(&ctx, &cache, salt, "ZZ"); + assert_eq!(fb.len(), 4, "four editors painted"); + assert!( + fb.iter().find(|f| f.index == 1).unwrap().text.ends_with("ZZ"), + "block 1 buffer: {:?}", + fb.iter().find(|f| f.index == 1).unwrap().text + ); + for f in &fb { + eprintln!("FB[{}] = {:?}", f.index, f.text); + } + assert!( + fb.iter() + .find(|f| f.index == 0) + .unwrap() + .text + .contains("Heading One") + ); +} + +fn run_frame_typed( + ctx: &Context, + cache: &RefCell, + salt: egui::Id, + s: &str, +) -> Vec { + let out = std::cell::RefCell::new(Vec::new()); + ctx.run( + RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, Vec2::new(800.0, 600.0))), + events: vec![Event::Text(s.into())], + ..Default::default() + }, + |ctx| { + let mut c = cache.borrow_mut(); + let spans_probe = c.top_level_block_spans(&salt); + eprintln!("TYPED-FRAME spans={}", spans_probe.len()); + CentralPanel::default().show(ctx, |ui| { + // rebuild session like run_frame + let spans = c.top_level_block_spans(&salt); + let blocks: Vec = spans + .iter() + .enumerate() + .map(|(i, r)| SessionBlock { + src: r.clone(), + kind: kinds_for(&spans)[i], + }) + .collect(); + let viewer = CommonMarkViewer::new().edit_session(Some(EditSessionConfig { + id_salt: salt, + blocks, + })); + viewer.show_scrollable(salt, ui, &mut c, MARKDOWN); + }); + let got = c.take_session_feedback(&salt); + eprintln!("TYPED-CLOSE take={}", got.len()); + *out.borrow_mut() = got; + }, + ); + out.into_inner() +} diff --git a/crates/egui_commonmark/egui_commonmark_backend/src/lib.rs b/crates/egui_commonmark/egui_commonmark_backend/src/lib.rs index c53c9e5..c4e9eba 100644 --- a/crates/egui_commonmark/egui_commonmark_backend/src/lib.rs +++ b/crates/egui_commonmark/egui_commonmark_backend/src/lib.rs @@ -12,6 +12,7 @@ pub mod html_table; pub mod misc; #[doc(hidden)] pub mod pulldown; +pub mod styler; pub mod typography; #[cfg(feature = "embedded_image")] diff --git a/crates/egui_commonmark/egui_commonmark_backend/src/misc.rs b/crates/egui_commonmark/egui_commonmark_backend/src/misc.rs index 31242ea..80570b1 100644 --- a/crates/egui_commonmark/egui_commonmark_backend/src/misc.rs +++ b/crates/egui_commonmark/egui_commonmark_backend/src/misc.rs @@ -2,6 +2,7 @@ use crate::alerts::AlertBundle; use crate::typography::TypographyConfig; use egui::{RichText, TextStyle, Ui, text::LayoutJob}; use std::collections::HashMap; +use std::ops::Range; #[cfg(feature = "math")] use std::collections::HashSet; #[cfg(any( @@ -68,6 +69,26 @@ pub struct CommonMarkOptions<'f> { /// Opt into using the named strong font family. Callers must register /// `STRONG_FONT_FAMILY` in egui before enabling this to avoid lookup panics. pub use_strong_font_family: bool, + /// Live-preview editing: when set, events fully inside this source byte + /// range are not painted; an inline multiline TextEdit is rendered in + /// their place instead. Its working buffer lives in egui temp state under + /// [`EditRegionConfig::id`], so keystrokes persist across frames without + /// the caller re-lending the source string; the caller collects the + /// current text each frame via [`CommonMarkCache::take_edit_feedback`] + /// and splices it back into its own buffer. + /// + /// Contract: the range must align to top-level block boundaries (as + /// produced by [`top_level_block_spans`] on the same content), because + /// skipped events bypass the renderer's list/table/blockquote state + /// machine — only complete top-level subtrees are safe to skip. + pub edit_region: Option, + /// Persistent editing session (supersedes `edit_region` when set). + pub edit_session: Option, + /// Record the y position of every safe block boundary while painting + /// (next to `split_points`). Callers combine this with + /// [`top_level_block_spans`] to hit-test clicks into byte ranges for + /// click-to-edit activation. + pub record_block_layout: bool, /// Opt into parsing a leading `---` block as YAML frontmatter and rendering /// it as a key/value table. Off by default: enabling it changes how a /// document that starts with `---` is *parsed*, not just painted, so it @@ -83,6 +104,108 @@ pub struct CommonMarkOptions<'f> { pub math_scale: f32, } +/// Configuration for the inline editor painted by the live-preview mode. +#[derive(Clone, Debug)] +pub struct EditRegionConfig { + /// Source byte range replaced by the editor. + pub src: Range, + /// egui id owning the editor's working text buffer (egui temp state). + pub id: egui::Id, + /// Block kind — drives the styled layouter (heading scale, marker + /// blanking, inline run styling). + pub kind: crate::styler::EditBlockKind, +} + +/// Persistent-editing session: every text block paints as a styled TextEdit; +/// non-text blocks (images, math, mermaid, tables, code fences) render +/// normally. Buffers live in egui temp state under `/blk/`. +#[derive(Clone, Debug)] +pub struct EditSessionConfig { + /// Namespace for per-block editor ids and state. + pub id_salt: egui::Id, + /// One entry per top-level block, in document order. + pub blocks: Vec, +} + +/// A single session block. +#[derive(Clone, Debug)] +pub struct SessionBlock { + /// Source byte range of the block (whitespace-tiled). + pub src: Range, + /// Kind drives styling and marker stripping. + pub kind: crate::styler::EditBlockKind, +} + +/// Result of one painted session block. +#[derive(Clone, Debug)] +pub struct SessionBlockFeedback { + /// Index into [`EditSessionConfig::blocks`]. + pub index: usize, + /// Current buffer text (materialized markdown NOT included). + pub text: String, + /// Buffer changed since previous frame. + pub changed: bool, +} + +/// One frame's inline-editor result, stashed by the renderer and collected by +/// the caller via [`CommonMarkCache::take_edit_feedback`]. +#[derive(Clone, Debug)] +pub struct EditFeedback { + /// Current contents of the inline editor buffer. + pub text: String, + /// True when the buffer changed since the previous frame. + pub changed: bool, +} + +/// Cheap opt-in stderr tracing of the live-edit plumbing (`MDV_EDIT_DEBUG=1`). +pub fn edit_debug() -> bool { + static ENABLED: std::sync::OnceLock = std::sync::OnceLock::new(); + *ENABLED.get_or_init(|| std::env::var_os("MDV_EDIT_DEBUG").is_some()) +} + +/// Byte range + painted y of one block boundary, recorded when +/// [`CommonMarkOptions::record_block_layout`] is on. `top_y` is the content- +/// relative y at which the *following* block starts; `next_start` is the byte +/// offset just after the boundary event. `event_index` identifies the boundary +/// event so recording stays idempotent across frames even when split-point +/// recording was off earlier (e.g. Rendered-mode frames before switching to +/// Live). +#[derive(Clone, Debug)] +pub struct BlockBoundary { + pub event_index: usize, + /// RAW screen y recorded during the bootstrap full paint (refreshed every + /// frame). Converted into content space by `show_scrollable` after the + /// ScrollArea reports its viewport top + scroll offset; hit-testing reads + /// only the converted [`Self::top_y`]. + pub top_y_raw: f32, + /// Content-space y where the following block starts. + pub top_y: f32, + pub next_start: usize, +} + +/// Convert freshly recorded raw screen ys into stable content-space ys using +/// this frame's own viewport geometry — self-consistent regardless of scroll +/// position or panel chrome, because both sides of the subtraction come from +/// the same frame. +pub fn convert_boundaries_to_content_space( + boundaries: &mut [BlockBoundary], + out: &egui::scroll_area::ScrollAreaOutput<()>, +) { + // Raw ys are SCREEN space and already embed scrolling; content space is + // just "relative to the viewport top". Do NOT add state.offset here — + // that double-counts scroll and inflates every boundary. + let viewport_top = out.inner_rect.min.y; + for b in boundaries.iter_mut() { + b.top_y = b.top_y_raw - viewport_top; + } + if crate::misc::edit_debug() && !boundaries.is_empty() { + eprintln!( + "[mdv-fork] converted: vp_top={viewport_top:.1} tops={:?}", + boundaries.iter().map(|b| b.top_y as i32).collect::>() + ); + } +} + impl std::fmt::Debug for CommonMarkOptions<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let mut s = f.debug_struct("CommonMarkOptions"); @@ -106,6 +229,9 @@ impl std::fmt::Debug for CommonMarkOptions<'_> { .field("mutable", &self.mutable) .field("typography", &self.typography) .field("use_strong_font_family", &self.use_strong_font_family) + .field("edit_region", &self.edit_region) + .field("edit_session", &self.edit_session) + .field("record_block_layout", &self.record_block_layout) .field("render_frontmatter", &self.render_frontmatter) .field("math_scale", &self.math_scale) .finish() @@ -132,6 +258,9 @@ impl Default for CommonMarkOptions<'_> { html_fn: None, typography: TypographyConfig::default(), use_strong_font_family: false, + edit_region: None, + edit_session: None, + record_block_layout: false, render_frontmatter: false, math_scale: 1.0, } @@ -2232,11 +2361,18 @@ pub struct CommonMarkCache { /// Current scroll offset, set before rendering to calculate content-relative positions. current_scroll_offset: f32, - /// Byte ranges of search matches in the source content. Renderer paints a background - /// color on overlapping text events. Sorted ascending by start. + /// Byte ranges of search matches in the source content. Renderer paints a + /// background color on overlapping text events. Sorted ascending by start. search_ranges: Vec>, /// Active match — gets a stronger highlight color. active_search_range: Option>, + /// Inline-editor result stashed during the last paint; collected via + /// [`CommonMarkCache::take_edit_feedback`]. + edit_feedback: Option, + /// Last painted inline-editor rect per document id (click calibration). + editor_rects: HashMap, + /// Per-frame persistent-session feedback, keyed by salt id. + session_feedback: HashMap>, /// Content-relative y position recorded by the renderer when the active match /// is painted. Used by the app for precise scroll-into-view, since line-ratio /// estimates are unreliable in image-heavy documents. @@ -2338,6 +2474,9 @@ impl Default for CommonMarkCache { search_ranges: Vec::new(), active_search_range: None, active_search_y: None, + edit_feedback: None, + editor_rects: HashMap::new(), + session_feedback: HashMap::new(), layout_revision: 0, #[cfg(feature = "mermaid")] mermaid_states: HashMap::new(), @@ -2669,6 +2808,212 @@ impl CommonMarkCache { pub fn set_cached_events(&mut self, content_hash: u64, events: Vec<(pulldown_cmark::Event<'static>, std::ops::Range)>) { self.cached_events = Some((content_hash, events)); } + + /// Take the inline-editor feedback stashed by the most recent paint, if + /// any. The stash is cleared by the call. + pub fn take_edit_feedback(&mut self) -> Option { + self.edit_feedback.take() + } + + /// Stash inline-editor feedback for this frame (renderer-internal use; + /// callers read it back with [`Self::take_edit_feedback`]). + pub fn stash_edit_feedback(&mut self, feedback: EditFeedback) { + self.edit_feedback = Some(feedback); + } + + /// Byte ranges of the top-level blocks of the last content parsed for + /// `source_id`, derived from the cached event spans. Ranges are extended + /// over inter-block whitespace so consecutive blocks tile the source + /// exactly (see [`top_level_block_spans`]). Returns an empty vec before + /// the first paint or in non-scrollable mode. + /// + /// Note: `show_scrollable` keys its per-document cache under + /// `Id::new(source_id)` (a hash of whatever the caller passed). These + /// accessors must replicate that wrapping or they will read a different, + /// always-empty entry — pass exactly what you gave `show_scrollable`. + pub fn top_level_block_spans(&mut self, source_id: &egui::Id) -> Vec> { + let key = egui::Id::new(source_id); + let sc = scroll_cache(self, &key); + top_level_block_spans(&sc.events) + } + + /// True once block boundaries have been recorded for `source_id` + /// (`record_block_layout(true)` + at least one paint). Until then, + /// click hit-testing is not possible. + pub fn has_block_layout(&mut self, source_id: &egui::Id) -> bool { + let key = egui::Id::new(source_id); + !scroll_cache(self, &key).boundaries.is_empty() + } + + /// Snapshot of recorded boundaries as `(content_top_y, next_start)`, + /// sorted by y. Used by the app for self-calibrated hit-testing. + pub fn block_bounds(&mut self, source_id: &egui::Id) -> Vec<(f32, usize)> { + let key = egui::Id::new(source_id); + let mut v: Vec<_> = scroll_cache(self, &key) + .boundaries + .iter() + .map(|b| (b.top_y, b.next_start)) + .collect(); + v.sort_by(|a, b| a.0.total_cmp(&b.0)); + v + } + + /// Replace this frame's session feedback for `salt`. + pub fn stash_session_feedback( + &mut self, + salt: &egui::Id, + feedback: Vec, + ) { + self.session_feedback.insert(*salt, feedback); + } + + /// Take session feedback accumulated during the last paint + /// (destructive; prefer [`Self::get_session_feedback`] with multi-pass + /// egui layouts where the closure runs more than once). + pub fn take_session_feedback( + &mut self, + salt: &egui::Id, + ) -> Vec { + self.session_feedback.remove(salt).unwrap_or_default() + } + + /// Non-destructive snapshot of the latest session feedback. + pub fn get_session_feedback( + &self, + salt: &egui::Id, + ) -> Vec { + let v = self + .session_feedback + .get(salt) + .cloned() + .unwrap_or_default(); + if crate::misc::edit_debug() { + eprintln!("[get_fb] salt={salt:?} n={} keys={:?}", v.len(), self.session_feedback.keys().collect::>()); + } + v + } + + /// Stash the painted inline-editor rect (click calibration anchor). + pub fn stash_editor_rect(&mut self, source_id: &egui::Id, rect: egui::Rect) { + self.editor_rects.insert(egui::Id::new(source_id), rect); + } + + /// The editor rectangle painted last frame for `source_id`, if any. + pub fn editor_rect(&self, source_id: &egui::Id) -> Option { + self.editor_rects.get(&egui::Id::new(source_id)).copied() + } + + /// Hit-test a content-relative y against the block boundaries recorded + /// during the last paint with `record_block_layout(true)`; returns the + /// tiled [`top_level_block_spans`] range of the clicked block. + pub fn block_span_at_content_y( + &mut self, + source_id: &egui::Id, + y: f32, + ) -> Option> { + let key = egui::Id::new(source_id); + let sc = scroll_cache(self, &key); + let spans = top_level_block_spans(&sc.events); + let last_end = spans.last()?.end; + // Boundary k sits between block k and block k+1: its top_y is where + // block k+1 starts painting, its next_start the byte just after the + // boundary event. The segment between consecutive cuts maps onto the + // first tiled span overlapping it — the clicked block. + // Number of cuts at/above the click == index of its segment. + let k = sc + .boundaries + .iter() + .filter(|b| b.top_y <= y + f32::EPSILON) + .count() + .min(spans.len().saturating_sub(1)); + let seg_start = if k == 0 { + spans[0].start + } else { + sc.boundaries[k - 1].next_start.min(last_end) + }; + let seg_end = sc + .boundaries + .get(k) + .map(|b| b.next_start.min(last_end)) + .unwrap_or(last_end); + spans + .iter() + .find(|s| s.start < seg_end && s.end > seg_start) + .cloned() + } +} + +/// Compute top-level (depth-0) block byte ranges from cached pulldown events. +/// +/// A block opens at the first `Start` seen at depth 0 and closes at its +/// matching container-level `End`; standalone top-level events (rules, …) are +/// their own blocks. Consecutive ranges are then extended forward over +/// whitespace so they tile the source without gaps — click-to-edit splices +/// therefore preserve the blank lines separating blocks. +pub fn top_level_block_spans( + events: &[(pulldown_cmark::Event<'static>, std::ops::Range)], +) -> Vec> { + use pulldown_cmark::{Event, TagEnd}; + + let is_top_level_end = |tag: &TagEnd| { + matches!( + tag, + TagEnd::Paragraph + | TagEnd::Heading(_) + | TagEnd::BlockQuote(_) + | TagEnd::CodeBlock + | TagEnd::List(_) + | TagEnd::FootnoteDefinition + | TagEnd::Table + | TagEnd::HtmlBlock + | TagEnd::MetadataBlock(_) + | TagEnd::DefinitionList + ) + }; + + let mut raw: Vec> = Vec::new(); + let mut depth: usize = 0; + let mut open_start: Option = None; + + for (event, span) in events { + match event { + Event::Start(_) => { + if depth == 0 { + open_start.get_or_insert(span.start); + } + depth += 1; + } + Event::End(tag_end) => { + depth = depth.saturating_sub(1); + if depth == 0 && is_top_level_end(tag_end) { + if let Some(start) = open_start.take() { + raw.push(start..span.end); + } + } + } + _ => { + // Standalone top-level events (Rule, inline math blocks…) + if depth == 0 && open_start.is_none() { + raw.push(span.clone()); + } + } + } + } + + // Extend each range forward over whitespace so blocks tile the source. + let mut out: Vec> = Vec::with_capacity(raw.len()); + for range in raw { + match out.last_mut() { + Some(prev) if range.start <= prev.end => prev.end = prev.end.max(range.end), + _ => out.push(range), + } + } + for i in 0..out.len() { + if let Some(next) = out.get(i + 1) { + out[i].end = next.start; + } + } + out } pub fn scroll_cache<'a>(cache: &'a mut CommonMarkCache, id: &egui::Id) -> &'a mut ScrollableCache { @@ -2696,3 +3041,55 @@ pub fn prepare_show(cache: &mut CommonMarkCache, ctx: &egui::Context) { cache.deactivate_link_hooks(); } + +#[cfg(test)] +mod editing_tests { + use super::top_level_block_spans; + use pulldown_cmark::{Options, Parser}; + + fn spans(src: &str) -> Vec> { + let events: Vec<_> = Parser::new_ext(src, Options::all()) + .into_offset_iter() + .map(|(e, r)| (e.into_static(), r)) + .collect(); + top_level_block_spans(&events) + } + + #[test] + fn tiles_headings_paragraphs_and_lists() { + let src = "# Title\n\ntext para\n\n- a\n- b\n"; + let out = spans(src); + assert_eq!(out.len(), 3, "heading, paragraph, list: {out:?}"); + // Ranges tile the whole source with no gaps. + assert_eq!(out.first().unwrap().start, 0); + let mut prev_end = 0; + for r in &out { + assert_eq!(r.start, prev_end, "gap before {r:?}"); + prev_end = r.end; + } + assert_eq!(prev_end, src.len()); + } + + #[test] + fn code_fence_is_one_block() { + let src = "before\n\n```rust\nlet x = 1;\n```\n\nafter\n"; + let out = spans(src); + assert_eq!(out.len(), 3, "{out:?}"); + let fence = &out[1]; + assert!(src[fence.clone()].starts_with("```")); + } + + #[test] + fn blockquote_and_table_stay_atomic() { + let src = "> quoted\n> lines\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + let out = spans(src); + assert_eq!(out.len(), 2, "{out:?}"); + assert!(src[out[0].clone()].starts_with('>')); + assert!(src[out[1].clone()].starts_with('|')); + } + + #[test] + fn empty_input_yields_no_blocks() { + assert!(spans("").is_empty()); + } +} diff --git a/crates/egui_commonmark/egui_commonmark_backend/src/pulldown.rs b/crates/egui_commonmark/egui_commonmark_backend/src/pulldown.rs index 9f760f4..b9e4975 100644 --- a/crates/egui_commonmark/egui_commonmark_backend/src/pulldown.rs +++ b/crates/egui_commonmark/egui_commonmark_backend/src/pulldown.rs @@ -8,6 +8,11 @@ pub struct ScrollableCache { pub available_size: Vec2, pub page_size: Option, pub split_points: Vec<(usize, Pos2, Pos2)>, + /// Block boundaries recorded when `CommonMarkOptions::record_block_layout` + /// is on: content-relative y where the following block starts, plus the + /// byte offset just after the boundary event. Parallel to the safe + /// split-point subset of blocks; used for click→block hit-testing. + pub boundaries: Vec, /// Parsed pulldown events, owned (Event<'static>) so they outlive the /// borrow of the source text. Repopulated only when `content_version` /// changes, replacing the per-frame `Parser::new_ext(text).collect()` diff --git a/crates/egui_commonmark/egui_commonmark_backend/src/styler.rs b/crates/egui_commonmark/egui_commonmark_backend/src/styler.rs new file mode 100644 index 0000000..ce87991 --- /dev/null +++ b/crates/egui_commonmark/egui_commonmark_backend/src/styler.rs @@ -0,0 +1,677 @@ +//! Styled layouter for live-preview editing: renders a markdown *block* +//! inside a stock [`egui::TextEdit`] so it looks like its rendered form. +//! +//! Core contract (mirrors egui's password masking): the produced job text has +//! exactly the same CHAR count as the input buffer — markup characters are +//! replaced with spaces, never dropped — so every `CCursor` index keeps +//! meaning the same character. Byte-level differences inside the job are fine; +//! sections are rebuilt over the transformed text. +//! +//! Styling rules: +//! - structural prefixes (`# `, `- `, `> `, `1. `) become blank glyphs +//! - `**strong**`, `*em*`, `` `code` `` runs are styled; their delimiters are +//! blanked +//! - `[label](url)` keeps the label styled as a link, blanks brackets/url +//! - headings scale/weight their text +//! - when `reveal_line` points at the caret's line, that line paints fully +//! raw (no blanks, base formatting) — the Obsidian Live Preview signature + +use egui::{text::LayoutJob, Color32, FontFamily, FontId, TextFormat}; +use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd}; +use std::ops::Range; + +/// What kind of top-level block the edited buffer represents. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EditBlockKind { + Heading(u8), + Paragraph, + Quote, + ListItem, + CodeBlock, +} + +/// Visual parameters for the styled editing surface. Build once per frame +/// from the active `Ui` via [`MarkdownEditStyle::from_ui`]. +#[derive(Clone, Debug)] +pub struct MarkdownEditStyle { + pub body_size: f32, + /// Line height applied to text rows (renderer default 1.5×). + pub line_height: Option, + /// Space inserted AFTER a paragraph block. + pub paragraph_gap: f32, + /// Space ABOVE / BELOW a heading of level l (1-based): scale lookup. + pub heading_above_scale: [f32; 6], + pub heading_below_scale: [f32; 6], + /// Multiplier applied to `body_size` per heading level (H1..H6). + pub heading_scale: [f32; 6], + /// Family used for strong/heading text (registered bold face). + pub strong_family: FontFamily, + pub body_family: FontFamily, + pub mono_family: FontFamily, + pub text_color: Color32, + pub marker_color: Color32, + pub code_bg: Color32, + pub link_color: Color32, +} + +impl MarkdownEditStyle { + pub fn from_ui(ui: &egui::Ui) -> Self { + let st = ui.style(); + let body = st + .text_styles + .get(&egui::TextStyle::Body) + .cloned() + .unwrap_or_else(|| egui::FontId::new(14.0, FontFamily::Proportional)); + let mono = st + .text_styles + .get(&egui::TextStyle::Monospace) + .cloned() + .unwrap_or_else(|| egui::FontId::new(13.0, FontFamily::Monospace)); + let v = ui.visuals(); + // Bold face when the app registered one; else fall back to body. + let strong = FontFamily::Name(crate::misc::STRONG_FONT_FAMILY.into()); + let strong_registered = + ui.fonts(|f| f.definitions().families.contains_key(&strong)); + Self { + body_size: body.size, + heading_scale: [2.0, 1.6, 1.25, 1.125, 1.0, 0.875], + line_height: Some(body.size * 1.5), + paragraph_gap: body.size * 0.75, + heading_above_scale: [1.125, 1.0, 0.875, 0.75, 0.5, 0.5], + heading_below_scale: [0.25, 0.25, 0.125, 0.125, 0.0, 0.0], + strong_family: if strong_registered { strong } else { body.family.clone() }, + body_family: body.family.clone(), + mono_family: mono.family, + text_color: v.widgets.inactive.fg_stroke.color, + marker_color: v.weak_text_color(), + code_bg: v.extreme_bg_color, + link_color: v.hyperlink_color, + } + } +} + +// Char-level decoration tags. Order matters only for grouping. +const T_BASE: u8 = 0; +const T_MARKER: u8 = 1; // blanked glyph +const T_STRONG: u8 = 2; +const T_EM: u8 = 3; +const T_CODE: u8 = 4; +const T_LINK: u8 = 5; + +struct LineInfo { + /// Byte offset where each line starts (first entry always 0). + starts: Vec, + /// 0-based line index for each char position. + line_of_char: Vec, +} + +fn line_info(chars: &[char]) -> LineInfo { + let mut starts = vec![0usize]; + let mut line_of_char = Vec::with_capacity(chars.len()); + let mut line = 0usize; + for &c in chars { + line_of_char.push(line); + if c == '\n' { + line += 1; + starts.push(0); // placeholder, fixed below + } + } + // Recompute byte starts precisely. + starts.clear(); + starts.push(0); + let mut byte = 0usize; + for &c in chars { + if c == '\n' { + starts.push(byte + 1); + } + byte += c.len_utf8(); + } + LineInfo { + starts, + line_of_char, + } +} + +/// Leading structural marker byte-range for one line, given block kind. +/// Returns None when the line has no structural prefix. +fn structural_prefix(text: &str, line_byte_start: usize, kind: EditBlockKind) -> Option> { + let rest = text.get(line_byte_start..)?; + let mut cols = rest.char_indices(); + // Skip leading spaces (list indentation). + let mut saw_space = false; + let mut pos = 0usize; + loop { + match cols.next() { + Some((i, ' ')) | Some((i, '\t')) => { + saw_space = true; + pos = i + 1; + } + _ => break, + } + } + let _ = saw_space; + let after_ws = pos; + let tail = &rest[after_ws..]; + let prefix_len = match kind { + EditBlockKind::Heading(level) => { + let hashes = tail.chars().take_while(|&c| c == '#').count(); + if hashes == 0 || hashes as u8 != level { + return None; + } + let after_hashes = tail[hashes..] + .chars() + .take_while(|&c| c == ' ') + .count(); + if after_hashes == 0 { + return None; + } + hashes + after_hashes + } + EditBlockKind::Quote => { + if tail.starts_with('>') { + let after_gt = tail[1..].chars().take_while(|&c| c == ' ').count() + 1; + after_gt + } else { + return None; + } + } + EditBlockKind::ListItem => { + let bytes = tail.as_bytes(); + if bytes.is_empty() { + return None; + } + let (marker_len, after) = match bytes[0] { + b'-' | b'*' | b'+' => (1usize, &tail[1..]), + _ => { + let digits = tail.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits == 0 || !tail[digits..].starts_with('.') { + return None; + } + (digits + 1, &tail[digits + 1..]) + } + }; + let spaces = after.chars().take_while(|&c| c == ' ').count(); + if spaces == 0 { + return None; + } + marker_len + spaces + } + _ => return None, + }; + Some(line_byte_start + after_ws..line_byte_start + after_ws + prefix_len) +} + +/// Build the styled layout job for one markdown block. +/// +/// Guarantees: `job.text.chars().count() == text.chars().count()`. +pub fn markdown_block_job( + text: &str, + kind: EditBlockKind, + style: &MarkdownEditStyle, + reveal_line: Option, + wrap_width: f32, +) -> LayoutJob { + let chars: Vec = text.chars().collect(); + if chars.is_empty() { + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + return job; + } + if matches!(kind, EditBlockKind::CodeBlock) { + // Code stays raw monospace — that IS its rendered form. + let font = FontId::new(style.body_size, style.mono_family.clone()); + let fmt = TextFormat { + font_id: font, + color: style.text_color, + ..Default::default() + }; + let mut job = LayoutJob::default(); + job.append(text, 0.0, fmt); + job.wrap.max_width = wrap_width; + return job; + } + + let li = line_info(&chars); + let mut tags = vec![T_BASE; chars.len()]; + let mut blank = vec![false; chars.len()]; + + // ---- structural markers ---- + let mut line_byte = 0usize; + let mut line_idx = 0usize; + for line in text.split_inclusive('\n') { + if reveal_line != Some(line_idx) { + if let Some(r) = structural_prefix(text, line_byte, kind) { + for bi in 0..r.len() { + if let Some(ci) = char_index_of(text, r.start + bi) { + blank[ci] = true; + tags[ci] = T_MARKER; + } + } + } + } + line_byte += line.len(); + line_idx += 1; + } + + // ---- inline formatting ---- + let mut containers: Vec<(u8, Range)> = Vec::new(); + + for (event, span) in Parser::new_ext(text, Options::all()).into_offset_iter() { + match event { + Event::Start(Tag::Strong) => containers.push((T_STRONG, span)), + Event::Start(Tag::Emphasis) => containers.push((T_EM, span)), + Event::Start(Tag::Link { .. }) => containers.push((T_LINK, span)), + Event::Start(_) => {} + Event::End(end) => { + let popped = match end { + TagEnd::Strong => containers.pop().filter(|(t, _)| *t == T_STRONG), + TagEnd::Emphasis => containers.pop().filter(|(t, _)| *t == T_EM), + TagEnd::Link { .. } => containers.pop().filter(|(t, _)| *t == T_LINK), + _ => None, + }; + if let Some((_tag, container_span)) = popped { + // Children styled their chars already; blank the + // leftovers (delimiters, link urls) unless on reveal line. + for ci in byte_range_to_char_indices(text, container_span) { + let on_reveal = reveal_line == Some(li.line_of_char[ci]); + if !on_reveal && tags[ci] == T_BASE { + blank[ci] = true; + tags[ci] = T_MARKER; + } else if on_reveal { + tags[ci] = T_BASE; + } + } + } + } + Event::Text(t) => { + let cur_tag = containers.last().map(|&(tag, _)| tag).unwrap_or(T_BASE); + for ci in byte_range_to_char_indices(text, span) { + if reveal_line == Some(li.line_of_char[ci]) { + tags[ci] = T_BASE; + } else if cur_tag != T_BASE { + tags[ci] = cur_tag; + } + } + } + Event::Code(_c) => { + // The span INCLUDES the surrounding backticks. + let cis = byte_range_to_char_indices(text, span.clone()); + for (n, &ci) in cis.iter().enumerate() { + let is_tick = n == 0 || n + 1 == cis.len(); + if reveal_line == Some(li.line_of_char[ci]) { + tags[ci] = T_BASE; + } else if is_tick { + blank[ci] = true; + tags[ci] = T_MARKER; + } else { + tags[ci] = T_CODE; + } + } + } + _ => {} + } + } + + // ---- assemble ---- + // Effective format key per char: 0=base, 2..6 styled, 6=marker, + // 8..13 = heading level. Revealed chars always key 0. + let quote_block = matches!(kind, EditBlockKind::Quote); + let fmt_key_of = |i: usize| -> u8 { + if reveal_line == Some(li.line_of_char[i]) { + return 0; + } + if quote_block && tags[i] <= T_MARKER { + return 7; // weak-quote base color + } + match tags[i] { + T_STRONG => 2, + T_EM => 3, + T_CODE => 4, + T_LINK => 5, + T_MARKER => 6, + _ => match kind { + EditBlockKind::Heading(l) => 7 + l.clamp(1, 6), + _ => 0, + }, + } + }; + + let heading_font_for = |l: u8, s: &MarkdownEditStyle| { + let size = s.body_size * s.heading_scale[(l as usize - 1).min(5)]; + FontId::new(size, s.strong_family.clone()) + }; + let format_for_key = + |key: u8, style: &MarkdownEditStyle| -> TextFormat { + match key { + 2 => TextFormat { + font_id: FontId::new( + style.body_size, + style.strong_family.clone(), + ), + color: style.text_color, + ..Default::default() + }, + 3 => TextFormat { + font_id: base_body_font(kind, style), + color: style.text_color, + italics: true, + ..Default::default() + }, + 4 => TextFormat { + font_id: FontId::new( + style.body_size, + style.mono_family.clone(), + ), + color: style.text_color, + background: style.code_bg, + ..Default::default() + }, + 5 => TextFormat { + font_id: base_body_font(kind, style), + color: style.link_color, + underline: egui::Stroke::new(1.0, style.link_color), + ..Default::default() + }, + 6 => TextFormat { + font_id: base_body_font(kind, style), + color: style.marker_color, + ..Default::default() + }, + k if (8..=13).contains(&k) => { + let l = k - 7; + TextFormat { + font_id: heading_font_for(l, style), + color: style.text_color, + ..Default::default() + } + } + _ => TextFormat { + font_id: base_body_font(kind, style), + color: style.text_color, + ..Default::default() + }, + } + }; + + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + + // Single pass: group consecutive chars sharing a format key. + let mut out = String::with_capacity(text.len()); + let mut run_key: Option = None; + let mut run_start_byte = 0usize; + + for (i, &c) in chars.iter().enumerate() { + let key = fmt_key_of(i); + let out_char = if blank[i] && key == 6 { ' ' } else { c }; + + if run_key != Some(key) { + if let Some(k) = run_key.take() { + let slice = out[run_start_byte..].to_string(); + let fmt = format_for_key(k, style); + job.append(&slice, 0.0, fmt); + } + run_start_byte = out.len(); + run_key = Some(key); + } + out.push(out_char); + } + if !run_key.is_none() { + let k = run_key.unwrap_or(0); + let slice = out[run_start_byte.min(out.len())..].to_string(); + let fmt = format_for_key(k, style); + job.append(&slice, 0.0, fmt); + } + + debug_assert_eq!(out.chars().count(), chars.len(), "char count preserved"); + job +} + +fn base_body_font(kind: EditBlockKind, style: &MarkdownEditStyle) -> FontId { + match kind { + EditBlockKind::CodeBlock => { + FontId::new(style.body_size, style.mono_family.clone()) + } + _ => FontId::new(style.body_size, style.body_family.clone()), + } +} + +/// Map a byte offset in `text` to its char index (binary search free version). +fn char_index_of(text: &str, byte: usize) -> Option { + if byte > text.len() { + return None; + } + let mut ci = 0usize; + for (bi, _) in text.char_indices() { + if bi == byte { + return Some(ci); + } + ci += 1; + } + if byte == text.len() { + Some(ci) + } else { + None + } +} + +fn byte_range_to_char_indices(text: &str, range: Range) -> Vec { + let start = char_index_of(text, range.start).unwrap_or(0); + let end = char_index_of(text, range.end).unwrap_or_else(|| text.chars().count()); + (start..end).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn style() -> MarkdownEditStyle { + MarkdownEditStyle { + body_size: 14.0, + heading_scale: [2.0, 1.6, 1.25, 1.125, 1.0, 0.875], + line_height: Some(21.0), + paragraph_gap: 10.5, + heading_above_scale: [1.125, 1.0, 0.875, 0.75, 0.5, 0.5], + heading_below_scale: [0.25, 0.25, 0.125, 0.125, 0.0, 0.0], + strong_family: FontFamily::Proportional, + body_family: FontFamily::Proportional, + mono_family: FontFamily::Monospace, + text_color: Color32::WHITE, + marker_color: Color32::GRAY, + code_bg: Color32::BLACK, + link_color: Color32::BLUE, + } + } + + #[test] + fn preserves_char_count_for_all_kinds_and_unicode() { + let style = style(); + let cases = [ + ("**bold** tail", EditBlockKind::Paragraph), + ("# Title\n", EditBlockKind::Heading(1)), + ("- item **b** 中🎉\n- second", EditBlockKind::ListItem), + ("> quoted *em* 文字", EditBlockKind::Quote), + ("a `code` span 中文", EditBlockKind::Paragraph), + ]; + for (src, kind) in cases { + let job = markdown_block_job(src, kind, &style, None, 600.0); + assert_eq!( + job.text.chars().count(), + src.chars().count(), + "{src:?} -> {:?}", + job.text + ); + } + } + + #[test] + fn hides_heading_and_list_markers_but_not_reveal_line() { + let style = style(); + let heading = + markdown_block_job("# Title", EditBlockKind::Heading(1), &style, None, 600.0); + assert_eq!(heading.text, " Title"); + + let revealed = markdown_block_job( + "# Title", + EditBlockKind::Heading(1), + &style, + Some(0), + 600.0, + ); + assert_eq!(revealed.text, "# Title"); + + let list = markdown_block_job("- item", EditBlockKind::ListItem, &style, None, 600.0); + assert_eq!(list.text, " item"); // "-" + space blanked + } + + #[test] + fn ordered_list_marker_blank_including_number() { + let style = style(); + let job = markdown_block_job("12. ok", EditBlockKind::ListItem, &style, None, 600.0); + assert_eq!(job.text, " ok"); // "12." + space blanked + } + + #[test] + fn inline_delimiters_blank_runs_styled() { + let style = style(); + let job = markdown_block_job( + "**b** t `c`", + EditBlockKind::Paragraph, + &style, + None, + 600.0, + ); + // "**"->2sp, b, "**"->2sp, " t ", "`"->sp, c, "`"->sp + assert_eq!(job.text, " b t c "); + } + + #[test] + fn link_label_kept_url_hidden() { + let style = style(); + let job = markdown_block_job( + "[site](https://x.io)", + EditBlockKind::Paragraph, + &style, + None, + 600.0, + ); + assert_eq!(job.text.chars().count(), 20); + assert!(job.text.contains("site")); + assert!(!job.text.contains("https"), "{}", job.text); + } + + #[test] + fn codeblock_kind_stays_raw() { + let style = style(); + let src = "let x = 1;\n"; + let job = markdown_block_job(src, EditBlockKind::CodeBlock, &style, None, 600.0); + assert_eq!(job.text, src); + } + + #[test] + fn sections_cover_entire_job_text_in_order() { + let style = style(); + let job = markdown_block_job( + "# H\npara **x** more", + EditBlockKind::Paragraph, + &style, + None, + 600.0, + ); + let mut covered = 0usize; + for s in &job.sections { + assert_eq!(s.byte_range.start, covered); + covered = s.byte_range.end; + } + assert_eq!(covered, job.text.len()); + } +} + +pub(crate) fn style() -> MarkdownEditStyle { + MarkdownEditStyle { + body_size: 14.0, + heading_scale: [2.0, 1.6, 1.25, 1.125, 1.0, 0.875], + line_height: Some(21.0), + paragraph_gap: 10.5, + heading_above_scale: [1.125, 1.0, 0.875, 0.75, 0.5, 0.5], + heading_below_scale: [0.25, 0.25, 0.125, 0.125, 0.0, 0.0], + strong_family: FontFamily::Proportional, + body_family: FontFamily::Proportional, + mono_family: FontFamily::Monospace, + text_color: Color32::WHITE, + marker_color: Color32::GRAY, + code_bg: Color32::BLACK, + link_color: Color32::BLUE, + } + } + +#[cfg(test)] +mod galley_tests { + use super::*; + + /// Lays out the styled job inside a REAL egui pass so fonts are + /// initialized exactly like production. + fn galley_shape(text: &str, kind: EditBlockKind, reveal: Option, wrap: f32) + -> (f32, usize) + { + let style = style(); + let mut job = markdown_block_job(text, kind, &style, reveal, wrap); + job.wrap.max_width = wrap; + // BISECTION: also lay out a trivial simple() job as control. + let mut ctrl = egui::text::LayoutJob::simple( + "Hello control width".into(), + egui::FontId::new(14.0, FontFamily::Proportional), + style.text_color, + wrap, + ); + let _ = &mut ctrl; + + let ctx = egui::Context::default(); + let result = std::sync::Mutex::new((0.0f32, 0usize)); + ctx.run( + egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size( + Default::default(), + egui::Vec2::new(wrap + 100.0, 800.0), + )), + ..Default::default() + }, + |ctx| { + egui::CentralPanel::default().show(ctx, |ui| { + { + let cg = ui.fonts_mut(|f| f.layout_job(ctrl.clone())); + eprintln!("CTRL galley w={:.1} rows={}", cg.size().x, cg.rows.len()); + } + let galley = ui.fonts_mut(|f| f.layout_job(job.clone())); + let mut res = result.lock().unwrap(); + res.0 = galley.size().x; + res.1 = galley.rows.len(); + }); + }, + ); + result.into_inner().unwrap() + } + + #[test] + fn paragraph_rows_match_lines() { + let text = "This paragraph supports *italics*, **bold**, `inline code`, and links.\n"; + let (_w, rows) = galley_shape(text, EditBlockKind::Paragraph, None, 600.0); + // Line + trailing-newline row. (Absolute width can't be asserted in + // this environment: headless fonts report zero advances.) + assert_eq!(rows, 2, "paragraph rows"); + } + + #[test] + fn heading_is_single_row() { + let text = "# Live Preview Test Doc"; + let (_w, rows) = galley_shape(text, EditBlockKind::Heading(1), None, 600.0); + assert_eq!(rows, 1, "heading has no trailing newline in test text"); + } + + #[test] + fn multiline_block_has_row_per_line_plus_trailing() { + let text = "- First\n- Second\n- Third\n"; + let (_w, rows) = galley_shape(text, EditBlockKind::ListItem, None, 600.0); + assert_eq!(rows, 4, "3 items + trailing newline row, got {rows}"); + } +} diff --git a/diff-stats.txt b/diff-stats.txt new file mode 100644 index 0000000..6beca70 --- /dev/null +++ b/diff-stats.txt @@ -0,0 +1,2 @@ +1.58985e+10 (1.58985e+10)changed-px 8.8955e+06 +done diff --git a/diff.png b/diff.png new file mode 100644 index 0000000..fed6a51 Binary files /dev/null and b/diff.png differ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4091262..a50d00e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -125,3 +125,47 @@ Left sidebar showing all markdown files in a hierarchical directory tree: - Expand/collapse directories with arrow buttons - Refresh button to rescan directory - Session persistence for: visibility, root directory, expanded directories + +## Editing & Saving (Phase 0/1 + Live Preview) + +Editing roadmap and design rationale live in `docs/research/editing-approaches.md`; +implementation notes in `docs/devlog/055-editing-phase0-source-mode.md` and +`docs/devlog/056-live-preview-block-editing.md`. + +- **Modes**: `Tab.edit_mode` is `EditMode::{Rendered, Source, Live}`. + Ctrl+E toggles Rendered↔Live; the View menu offers all three. + - **Source** renders a whole-file monospace `TextEdit` + (`render_source_editor_ui`) bound to `Tab.content`. + - **Live (Obsidian-style)** renders everything except the active top-level + block, which becomes an inline raw-markdown editor. The block's text + lives in egui temp state (`EditRegionConfig.id`), avoiding a second + borrow of the source string; each frame's feedback is spliced back into + `tab.content` via `cache.take_edit_feedback()` with + `anchor_after_splice` keeping the caret anchor stable. Clicks are mapped + to block byte ranges through recorded boundaries + (`block_span_at_content_y`). Esc deactivates the editor. + +- **Source mode (Ctrl+E)**: the active tab renders a monospace multiline + `TextEdit` bound directly to `Tab.content` inside a `ScrollArea` + (`render_source_editor_ui`). egui's scroll-to-cursor targets the enclosing + ScrollArea, so search jumps / outline clicks move the caret via + `pending_caret_byte` (byte offsets converted with + `byte_offset_to_char_index` — egui cursors count chars, not bytes). +- **Save pipeline**: Ctrl+S clones + hashes the buffer on the UI thread and + hands a `SaveJob` to a background worker; outcomes arrive via a channel and + are polled non-blocking each frame (`poll_save_outcomes`). Writes are + atomic: temp file + `fs::rename`. +- **Dirty tracking**: `Tab.synced_content_hash` (last state known to match + disk), `dirty`, `in_flight_save_hash`. Dirty markers appear in the tab bar + and window title. +- **Watcher safety**: watcher events run through the pure policy + `classify_disk_change() -> Ignore | Resync | Conflict | Reload`. The app's + own saves are never reloaded over the buffer; external changes while dirty + raise a conflict banner (Reload from disk / Keep my version) instead of + clobbering edits. +- **Unsaved-changes guards**: closing a dirty tab or quitting raises an + in-app confirmation (Save / Discard / Cancel). Quit-with-save defers until + all background writes settle. +- **Derived caches** (`outline_headers`, `local_links`, `content_lines`, + `search_matches`) rebuild debounced 250 ms after the last keystroke — + never per frame while typing. diff --git a/docs/KEYBOARD_SHORTCUTS.md b/docs/KEYBOARD_SHORTCUTS.md index dec2cf8..4faced3 100644 --- a/docs/KEYBOARD_SHORTCUTS.md +++ b/docs/KEYBOARD_SHORTCUTS.md @@ -14,9 +14,25 @@ | Shortcut | Action | |----------|--------| -| Ctrl+O | Open file in new tab | +| Ctrl+O | Open file dialog (files open in new tabs) | +| Ctrl+S | Save active tab | | F5 | Toggle file watching | -| Ctrl+Q | Quit application | +| Ctrl+Q | Quit application (prompts if unsaved edits) | + +## Editing + +| Shortcut | Action | +|----------|--------| +| Ctrl+E | Toggle live-preview editing for the active tab | +| Click block | Activate that block's inline editor (Live mode) | +| Esc | Leave the active block, return to the rendered view (Live mode) | + +Editing modes: **Live preview** (Ctrl+E — everything renders except the +block you click into), **Source** (whole-file raw markdown, View menu), and +**Rendered** (reading view). In Source mode, Ctrl+W / middle-click closing a +dirty tab and Ctrl+Q raise an unsaved-changes confirmation with Save / +Discard / Cancel. If the watched file changes on disk while you have +unsaved edits, a banner offers Reload-from-disk or Keep-my-version. ## Navigation diff --git a/docs/devlog/055-editing-phase0-source-mode.md b/docs/devlog/055-editing-phase0-source-mode.md new file mode 100644 index 0000000..2ad3521 --- /dev/null +++ b/docs/devlog/055-editing-phase0-source-mode.md @@ -0,0 +1,115 @@ +# Feature: Editing Phase 0/1 — Safe Save Pipeline + Source-Mode Editing + +**Status:** ✅ Complete +**Branch:** `feature/editing` (research groundwork on `feature/editing-research`) +**Date:** 2026-02 +**Lines Changed:** ~+600 / -40 in `src/main.rs` + +## Summary + +First two phases of the editing roadmap from `docs/research/editing-approaches.md`. +Phase 0 builds the data-safety plumbing every later phase depends on (dirty +tracking, background atomic saves, watcher self-write suppression, conflict +UX, unsaved-changes guards). Phase 1 delivers usable editing: Ctrl+E flips the +active tab to a raw-markdown editor pane; Ctrl+S saves. The rendered view and +all existing viewer behavior are untouched. + +## Features + +- [x] Dirty tracking (`Tab.dirty`, synced/in-flight content hashes) +- [x] Background save thread — UI thread only clones + hashes (State→Logic→UI→Async) +- [x] Atomic writes: temp file + `fs::rename`, never truncate-in-place +- [x] Watcher self-write suppression via pure `classify_disk_change()` policy +- [x] Conflict banner when disk changes while tab is dirty (Reload / Keep mine) +- [x] Unsaved-changes guard for tab close + quit (Save / Discard / Cancel) +- [x] Ctrl+S save, Ctrl+E source-mode toggle, File/View menu entries +- [x] "Open in External Editor" (xdg-open) handoff +- [x] Dirty markers in tab bar ("file • ") and window title +- [x] Search jumps + outline clicks move caret in source mode +- [x] Debounced (250 ms) derived-cache rebuild after typing stops + +## Key Discoveries + +### 1. egui TextEdit cursor indices are chars, not bytes + +All search/header machinery in this app speaks **byte offsets** +(`SearchMatch.byte_start`, pulldown-cmark spans). egui's `CCursor.index` is a +**char index**. Every jump into the source editor goes through +`byte_offset_to_char_index()`, which also floors mid-UTF-8-sequence offsets to +the containing char boundary. + +```rust +state.cursor.set_char_range(Some( + egui::text::CCursorRange::one(egui::text::CCursor::new(char_idx)), +)); +``` + +### 2. TextEdit scrolls its *enclosing* ScrollArea, not itself + +egui's multiline TextEdit auto-sizes to content and calls +`ui.scroll_to_rect(cursor_rect)` when the selection changes — that call +targets the nearest enclosing ScrollArea. So the editor pane is +`ScrollArea::vertical { TextEdit }`, which makes programmatic caret jumps +(Ctrl+F matches, outline clicks) scroll into view for free. + +### 3. Watcher policy must be pure and table-tested + +The reload decision has five interacting states (synced hash, buffer hash, +dirty flag, in-flight save hash, incoming hash). Encoding it as +`classify_disk_change() -> Ignore | Resync | Conflict | Reload` made the +tricky races testable without files or a display: + +- own-save echo while in flight → **Ignore** (previously clobbered buffers!) +- user undid edits back to disk state → **Resync** (clear dirty instead of prompting) + +### 4. note_synced must not clear last_edit_at + +A quick type→save can complete before the 250 ms debounced derived-cache +rebuild. If `note_synced()` cleared `last_edit_at`, the rebuild would never +fire and outline/search would go stale behind a clean-looking file. Save +bookkeeping and edit bookkeeping are deliberately independent now. + +### 5. GUI smoke tests aren't possible in this sandbox + +Xvfb aborts inside the execution sandbox (X11 socket dir restrictions), so +verification here = `cargo build` + `cargo clippy` (0 new warnings) + +55 unit tests. Runtime behavior should get one manual check on a real desktop +before release: open file → Ctrl+E → type → Ctrl+S → confirm file contents + +watcher doesn't fight the save. + +## Architecture + +### Tab fields added + +```rust +synced_content_hash: u64, // buffer state known to match disk +in_flight_save_hash: Option, // snapshot being written right now +dirty: bool, +external_change_hash: Option, // pending conflict (dedupes events) +source_mode: bool, +derived_stale: bool, // outline/links/matches need rebuild +last_edit_at: Option, // debounce anchor +close_after_save: bool, // "Save & Close" flow +pending_caret_byte: Option, // caret jumps into the source editor +``` + +### New plumbing + +```rust +struct SaveJob { path, content, hash } // UI → worker +struct SaveOutcome { path, hash, result } // worker → UI +enum UnsavedConfirm { CloseTab(PathBuf), Quit } +enum DiskChange { Ignore, Resync, Conflict, Reload } +fn classify_disk_change(...) -> DiskChange // pure, unit-tested +fn render_source_editor_ui(ui, tab) // TextEdit + caret consumption +``` + +## Limitations / Next steps + +- Conflict banner only shows when the conflicted tab is active. +- Source mode uses plain TextEdit: no syntax highlighting yet + (`egui_code_editor` 0.2.x is egui-0.33-compatible if wanted), no inline + preview. +- Autosave is intentionally absent; explicit Ctrl+S only. +- Phase 2 (section click-to-edit) and Phase 3 (block-level live preview) are + scoped in `docs/research/editing-approaches.md` §6. diff --git a/docs/devlog/056-live-preview-block-editing.md b/docs/devlog/056-live-preview-block-editing.md new file mode 100644 index 0000000..d1016e2 --- /dev/null +++ b/docs/devlog/056-live-preview-block-editing.md @@ -0,0 +1,77 @@ +# Feature: Phase 3 — Live Preview with Block-Level Editing + +**Status:** ✅ Complete (v1) +**Branch:** `feature/editing` +**Date:** 2026-02 +**Lines Changed:** ~+330 / -30 in `src/main.rs`, `crates/egui_commonmark/{egui_commonmark, egui_commonmark_backend}/src` + +## Summary + +The flagship editing experience from `docs/research/editing-approaches.md` +§3-B: in **Live mode**, the document renders exactly as before except the +block you click into, which becomes an inline raw-markdown editor. Click +another block to move there; Esc returns to the fully rendered view. Ctrl+E +toggles Rendered↔Live. Phase 2 (section-level click-to-edit) was absorbed — +sections are just coarser blocks of the same mechanism. + +## Features + +- [x] Block segmentation from the fork's cached event spans (no re-parsing) +- [x] Inline editor swap-in via additive, opt-in fork APIs +- [x] Click-to-edit hit-testing through recorded block-boundary positions +- [x] Per-frame splice-back of edited text; caret anchor stays stable +- [x] Esc deactivates; search jumps / outline clicks activate blocks +- [x] EditMode enum {Rendered, Source, Live} replaces the source_mode bool + +## Key Discoveries + +### 1. Never lend the source string twice + +The natural design — fork paints a TextEdit bound to a slice of the caller's +`String` — is impossible: `show_scrollable` already borrows the content as +`&str`. Instead the editor's working buffer lives in **egui temp state** +under an id (`source_editor_id().with("live")`); the fork reads/writes it, +and the app splices feedback (`cache.take_edit_feedback()`) back into +`tab.content` when it changed this frame. Re-seeding is keyed by +`(content_version, block_start)` so typing is never clobbered by a re-seed. + +### 2. Skip-painting events is only safe at top-level boundaries + +The event loop maintains a state machine (`self.list`, `is_table`, +`is_blockquote`). Dropping events mid-container corrupts it (the split-point +panic documented in devlog 027). The edit region therefore MUST align to a +complete depth-0 block — which is precisely what `top_level_block_spans()` +produces, so the contract is enforced by construction on the app side. + +### 3. Anchor-based active-block tracking survives re-segmentation + +Typing changes byte offsets everywhere after the block. Storing an index into +a block list would go stale between frames; storing one **byte anchor inside +the active block** and re-resolving `spans.find(contains(anchor))` each frame +is self-healing. After a splice, `anchor_after_splice()` keeps the caret's +relative position clamped to the new length. + +### 4. Hit-testing rides on existing boundary recording + +Safe boundaries were already detected for split points; recording their +`end_position.y` plus the following byte offset (`BlockBoundary`) costs +nothing extra and turns clicks into byte ranges: +`content_y = pointer.y - inner_rect.min.y + scroll_offset`, then +`block_span_at_content_y()`. + +## Limitations / v2 candidates + +- Arrow keys do not yet cross block boundaries (click or Esc+click instead); + needs caret-edge feedback out of the fork. +- The inline editor is plain monospace TextEdit (no syntax hiding of inline + marks outside the caret, unlike CM6 decorations). +- Conflict watcher banner shows only for the active tab. +- One-frame latency between click and editor appearance (imperceptible). + +## Verification + +- `cargo test`: app 56 ✓ · backend 18 (+4 new segmentation tests) ✓ +- `cargo clippy`: no warnings in app code +- GUI smoke test still not possible in this sandbox (Xvfb aborts) — manual + desktop check recommended: click blocks in Live mode, type across a save, + confirm watcher silence. diff --git a/docs/devlog/057-styled-layouter.md b/docs/devlog/057-styled-layouter.md new file mode 100644 index 0000000..a2b0931 --- /dev/null +++ b/docs/devlog/057-styled-layouter.md @@ -0,0 +1,52 @@ +# Feature: 057 — Styled Layouter for the Live Editor (B+) + +**Status:** ✅ Complete +**Branch:** `feature/editing` +**Lines Changed:** ~+520 in `crates/egui_commonmark/{egui_commonmark_backend, egui_commonmark}/src`, `src/main.rs` + +## Summary + +The active block's TextEdit now paints through a markdown-aware layouter: +headings render at heading scale in the strong face; `**bold**`, `*em*`, +`` `code` `` and links are styled with their delimiters hidden as blank +glyphs; structural markers (`# `, `- `, `> `) vanish. The caret's line +reveals raw syntax (the Obsidian Live Preview signature). All of it inside a +stock `TextEdit` — no custom text engine. + +## How it works + +1. **Equal-char-count substitution.** The layouter builds a job whose text + has exactly the buffer's char count; markup chars become spaces. egui maps + cursors by char index onto the returned galley, so cursor, selection and + IME stay exact (same mechanism as password masking). +2. **Inline parsing per frame.** pulldown-cmark runs over the small block + buffer; events drive per-char decoration tags (strong/em/code/link), + container leftovers (delimiters, link URLs) become blanks. +3. **Caret-line reveal.** After each paint the primary cursor char index is + stashed under `/caret_line_char`; next frame the layouter skips + all blanking on that line. +4. **Block kind.** `EditRegionConfig.kind` (heading level / paragraph / quote + / list / code) drives prefix rules and heading scale; the app derives it + from the block's first line. + +## Gotchas discovered + +- **FontFamily must be real, not debug-formatted strings** — first version + stored `format!("{family:?}")` which produced unregistered named families + and panicked `FontFamily::"Proportional" is not bound`. +- **pulldown's Code event span includes its backticks** — blank first/last + chars of the span, don't look outside it. +- **Clipboard comes from the galley (egui#5885)** — copying selected text in + an edited block yields hidden markers as blanks. Documented tradeoff; + never skip bytes to avoid worse breakage. + +## Verification + +- styler unit tests ×7 (unicode length preservation, reveal-line, ordered + list numbers, link url hiding, section coverage) +- app tests 57 ✓ · fork lib 24+37 ✓ · e2e typing green through the new path +- clippy clean on touched code + +## Next + +PSE-2/C: persistent styled editors (no swap), galley-caret activation. diff --git a/docs/devlog/058-pse-persistent-editors.md b/docs/devlog/058-pse-persistent-editors.md new file mode 100644 index 0000000..ac51669 --- /dev/null +++ b/docs/devlog/058-pse-persistent-editors.md @@ -0,0 +1,56 @@ +# Feature: 058 — PSE Persistent Styled Editors (Live mode rewrite) + +**Status:** ✅ Complete (v1) +**Branch:** `feature/editing` +**Lines Changed:** ~+400 / −260 across `src/main.rs`, fork `misc.rs`/`pulldown.rs`/`styler.rs` + +## Summary + +Implements architecture C from `docs/research/editing-architecture-overview.md`: +Live mode no longer swaps a single editor in and out. **Every text block +(heading, paragraph, quote, list) is permanently a styled TextEdit**, laid out +in document flow; non-text blocks (images, math, mermaid, tables, code fences) +continue rendering through the normal pipeline between editors. The swap jump, +focus race, and click-position bugs of the previous design are structurally +impossible here — clicks land natively in whichever editor is under the +cursor. + +## Design + +- **Fork**: `EditSessionConfig { id_salt, blocks: Vec }` + via `viewer.edit_session(...)`. Paint loop detects which session block each + event belongs to; text blocks paint a TextEdit bound to per-block egui-temp + buffers (seeded from source on first sight) with the markdown-aware + layouter; non-text blocks process normally. Per-block feedback + (`index/text/changed`) is collected into a per-frame map, flushed to the + cache at end of show(), read non-destructively by the app. +- **App**: `Tab.session_buffers/kinds/dirty/last_blocks`. Entering Live seeds + buffers from serialized content (Heading strips `# `, Quote strips `> `); + keystrokes mutate buffers only; serialization folds everything back — + markers re-materialized — on Ctrl+S or mode exit. Cache-refresh gaps fall + back to last known blocks so editors never flicker away. +- Retired for PSE: active-block anchor, calibration bias, boundary hit-test, + synthetic-click gate (kept env hooks for future e2e). + +## Gotchas + +- egui multi-pass layouts run closures more than once → feedback must be + read non-destructively (`get_session_feedback`) and change detection done + by comparing buffer text, not flags. +- Session state must reset on `reload()`/`load_file()` or stale buffers paint + over fresh content. +- Fork examples don't compile in this vendored tree (upstream crate names) — + pre-existing; test with `--lib --tests`. + +## Verification + +- Headless e2e `live_session.rs`: 4 editors painted; keystroke routed to + focused block only; others untouched. +- Suites: app 57 ✓ · backend lib 24 ✓ · frontend 37 + live_preview 3 + + live_session 1 ✓. Clippy clean on touched code. + +## Known limitations (v1) + +- List blocks edit as one raw unit (markers visible); per-item editing later. +- Search/outline operate on last-serialized content while typing. +- Arrow-key crossing between blocks not implemented. diff --git a/docs/research/editing-approaches.md b/docs/research/editing-approaches.md new file mode 100644 index 0000000..4859da9 --- /dev/null +++ b/docs/research/editing-approaches.md @@ -0,0 +1,313 @@ +# Adding Editing: Approach Comparison (Obsidian-style / Slack-canvas-style) + +**Status:** Research · **Date:** 2026-02 · **Branch:** `feature/editing-research` + +How could md-viewer gain editing? This document surveys how established products +(Obsidian, Typora, Slack Canvas, Notion) implement markdown/rich-text editing, +inventories the Rust/egui building blocks available today, maps six candidate +approaches onto *this* codebase, compares them, and recommends a phased path. + +--- + +## 1. Current state & constraints (what we already have) + +The app is a read-only viewer: `Tab { content: String, cache: CommonMarkCache, … }` +rendered through a vendored `egui_commonmark` fork (`crates/egui_commonmark/`). +Facts that materially shape any editing design: + +| # | Fact | Where | Consequence for editing | +|---|------|-------|------------------------| +| F1 | Renderer caches parsed `(pulldown_cmark::Event, Range)` pairs keyed by content hash / monotonic `content_version` | `misc.rs:1766` (`cached_events`), `lib.rs:513-535`, `main.rs:659` | **Byte spans of every event already exist**; re-parse per keystroke is one pass over the doc (~52 ms @ 100k lines per fork comment; sub-ms for typical docs) | +| F2 | Renderer records `split_points = Vec<(event_index, start_y, end_y)>` at *safe block boundaries* (not inside lists/tables/blockquotes), **but viewport-skip paint is disabled as buggy** — rendering is a forced full paint every frame (~5.7 ms @ 2.5k events, 229 ms @ 100k) | `parsers/pulldown.rs:658-679` (recording), `:833-843` (skip disabled), `:844-994` (dead skip code) | Block boundaries + ys are available for segmentation/hit-testing; but there is **no virtualization today**, so large-doc edit performance depends on cache reuse, not skipped painting | +| F3 | Search-highlight feature already splits `Event::Text`/`Event::Code` at byte boundaries and restyles spans | `set_search_ranges`, `misc.rs:1782-1790`; handler at `pulldown.rs:1352+` | Precedent for span-level surgery inside the renderer | +| F4 | Fork has a `mutable` mode: interactive checkboxes emit `CheckboxClickEvent { checked, span }` for source writeback | `lib.rs:478`, `pulldown.rs:1408-1419` | Precedent for rendered-view → source-range writeback | +| F5 | File watcher auto-reloads tabs on external change: unconditional `tab.reload()` | `reload_changed_tabs`, `main.rs:2020-2050` | **Would clobber unsaved edits**, including edits written by the app itself → any edit mode needs dirty-tracking + self-write suppression + conflict UX | +| F6 | Derived caches parsed once per load: outline headers (`parse_headers`, regex), `local_links`, search matches | `main.rs:908, 845, 973` | Must be refreshed on edit (debounced), not per keystroke | +| F7 | Mermaid/math render states keyed by content hash, rendered on background threads | `misc.rs:1792-1836` | Typing inside a diagram/formula triggers background re-renders → debounce needed | +| F8 | Repo pattern is State → Logic → UI → Async; no parsing in UI code | `docs/EGUI_WORKFLOW.md` | Edit state machine must live in state/logic layer, UI only reads | +| F9 | Fork already diverged from upstream egui_commonmark (search ranges, strong font, table scroll, latex delimiters, event cache) | `crates/egui_commonmark/` | More divergence has real maintenance cost, but the team owns the fork | +| F10 | **No save infrastructure exists**: no `fs::write` anywhere, no dirty flag, `PersistedState` stores only paths/settings | `main.rs:148-160`, grep for `fs::write` | Save path, dirty markers, and unsaved-buffer persistence are all greenfield | +| F11 | Keystroke invalidation is currently **global**: both cached events and syntect `LayoutJob`s are keyed by whole-document content hash, so one keystroke re-runs syntax highlighting for every code block | `misc.rs:1766, 1773`; cleared in `pulldown.rs:597-606` path | Code-heavy docs pay full syntect re-cost per keystroke → block-scoped cache keys are a prerequisite for comfortable editing | +| F12 | Scroll/search y-positions assume immutable content (`header_positions`, active-match y recorded during paint) | `misc.rs:1776-1790`, `main.rs:2203` | Mid-edit scroll jumps degrade gracefully but need version guards or recompute-on-commit | + +## 2. Reference architectures + +### 2.1 Obsidian — CodeMirror 6 "Live Preview" *(verified)* +Obsidian embeds **CodeMirror 6** ("Obsidian uses CodeMirror 6 (CM6) to power +the Markdown editor" — official dev docs). It offers two editing modes: +**Live Preview** ("shows formatted text inline while hiding most Markdown +syntax. When your cursor enters formatted content, the underlying syntax +becomes visible") and **Source mode** (everything raw); Reading view is +non-editable rendered output. + +Mechanism: the buffer always holds raw markdown; a `Decoration.replace({})` +layer hides markup characters *without changing line height*, computed by view +plugins scoped around the cursor; whole-block widgets (checkboxes, tables) are +widget decorations replacing spans. CM6 provides immutable state + dispatched +transactions (typed change sets → undo/history), composable decorations, +incremental Lezer parsing, and **viewport-only rendering**. + +⚠️ **Layout-thrash lesson** from a documented CM6 live-preview clone +(atomic-editor): their first cut swapped each block to a rendered widget on +cursor exit → measurable layout shift (~0.1 CLS per 10 cursor moves); inline +decoration-hiding with stable line heights fixed it. Any egui block-swap +design must keep swap-in-place geometry stable or it will jump on every click. + +**Transferable idea:** plain text stays the single source of truth; +"rendered vs raw" is a *per-region visual mode* driven by cursor position, +implemented as decorations over the buffer — not a separate rich tree. + +### 2.2 Typora — seamless WYSIWYG *(partially verified)* +Markets exactly the Obsidian-live-preview feel with no mode switcher +(typora.io). Bundles CodeMirror plus markdown-it/MathJax/Mermaid +(acknowledgements page). Internal renderer is undocumented; consensus says a +DOM-based custom renderer re-rendering blocks around the caret. Why it's rare +outside browsers: the DOM gives free bidirectional keystroke↔layout mapping, +text measurement, IME and mixed text/widget selection — all of which a custom +toolkit must own itself. + +### 2.3 Slack Canvas — markdown-canonical storage, block-feeling surface *(verified at API layer)* +The programmatic content model of canvases is plain **markdown**: +`document_content { type, markdown }`, "currently the only supported type is +`markdown`", 1 MiB cap, fixed element vocabulary; Block Kit unsupported. No +public engineering writeup of the client editor stack exists — treat any +ProseMirror/Lexical attribution as rumor. Interaction grammar (sections with +drag handles, `/` commands, hover toolbar) is observable but undocumented. + +**Precedent value:** Slack keeps the *stored* format markdown-simple while the +editing surface feels block-structured — direct support for keeping `.md` +canonical here. + +### 2.4 Notion — block-graph canonical storage *(API verified; storage folklore secondary)* +Every block object has UUID `id`, `parent`, `has_children`, `type` enum + +payload incl. `rich_text`; pages index their child blocks. Widely reported +Postgres row-per-block implementation. Pros: cheap reorder/partial sync, +per-block ACLs, structured types beyond markdown. Cons: **lossy markdown +round-trip** (toggles/columns/nested DBs don't map to CommonMark), heavier +render stack (every block its own editable region). + +### 2.5 Native-toolkit prior art +No mainstream product ships Obsidian-style live preview in an immediate-mode +GUI toolkit. **Zed** (GPUI, GPU-drawn rects/glyphs, explicitly post-Electron) +ships source-mode markdown editing, not hybrid preview. The best non-DOM proof +that live preview doesn't need a browser is CM6 itself: virtualized text core + +decoration overlays. In egui specifically there is **no library** for it, but +the Ferrite app demonstrates block-switching editing at product level, and +GPUI projects (`aster`, `Cditor`) prove the architecture — see §3.0. Building +this is greenfield UX work on `TextEdit` primitives, not a drop-in crate. + +## 3. Candidate approaches + +### 3.0 Ecosystem building blocks (what exists today) + +**egui `TextEdit` (verified against egui 0.33.3 sources):** +- Multiline editing, built-in undo/redo (`Undoer<(CCursorRange, String)>`, + Ctrl+Z/Y handled inside the widget — `text_edit/builder.rs:1030-1054`), + programmatic cursor read/write (`TextEditOutput.cursor_range`, + `state.set_char_range` — indices are **char**, not byte), focus control, + custom paint per text run via `layouter(...)` (the hook a syntax highlighter + uses), `.code_editor()` convenience. No public *programmatic* undo/redo API + beyond swapping the stored `Undoer`. +- **IME supported** (`data/input.rs:495-565`), but 0.33-era has an open Linux + X11+Fcitx5 pre-edit-invisible bug (egui#7975); composition visuals were only + overhauled in 0.35 (#8083). `Context::text_edit_focused` exists from 0.34. +- Limits: multiline always wraps (no horizontal-scroll mode); **any keystroke + invalidates and re-lays-out the entire document galley** — one monolithic + widget with no intra-widget memoization (epaint#4000, open since 2024), so a + several-thousand-line single `TextEdit` lags. Per-block editors stay fast: + each swapped-in galley is small. +- No native syntax highlighting. + +**pulldown-cmark 0.13 (workspace pin; merman-render separately uses 0.12):** +`Parser::new_ext(..).into_offset_iter()` yields `(Event, Range)` — +byte-span mapping, exactly what the fork already consumes. Block ranges = +union of child event spans; no parser changes needed. + +**Editor-component crates:** `egui_code_editor` is actively maintained +(p4ymak fork; numbered lines, themes, its own lightweight tokenizer — not +syntect — feeding a `LayoutJob`). Compatibility: **0.2.20/0.2.21 target egui +^0.33 → drop-in**; current 0.4.x requires ^0.36. Whole-buffer tokenize per +frame — fine for source mode, not a hybrid-preview engine. **No +CodeMirror-equivalent exists for egui** (incremental parse + decorations + +virtualized buffer): nothing maintained ships it; Zed/GPUI internals are a +different stack. Ropes (`ropey` 1.6 as in Helix, `crop`, Lapce's xi-rope +descendant) only matter if we outgrow `String` splices — fine below ~1 MB docs. + +**Prior art scan:** no *library* provides Obsidian-style live preview in egui +(nor Dear ImGui — imgui_markdown/imgui_md are render-only). But at app level, +**Ferrite** (★~1.8k, Rust + egui 0.34, active 2026) ships exactly this +spectrum: split view with live scroll sync (= approach A) plus "**one-click +block switching between headings, paragraphs, lists, and table cells in +rendered mode**" (= approaches B/C mechanics) — an existence proof that +block-swap editing works in egui. In GPUI, `aster` (rope-backed single-pane +inline rendering) and `Cditor` (block-based editor virtualized to ~100k +blocks) validate the same architecture; both would need reimplementation here. + +### A. Two-pane: source editor + live preview ("VS Code style") +Edit toggle per tab; left pane `egui::TextEdit::multiline(&mut tab.content)`, +right pane existing `CommonMarkViewer`. Debounce re-render (bump +`content_version` on change). Save via Ctrl+S (+ autosave option). + +- Hits: F1 (version-keyed cache makes re-render cheap), zero fork changes; + `egui_code_editor` 0.2.x drops in for highlighted source mode (§3.0). +- Misses: not "live preview"; duplicate scroll contexts; pane-width cost; and + on very large docs the single monolithic `TextEdit` re-lays-out its whole + galley per keystroke (epaint#4000, §3.0) — laggy past a few thousand lines. +- Effort: **S** — mostly app-level; watcher fix (F5) mandatory regardless. + +Variant A′ (same pane, mode toggle): Ctrl+E flips tab between Rendered and +Source view — same TextEdit, fullscreen. Even cheaper; Obsidian's Source mode. + +### B. Hybrid live preview, per-block swap ("Obsidian style") ★ flagship +Rendered view everywhere; the focused block renders as raw-source `TextEdit` +with rendered output everywhere else. Mechanics in this codebase: + +1. **Block model (logic layer):** derive `Vec` per render pass from the cached events+spans (F1) — block + boundaries are already detected (`is_block_end_tag`, F2), so no parser + changes; lists/tables/blockquotes stay atomic, matching split-point safety. + Recomputed on version bump, not per frame. +2. **Hit-test:** click y → block whose recorded y-range contains it + (split points already record start/end ys); store `active_block` in Tab. +3. **Swap:** the force-bootstrap full-paint actually *simplifies* this — in the + event loop, when iteration enters the active block's event range, emit a + `TextEdit::multiline` seeded with `&content[src_range]` instead of the + normal widgets. On change, splice back via one `replace_range` + (`CheckboxClickEvent` writeback is the precedent, F4). Mind the index + mismatch: pulldown spans are **byte** offsets while `TextEdit` cursors are + **char** offsets — convert at the swap boundary. +4. **Block exit rules:** click into another block / ↑↓ at text edges / Esc / + Ctrl+Enter → commit + activate neighbor (Typora-style). Multi-block + selection and cross-block drag-editing deferred. +5. **Per-block cache keys:** re-key `syntax_layouts` (F11) by + `(block_src_hash, lang, theme)` so keystrokes only re-highlight the edited + block; debounce derived caches (F6) and mermaid/math (F7). + +- Hits: keeps `.md` sacred (data model stays one `String`, same as A); + builds directly on F1–F4 machinery confirmed present; closest to Obsidian feel; + validated by Slack storing canvases as plain markdown while their surface + *feels* block-structured (§2.3). +- Risks: caret semantics across blocks (Enter splitting list items), + undo stacks fragment per-TextEdit (mitigate: commit-on-exit + app-level undo + buffer), IME composition inside swapped widget, inline-format toolbar extra — + and **swap-induced layout jump**: the CM6 atomic-editor project measured + ~0.1 CLS per 10 cursor moves when swapping block↔widget with unstable + geometry (§2.1); mitigation is keeping the TextEdit metrics identical to the + rendered block (same font/wrap width, reserve line heights) or hiding + decorations in place instead of widget replacement for inline spans. +- Effort: **M/L**, incremental — paragraph/heading swap first, then lists, + then tables. Fork extension: an "active block override" hook in the paint loop. + +### C. Click-to-edit section overlays ("section edit", wiki-style) +Like B but coarser: double-click swaps a whole section (heading + until next +heading) for a plain TextEdit overlay; rest stays rendered. No block model +finer than headings needed (reuse `parse_headers` offsets). + +- Cheaper than B (M effort), noticeably less slick; good intermediate step. + +### D. Full block-graph canvas editor ("Slack canvas style") +Restructure storage into typed blocks with drag handles/slash menus, serialize +back to markdown on save. + +- Directly conflicts with the app's promise (faithful rendering of *arbitrary* + user markdown: tables, footnotes, math, mermaid, HTML blocks — all lossy or + unrepresentable in a hand-built block schema). Notion's own model pays this + cost deliberately; Slack notably did *not* — canvases store plain markdown. +- Largest effort (XL): storage rewrite, DnD reorder UI, per-block chrome, + round-trip tests. Rejected for this codebase's goals; revisit only if the + product pivots to "canvas notes app". + +### E. Embed a web editor (CodeMirror 6 / MilkDown in a wry webview) +Instant fidelity to Obsidian's UX; but wry's GL-coexistence examples are +GTK-only on Linux — eframe/glow (winit/glutin surface) and WebKitGTK's widget +tree can't be mixed in one window, no eframe example or egui↔wry bridge crate +exists. On Win/macOS hole-punching puts the webview *above* the GL surface, so +egui chrome can't overlay it and input/z-order fights ensue. Also adds a +browser engine + an IPC bridge for file IO, breaking the ~35 MB native-footprint +story. Rejected on weight/coherence. + +### F. External-editor handoff (baseline) +"Edit" menu item opens `$EDITOR`/system default; the existing watcher (F5) +live-refreshes the viewer. Near-zero code; useful stopgap and already 80% +present via watch mode. + +## 4. Comparison matrix + +| Axis | A/A′ two-pane | C section-overlay | B block-swap | D block-canvas | E webview | +|------|--------------|-------------------|--------------|----------------|-----------| +| "Live preview" feel | ✗ (split attention) | partial | **✓ closest to Obsidian** | ✓✓ canvas-y | ✓✓ | +| `.md` stays source of truth | ✓ | ✓ | ✓ | ✗ (lossy risk) | ✓ (if wired so) | +| Effort / risk in this codebase | S | M | M/L (incremental) | XL | L + foreign deps | +| Fork changes needed | none | none–minor | moderate (block segmentation + active-block paint hook + per-block cache keys) | large | none | +| Perf on large docs | preview side fine (F1); editor TextEdit re-lays-out whole doc galley per keystroke — lags on huge files (epaint#4000) | same as A | **best native option**: small per-block TextEdits dodge monolithic galley cost; per-block cache keys (F11) contain highlight cost; still full repaint each frame (F2) | unknown/new engine | outsourced | +| Editing UX completeness (undo, IME, selection) | TextEdit-grade | TextEdit-grade | TextEdit-grade per block; cross-block gaps initially | bespoke | best-in-class | +| Watcher conflict work (F5) | required | required | required | required | required | +| New infra required (save/dirty, F10) | all of it | all of it | all of it | all of it | all of it | +| Alignment w/ repo patterns (State→Logic→UI→Async) | high | high | high (block model = logic layer) | low | low | + +## 5. Cross-cutting integration work (any approach) + +Ranked blockers from the codebase audit: + +1. **Watcher safety (F5) — blocker #1:** add `dirty: bool` + + `last_saved_hash: u64`; in `reload_changed_tabs` skip reload when incoming + content hash equals own last write; external-change banner offering + Reload / Keep-mine. Touches `Tab::reload`, `check_file_changes`. +2. **Save path (F10):** Ctrl+S → async write channel (never in UI); dirty + marker in tab title/window title; optional autosave debounce; + `PersistedState` must not silently drop unsaved buffers on restore. +3. **Per-block cache keys (F11):** re-key syntect `syntax_layouts` by block + source hash so a keystroke re-highlights one block, not the document. +4. **Derived-cache refresh:** outline headers, local links, search matches + rebuild debounced (~250 ms idle) instead of per load (F6). +5. **Scroll anchoring while typing above viewport:** version-guard the + recorded-y corrective scrolls (F12), reusing the existing machinery + (`main.rs:2869-2891`) only when `content_version` hasn't changed mid-jump. +6. **Keyboard map:** Ctrl+E toggle edit/render; Ctrl+S save; Tab/Shift-Tab + indent inside list blocks; arrows crossing block boundary commit+move. + +## 6. Recommendation + +Phased, each phase independently shippable: + +1. **Phase 0 — save/dirty/watcher plumbing (+ F, edit-in-$EDITOR)**: + blockers #1–2 fixed as pure app-level work. Unblocks everything. +2. **Phase 1 — A′/A (mode-toggle source editing; optional side-by-side)**: + proves the save/dirty/conflict loop end-to-end with zero fork changes. +3. **Phase 2 — C (section click-to-edit)**: first rendered-surface editing; + exercises hit-testing + swap UX using existing heading offsets. +4. **Phase 3 — B (block-level live preview)**: generalize C with the + event-span block model (F1+F2) and per-block highlight keys (F11); + fork gets an "active block override" hook in the paint loop. This is the + Obsidian-like flagship. +5. **D/E explicitly out of scope** for the viewer's mission. + +Rough sizing (focused work): P0 days, P1 ~1 wk, P2 ~1–2 wk, P3 multi-week +incremental behind a flag. + +## 7. Sources & evidence + +Product architecture (fetched primary sources): +- CodeMirror 6 guide — transactions, decorations, viewport-only rendering: +- Obsidian dev docs — "Obsidian uses CodeMirror 6": +- Obsidian help — Live Preview vs Source mode definitions: +- atomic-editor (CM6 live-preview clone) — decoration mechanism + CLS layout-thrash lesson: +- Typora positioning & bundled libs: · +- Slack Canvas API — markdown `document_content` model, 1 MiB cap: +- Notion block object schema: +- Zed / GPUI native rendering: · + +Local code evidence: file:line references in the table in §1 and throughout +§3–5 were verified by direct inspection of this repository at commit `121ae04` +(`src/main.rs`, `crates/egui_commonmark/`) and of egui 0.33.3 + pulldown-cmark +0.13 sources in the local cargo registry (`text_edit/{builder,state,output}.rs`, +`data/input.rs`, `parse.rs`). + +Ecosystem (fetched primary metadata/sources): +- egui TextEdit limits / galley re-layout issue: +- IME pre-edit invisible on X11+Fcitx5 (0.33-era): · composition overhaul: +- `egui_code_editor` (maintained fork; 0.2.20/21 = egui ^0.33): +- Ferrite — egui editor with split view + one-click rendered-block editing: +- GPUI prior art: · +- wry GL/webview coexistence examples (GTK-only): +- Ropes: · diff --git a/docs/research/editing-architecture-overview.md b/docs/research/editing-architecture-overview.md new file mode 100644 index 0000000..16f6812 --- /dev/null +++ b/docs/research/editing-architecture-overview.md @@ -0,0 +1,145 @@ +# Editing Architecture Overview + +**Status:** Decision document · **Branch:** `feature/editing` · **Supersedes:** §6 phasing in `editing-approaches.md` + +Which architecture should md-viewer use for live-preview editing? This document +lays out every viable option, the evidence for each, and a concrete +recommendation with a migration path from the current prototype. + +--- + +## 1. The problem, stated precisely + +egui has no contentEditable. A browser (Notion, Slack Canvas, Tiptap) edits +*inside* the rendered result because the DOM both renders and accepts text +input. In egui, `TextEdit` is plain text: it cannot contain rendered headings, +images, or math, and its glyphs come from one monospace-ish font run unless a +custom layouter intervenes. So "render and edit the same text" must be +reconstructed by hand. Every candidate below is a different answer to one +question: **where does the editing surface live relative to the rendering +surface?** + +## 2. Evidence base (all verified) + +| Source | Key finding | +|---|---| +| **Obsidian/CM6** | Raw markdown buffer + `Decoration.replace` hides syntax outside the caret; viewport-only paint; transactions | +| **Typora** | DOM-only trick; no native equivalent exists | +| **Notion / Slack Canvas** | Rich block tree is canonical; markdown only at import/export (Canvas API stores plain markdown!) | +| **transcendence (our own project)** | Tiptap (ProseMirror schema) + Yjs CRDT (`yrs`/Axum) + Vue — rich-tree canonical, DOM does render+edit | +| **Ferrite ★1.8k (source dive)** | Persistent *styled* TextEdits per block — headings are always frameless single-line editors at heading scale; structural syntax hidden by **buffer design** (commit re-adds `#`/markers on exit); formatted spans shown as styled galleys when inactive, raw while active; keystrokes never touch source (per-block buffers, commit-on-exit, epoch-keyed widget IDs); caret via `galley.cursor_from_pos`; dismissal via global clicked-outside flag ("rect hit tests unreliable") | +| **egui 0.33 source + tracker** | `TextEdit::layouter` returns a full `Arc`; ALL cursor/selection hit-testing runs on the returned galley and `CCursor.index` is a **char** offset — equal-char-count glyph substitution keeps every mapping correct (in-tree proof: password masking). Per-section styling confirmed (`TextFormat`: size/weight/color/italics/underline/line-height; `LayoutJob::format_at_byte`, PR #8244). ⚠️ egui#5885 (open): **clipboard text comes from the galley** — never skip bytes; equal-count substitution keeps copy/paste intact (hidden markers copy as blanks) | +| **Zed** | Inline-rendered markdown *editing* is still an OPEN feature request (zed#21717, citing Obsidian); preview is a separate pane. Even Zed hasn't built it | +| **Ecosystem sweep 2026-08** | GitHub+crates.io searched ("egui richtext/wysiwyg/markdown/code editor"): **no maintained egui component provides inline syntax-hiding editing**. Ferrite is the only shipped rendered-mode editing app, engine unpublished. Render-only: tektite, membrane-io/egui_markdown, imgui_markdown 1.3k★, imgui_md. Dual-pane: md-echo, rustdown. Popover pattern: only Obsidian Hover Editor (Electron). Minor finds: Lilo, text-typeset | +| **Our v1 prototype** | Block-swap works end-to-end after 5 fixes (cache keying, boundary backfill, screen→content space, auto-focus, cut-segment ranges). Confirmed weaknesses: raw look, swap jump | + +## 3. Candidate architectures + +### A. Current v1 — swap-in plain TextEdit *(shipped)* +Rendered doc; clicked block becomes a monospace `TextEdit` seeded from its byte range. +- ✓ Works; minimal surface. ✗ Ugly (raw markdown), swap jump, caret lands wrong, typing felt dead until auto-focus fix. + +### B+. Layouter-decorated swap *(upgrade of B — **implemented**, devlog 057)* +Same swap, but the active block paints through a custom `layouter`: markup +punctuation hidden via equal-char-count substitution (e.g. `**`→two spaces), +bold/heading/code runs styled per-section, caret line reveals raw syntax. +- ✓ True Obsidian feel inside stock TextEdit; cursor/selection/IME intact by + the char-index contract; copy/paste safe as long as bytes are never skipped + (egui#5885 constraint). Biggest perceived-quality jump for days of work. +- ✗ Still a *swap*: entering/leaving changes geometry (CLS risk documented in + CM6 land); only one block ever looks rendered. + +### C. Persistent Styled Editors — **recommended** *(Ferrite model, adapted)* +Live mode stops swapping entirely. Every top-level block is **permanently an +editor**, styled to look like its rendered form: + +| Block kind | Surface in Live mode | +|---|---| +| Heading | Single-line `TextEdit`, heading font size/weight/color, `# ` stripped from buffer | +| Paragraph / list item / quote line | Multiline `TextEdit`, body font, `- `/quote markers stripped; list indent preserved | +| Inline bold/italic/code/links | Styled via `layouter` (dimmed markers, styled runs) — password-mask technique | +| Code fence | Mono `TextEdit` (already looks right) | +| Image / math / mermaid / html-table | **Rendered widget, read-only** — interleaved from the fork's normal pipeline (fidelity the editors can't provide) | + +Commit semantics (Ferrite-proven): keystrokes mutate only per-block buffers; +on block exit the buffer is re-materialized into markdown (`# ` + text) and +spliced once. Widget IDs key off `(block identity, source_epoch)` — never a +content hash — so commits don't remap focus (the exact race that made our v1 +feel broken). Caret placement on activation: build a display galley with the +same metrics and `cursor_from_pos(click)` — kills the offset class of bugs. +Dismissal: global "clicked outside active block" flag, not rect tests. + +- ✓ Looks rendered *and* is always editable; no swap jump; scroll-stable; + keeps `.md` canonical; preserves math/mermaid/image fidelity via interleave. +- ✗ Biggest build of the realistic options; per-block buffers + commit/undo + bookkeeping; two sources of truth during a block session (manageable — + Ferrite shipped it). + +### D. Custom decoration engine ("mini-CodeMirror") +Own galley assembly, hidden ranges, hit-testing, IME over one big buffer. +True CM6 parity including reveal-at-caret. **XL greenfield** — revisit only if +md-viewer pivots to editor-product territory. + +### E. Webview + Tiptap/Yjs *(transcendence stack)* +Instant WYSIWYG; wry↔eframe/glow coexistence is GTK-hack territory, browser +engine weight, IPC file bridge. Rejected previously; stands. + +### F. Block-graph storage (Notion-style) +Lossy against arbitrary user markdown; against the mission. Rejected; stands. + +## 4. Comparison + +| Axis | A (v1) | B+ layouter swap | C persistent styled | D custom engine | E webview | F block-graph | +|---|---|---|---|---|---|---| +| Feels rendered while editing | ✗ | partial | **✓** | ✓✓ | ✓✓ | ✓✓ | +| Swap jump / CLS | ✗ | ✗ | **none** | none | none | none | +| Caret accuracy | poor | medium | **good (galley)** | best | best | n/a | +| `.md` canonical | ✓ | ✓ | **✓** | ✓ | ✓ | ✗ | +| Math/mermaid/image fidelity in edit mode | ✓ (outside block) | ✓ | **✓ (interleaved widgets)** | hard | ✓ | lossy | +| Effort from current state | done | S/M | **M/L** | XL | L+fragile | XL | +| Prior art in egui | ours | none known | **Ferrite (proven)** | none | external | Ferrite-lite | + +## 5. Recommendation + +**Adopt C (Persistent Styled Editors) as the Live-mode target, reached through +B+ as the first shipping step.** Rationale: C is the only option that fixes +all three of your reported complaints structurally (position via galley +caret, look via always-styled editors, jump via no-swap) while keeping the +viewer's fidelity promises and the `.md` file canonical. It is also the only +option with shipped prior art in egui (Ferrite) whose design docs validate the +hard parts we already stumbled over. + +The independent ecosystem scan ranks B+ (styled layouter inside the existing +swap) as the single best effort/payoff move — days of work, cursor-safe by +the char-index contract, and it directly addresses the "doesn't look rendered" +complaint. The two recommendations compose: **ship B+ now, evolve to C.** +Both scans independently rule out D (single-surface WYSIWYG): no prior art in +egui, ImGui, or even Zed (open feature request #21717) — cost far exceeds +payoff over the block-swap family. + +### Migration plan from current v1 + +1. **PSE-0 — correctness debt on v1** *(done)*: cache-keying, backfill, + content-space boundaries, auto-focus. +2. **PSE-1 — persistent editors for headings/paragraphs/lists** in Live mode: + strip/re-add structural markers at commit; per-block buffers; epoch-keyed + IDs; galley-caret activation; global dismiss flag. Rendered mode untouched. +3. **PSE-2 — layouter inline decoration**: dim/hide emphasis markers, styled + runs inside the active block (password-mask technique); code fences become + mono editors. +4. **PSE-3 — interleave rendered widgets** for image/math/mermaid/tables + between editors; tables stay read-only initially. +5. **PSE-4 (optional)** — reveal-raw-at-caret-line (full Obsidian parity), + slash/format shortcuts. + +Each step ships independently behind Live mode; Rendered/Source modes are +untouched throughout. + +## 6. Open questions + +- Undo across blocks: per-block Undoer + commit-level stack (Ferrite's + `rendered_commit_undo` pattern) — needs design before PSE-2. +- Very large docs: PSE paints N editors/frame like Ferrite; acceptable up to + ~10k events per the fork's bootstrap measurements; virtualization later. +- IME inside restyled layouters: password fields prove glyph substitution + composes with IME; verify CJK early in PSE-2. diff --git a/docs/research/editing-state.md b/docs/research/editing-state.md new file mode 100644 index 0000000..b9d1998 --- /dev/null +++ b/docs/research/editing-state.md @@ -0,0 +1,134 @@ +# Editing Feature — Current State + +**Branch:** `feature/editing` (worktree `worktrees/editing`) — 30 commits ahead of `main` +**Delta:** +4,212 / −61 across 26 files (`src/main.rs`, vendored fork, docs, scripts) +**Status:** PSE v1 complete and manually verified; ready for PR review +**Last updated:** 2026-02 (session of 2026-08-24) + +--- + +## 1. What exists now + +### Editing modes (per tab, Ctrl+E cycles; View menu has all three) + +| Mode | Behavior | +|---|---| +| **Rendered** | Original viewer, untouched | +| **Live (PSE)** | Every text block is a permanently-styled TextEdit laid out in document flow; images/math/mermaid/tables/code render normally between editors | +| **Source** | Whole-file monospace editor | + +### Live mode (PSE) — the flagship + +- **Fidelity**: editors adopt renderer typography by construction — Major Third + heading scale (H1 2.0× → H6 0.875×) on the strong family, tight heading line + height (~1.3× equiv), body 1.5× line height, weak color for quotes, + typography spacing above/below headings and after paragraphs. Full-width + top-down rows so positions match the rendered view across Ctrl+E toggles. +- **Inline formatting**: strong/emphasis/code/link runs styled per-section; + structural markers (`# `, `- `, `> `, `**`, backticks, link URLs) hidden via + equal-char-count glyph substitution — cursor/selection/IME math exact. +- **Caret-line reveal**: the caret's line shows raw markdown; everything else + in the block stays styled (Obsidian Live Preview signature). +- **Session buffers**: keystrokes mutate per-block egui-temp buffers only. + Serialization (buffers → markdown with markers re-materialized) happens on + **Ctrl+S** and on **mode exit** — never mid-typing, so no reflow races. + +### Phase 0 infrastructure (all modes benefit) + +- Atomic background saves (temp file + rename), async worker thread +- Dirty tracking with synced/in-flight content hashes; dirty markers in tab + bar and window title +- Watcher self-write suppression via pure `classify_disk_change()` policy; + external-change conflict banner (Reload from disk / Keep my version) +- Unsaved-changes guards on close/quit (Save / Discard / Cancel) +- File → Save / Open in External Editor menu entries + +### MCP test automation (new capability) + +- `mcp` cargo feature registers mode/save buttons with the egui-mcp bridge +- `scripts/mcp_e2e.py`: JSON-RPC driver — menu clicks, focus, type_text, + get_value round-trip, save, disk verification +- `MDV_SIM_CLICK_Y` env hook injects synthetic clicks through the real path +- `MDV_EDIT_DEBUG=1` enables per-frame/per-block paint tracing + +--- + +## 2. Architecture summary + +``` +Live frame: + app builds EditSessionConfig { salt, blocks[{src, kind}] } + ← spans from cached events + kinds from first-line sniffing + fork paints each event stream position: + text block → Frame > TextEdit(multiline, markdown_block_job layouter, + per-block temp buffer, caret temp) + non-text block → normal renderer widgets (image/math/mermaid/table/code) + after paint: + feedback = cache.get_session_feedback(salt) // non-destructive (multi-pass!) + changed buffers update Tab.session_buffers, set dirty +Serialize (Ctrl+S / mode exit): + walk blocks: edited → materialize(kind, buffer) // markers re-added + passthrough → original slice + replace Tab.content atomically; bump version; mark_edited +``` + +Key invariants: +- Char-count-preserving substitution keeps CCursor math exact (egui#5885 safe) +- Keystrokes never touch source mid-session; serialize is a single fold +- Session state resets on reload/load_file (fresh seed from disk) +- Editors paint in explicit-size top-down rows (inline-flow would cascade them + sideways — fixed and regression-documented) + +## 3. Verification state + +| Suite | Count | Status | +|---|---|---| +| App unit tests | 57 | ✓ | +| Backend lib (incl. 7 styler, segmentation) | 24 | ✓ | +| Frontend lib + live_preview e2e ×3 | 37+3 | ✓ | +| live_session e2e (editors paint, keystroke routing) | 1 | ✓ | + +Manual: click/type/save loop verified on desktop across sessions; crash paths +(stale-range panic) hardened; `RUST_BACKTRACE=1` launches available. + +## 4. Known limitations (v1) + +1. Lists edit as one raw unit (markers visible); no per-item editing +2. Arrow keys don't cross block boundaries +3. Search/outline operate on last-serialized content while typing +4. Copying selected text inside a styled editor yields galley text (hidden + markers copy as blanks) — egui#5885 constraint +5. No formatting shortcuts (Ctrl+B/I), hover toolbar, or slash commands +6. IME composition inside restyled layouters untested (CJK verification pending) +7. Conflict banner only shows for the active tab +8. Very large docs (>10k events) repaint fully every frame (fork limitation, + pre-existing) + +## 5. Suggested next steps (priority order) + +1. **PR review + merge** of `feature/editing` into main +2. Manual CJK/IME pass (wtype or real keyboard) +3. Arrow-key block crossing (needs caret-edge signal from fork) +4. Formatting shortcuts (Ctrl+B/I/K inserting markers at selection) +5. Per-item list editing (split ListItem blocks further) +6. Virtualization restoration in fork for huge docs + +## 6. Key files + +| Path | Role | +|---|---| +| `src/main.rs` | Modes, session state, save pipeline, watcher policy, UI wiring | +| `crates/.../backend/src/styler.rs` | `markdown_block_job` + style config (the look) | +| `crates/.../backend/src/misc.rs` | Session config/feedback, boundaries, cache APIs | +| `crates/.../egui_commonmark/src/parsers/pulldown.rs` | Session paint loop, boundary recording | +| `docs/research/editing-approaches.md` | Research + decision record | +| `docs/research/editing-architecture-overview.md` | Architecture comparison | +| `docs/devlog/055–058` | Implementation narratives | + +## 7. Debug hooks + +| Hook | Effect | +|---|---| +| `RUST_LOG=info` | Mode switches, activations, seeds, splices | +| `MDV_EDIT_DEBUG=1` | Per-block painted rects + activation marker file | +| `MDV_SIM_CLICK_Y=` | Inject synthetic click at document offset (headless) | diff --git a/scripts/mcp_e2e.py b/scripts/mcp_e2e.py new file mode 100644 index 0000000..9e21d5e --- /dev/null +++ b/scripts/mcp_e2e.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""MCP-driven e2e test for PSE Live editing. + +Drives the real app over the egui-mcp bridge (JSON-RPC TCP :9877): +Ctrl+E -> Live mode, then verifies per-block editors exist, routes a +keystroke into block 1, reads back the buffer, saves, and checks disk. + +Usage: python3 scripts/mcp_e2e.py +Exit 0 = all checks pass. +""" +import socket, json, time, sys, subprocess + +class Bridge: + def __init__(self, port=9877): + self.s = socket.create_connection(("127.0.0.1", port), timeout=10) + self.rid = 0 + def rpc(self, method, params=None): + self.rid += 1 + req = {"jsonrpc": "2.0", "id": self.rid, "method": method} + if params is not None: + req["params"] = params + self.s.sendall((json.dumps(req) + "\n").encode()) + data = b"" + while b"\n" not in data: + c = self.s.recv(1 << 16) + if not c: + raise RuntimeError("bridge closed") + data += c + resp = json.loads(data.decode()) + if "error" in resp: + raise RuntimeError(f"{method}: {resp['error']}") + return resp.get("result") + def snap(self): + r = self.rpc("get_snapshot") + return r["tree"] if isinstance(r, dict) else str(r) + def key(self, name, ctrl=False): + mods = ["ctrl"] if ctrl else [] + return self.rpc("send_key", {"key": name, "modifiers": mods, "press_only": False}) + def type_text(self, t): + return self.rpc("type_text", {"text": t}) + def click_ref(self, ref): + return self.rpc("click", {"node_id": int(ref[1:])}) + def find(self, sub): + hits = [l.split("ref=")[1].split("]")[0] for l in self.snap().split("\n") if sub in l and "ref=" in l] + return hits[0] if hits else None + +def textboxes(tree): + out = [] + for line in tree.split("\n"): + if ("textbox" in line.lower() or "edit" in line.lower()) and "ref=" in line: + ref = line.split("ref=")[1].split("]")[0] + name = line.split("ref=")[0] + out.append((ref, name.strip())) + return out + +def main(): + b = None + for _ in range(20): + try: + b = Bridge(); break + except OSError: + time.sleep(0.5) + assert b, "bridge never came up on :9877" + time.sleep(0.3) + + # 1) Enter Live mode via View menu (registered buttons) + menu = b.find('button "Menu: View"') + b.click_ref(menu) + time.sleep(0.6) + live = b.find("Live Preview") + assert live, "Live Preview button missing from menu" + b.click_ref(live) + time.sleep(0.8) + tree = b.snap() + assert "Live" not in tree or True # mode isn't a widget; rely on editors below + + # 2) Session editors appear as editable nodes + boxes = textboxes(tree) + print(f"[1] editable nodes after Ctrl+E: {len(boxes)}") + assert len(boxes) >= 4, f"expected >=4 editors, got {len(boxes)}\n{tree[:1500]}" + + # 3) Focus the SECOND editor and type + ref = boxes[1][0] + b.rpc("focus", {"node_id": int(ref[1:])}) + time.sleep(0.3) + b.type_text("ZZ") + time.sleep(0.4) + + # 4) Read back via get_value + val = b.rpc("get_value", {"node_id": int(ref[1:])}) + vtxt = json.dumps(val) + print(f"[2] get_value({ref}): {vtxt[:120]}") + assert "ZZ" in vtxt, f"keystroke missing from buffer: {vtxt[:200]}" + + # 5) Save (Ctrl+S) and check disk + b.key("s", ctrl=True) + time.sleep(0.6) + disk = open("test-doc.md").read() + assert "ZZ" in disk, "serialized content missing typed text" + print("[3] disk contains typed text ✓") + + # 6) Exit Live (ctrl+e) and confirm rendered mode restored + file intact + b.key("e", ctrl=True) + time.sleep(0.5) + disk2 = open("test-doc.md").read() + assert disk2 == disk, "file changed on mode exit" + print("[4] PASS: full loop verified") + +if __name__ == "__main__": + main() diff --git a/shot.png b/shot.png new file mode 100644 index 0000000..50b9c83 Binary files /dev/null and b/shot.png differ diff --git a/shot2.png b/shot2.png new file mode 100644 index 0000000..1d84e5e Binary files /dev/null and b/shot2.png differ diff --git a/src/main.rs b/src/main.rs index a39e7ee..42e19c3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,12 +10,16 @@ use std::os::unix::ffi::OsStringExt; use std::os::unix::net::UnixStream; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::mpsc::{self, Receiver, TryRecvError}; +use std::sync::mpsc::{self, Receiver, Sender, TryRecvError}; +use std::sync::LazyLock; use std::time::{Duration, Instant}; use clap::Parser; use eframe::egui; -use egui_commonmark_extended::{header_position_key, CommonMarkCache, CommonMarkViewer}; +use egui_commonmark_extended::{ + header_position_key, CommonMarkCache, CommonMarkViewer, EditBlockKind, EditRegionConfig, + EditSessionConfig, SessionBlock, +}; use notify::{PollWatcher, RecommendedWatcher}; use notify_debouncer_mini::{new_debouncer, new_debouncer_opt, DebouncedEventKind, Debouncer}; use regex::Regex; @@ -60,6 +64,196 @@ const CONTENT_RIGHT_RESIZE_GUTTER: i8 = 10; const KEYBOARD_LINE_SCROLL_STEP: f32 = 48.0; const KEYBOARD_PAGE_SCROLL_RATIO: f32 = 0.9; +// Idle period after the last keystroke before derived caches (outline headers, +// local links, line counts, search matches) are rebuilt. Keeps typing snappy +// while still converging within a quarter second of pause. +const DERIVED_REFRESH_DEBOUNCE: Duration = Duration::from_millis(250); + +/// Hash a content string for dirty/sync tracking. Uses the same +/// `DefaultHasher` scheme as the vendored renderer's event cache. Hashes are +/// only compared within one process lifetime, so cross-run stability is not a +/// requirement. +fn hash_content(content: &str) -> u64 { + use std::hash::{Hash, Hasher}; + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + content.hash(&mut hasher); + hasher.finish() +} + +/// Convert a byte offset in `text` into the char index that egui cursor APIs +/// (`CCursor.index`) expect. Offsets past the end clamp to the char count; +/// offsets landing mid-UTF-8-sequence round down to the containing char start. +fn byte_offset_to_char_index(text: &str, byte_offset: usize) -> usize { + let clamped = byte_offset.min(text.len()); + let boundary = match clamped { + 0 => 0, + n if n >= text.len() => text.len(), + _ => { + // Walk back to a UTF-8 char boundary (floor division semantics). + let mut b = clamped; + while !text.is_char_boundary(b) { + b -= 1; + } + b + } + }; + text[..boundary].chars().count() +} + +/// Recompute the live-edit anchor after the edited block's text changed +/// length. The anchor keeps its relative position inside the block, clamped +/// to the new block length. +fn anchor_after_splice( + anchor: usize, + range_start: usize, + range_end: usize, + new_len: usize, +) -> usize { + let within = anchor.saturating_sub(range_start).min(range_end - range_start); + range_start + within.min(new_len) +} + +/// Byte offset of the start of a 1-based `line` in `text`. Lines beyond the +/// document clamp to the end of the text. +fn byte_offset_of_line_start(text: &str, line: usize) -> usize { + if line <= 1 { + return 0; + } + let mut offset = 0usize; + for (i, bytes) in text.split_inclusive('\n').enumerate() { + if i + 1 >= line { + break; + } + offset += bytes.len(); + } + offset.min(text.len()) +} + +/// Classify a top-level block's first line into its editing surface kind. +/// Mirrors what `pulldown-cmark` would emit for the block opener. +fn block_kind_of(block_text: &str) -> EditBlockKind { + let Some(first) = block_text.lines().next() else { + return EditBlockKind::Paragraph; + }; + let t = first.trim_start(); + let hashes = t.chars().take_while(|&c| c == '#').count(); + if (1..=6).contains(&hashes) + && t[hashes..] + .chars() + .next() + .is_some_and(|c| c == ' ' || c == '\t') + { + return EditBlockKind::Heading(hashes as u8); + } + if t.starts_with('>') { + return EditBlockKind::Quote; + } + let body = t.trim_start_matches([' ', '\t']); + if matches!(body.as_bytes().first(), Some(b'-' | b'*' | b'+')) + && body[1..].starts_with(' ') + { + return EditBlockKind::ListItem; + } + let digits = body.chars().take_while(|c| c.is_ascii_digit()).count(); + if digits > 0 && body[digits..].starts_with(". ") { + return EditBlockKind::ListItem; + } + if body.starts_with("```") { + return EditBlockKind::CodeBlock; + } + EditBlockKind::Paragraph +} + +/// How the watcher should react to an on-disk change for one open tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum DiskChange { + /// No-op echo or our own write — ignore entirely. + Ignore, + /// Buffer already matches disk (edits undone back to saved state) — + /// re-sync bookkeeping. + Resync, + /// External change while the buffer has unsaved edits — ask the user. + Conflict, + /// Clean external change — reload from disk. + Reload, +} + +/// Pure decision core of `reload_changed_tabs` so the watcher policy stays +/// unit-testable. +fn classify_disk_change( + synced_content_hash: u64, + buffer_hash: u64, + dirty: bool, + in_flight_save_hash: Option, + incoming: u64, +) -> DiskChange { + // Pure echo of unchanged content. + if !dirty && incoming == synced_content_hash { + return DiskChange::Ignore; + } + // Our own in-flight save observed on disk. + if in_flight_save_hash == Some(incoming) { + return DiskChange::Ignore; + } + // Live buffer already equals what's on disk. + if in_flight_save_hash.is_none() && buffer_hash == incoming { + return DiskChange::Resync; + } + if dirty { + DiskChange::Conflict + } else { + DiskChange::Reload + } +} + +/// Open `path` with the system's default handler (Linux desktops: xdg-open). +/// Fire-and-forget: the child outlives the app, and we never wait on it. +fn open_external(path: &Path) { + match Command::new("xdg-open").arg(path).spawn() { + Ok(_) => log::info!("Opened {:?} in external application", path), + Err(e) => { + log::error!("Failed to open {:?} externally: {}", path, e); + } + } +} + +/// Render a tab in source-editing mode: a monospace multiline TextEdit bound +/// directly to `tab.content`, inside a ScrollArea that follows the caret +/// (`ui.scroll_to_rect` from the TextEdit targets this enclosing scroller). +/// Buffer changes only mark the tab dirty; heavy derived work happens +/// debounced in the update loop. +fn render_source_editor_ui(ui: &mut egui::Ui, tab: &mut Tab) { + let editor_id = tab.source_editor_id(); + + egui::ScrollArea::vertical() + .id_salt("source_editor_scroll") + .show(ui, |ui| { + let response = egui::TextEdit::multiline(&mut tab.content) + .id(editor_id) + .code_editor() + .desired_width(ui.available_width()) + .show(ui); + + if response.response.changed() { + tab.mark_edited(Instant::now()); + } + + // Consume pending caret jumps after the widget exists so its + // state is in the context store. Byte offsets from search/header + // machinery are converted to the char indices egui cursors use. + if let Some(byte_off) = tab.pending_caret_byte.take() { + let char_idx = byte_offset_to_char_index(&tab.content, byte_off); + if let Some(mut state) = egui::TextEdit::load_state(ui.ctx(), editor_id) { + state.cursor.set_char_range(Some( + egui::text::CCursorRange::one(egui::text::CCursor::new(char_idx)), + )); + state.store(ui.ctx(), editor_id); + } + response.response.request_focus(); + } + }); +} + // App-level keyboard scroll actions; kept private until shortcut wiring needs // to pass them through `MarkdownApp::update`. #[derive(Clone, Copy, Debug)] @@ -495,7 +689,19 @@ struct ParsedHeaders { outline_headers: Vec
, } -/// A single match in a tab's content, identified by byte range and 1-based line number +/// Editing surface of a tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum EditMode { + /// Pure rendered view (original viewer behavior). + Rendered, + /// Whole-file raw markdown TextEdit. + Source, + /// Obsidian-style live preview: everything rendered except the active + /// top-level block, which shows as an inline raw-text editor. + Live, +} + +/// A search match identified by byte range and line number #[derive(Clone, Debug, PartialEq, Eq)] struct SearchMatch { byte_start: usize, @@ -503,6 +709,36 @@ struct SearchMatch { line_number: usize, } +// ---- Background save pipeline (State→Logic→UI→Async: thread does I/O, +// update() only polls the outcome channel) ---- + +/// A snapshot queued for asynchronous write to disk. +struct SaveJob { + path: PathBuf, + content: String, + /// Hash of `content`; echoed back so the app can tell stale outcomes + /// from fresh ones. + hash: u64, +} + +/// Result of one [`SaveJob`] written by the worker thread. +struct SaveOutcome { + path: PathBuf, + hash: u64, + result: io::Result<()>, +} + +/// A user confirmation awaiting a decision because closing/quitting would +/// abandon unsaved edits. Tracks the affected tab by path (indices shift +/// while a dialog is open). +#[derive(Clone)] +enum UnsavedConfirm { + /// Close the tab with this path. + CloseTab(PathBuf), + /// Quit the whole app. + Quit, +} + /// Per-frame return from `render_search_bar` #[derive(Default)] struct SearchBarOutcome { @@ -1022,6 +1258,53 @@ struct Tab { /// parsed events and split_points can survive across frames without /// re-hashing the entire content. content_version: u64, + // ---- Editing state (Phase 0/1: source mode + save pipeline) ---- + /// Hash of the buffer as last known to be in sync with the file on disk + /// (set on load, reload, and completed save). + synced_content_hash: u64, + /// Hash of the snapshot currently being written by the background save + /// thread. `Some` while a save is in flight; lets the watcher recognize + /// our own write events even before the completion message arrives. + in_flight_save_hash: Option, + /// Unsaved modifications exist in the buffer. + dirty: bool, + /// External on-disk change detected while `dirty` — a conflict awaiting + /// the user's choice (reload vs keep). Holds the on-disk hash so repeated + /// watcher events for the same change dedupe. + external_change_hash: Option, + /// Source editing mode: raw markdown TextEdit instead of rendered view. + edit_mode: EditMode, + /// Live preview: byte offset inside the currently-edited top-level block + /// (`None` = view-only, everything rendered). The block is resolved from + /// this anchor every frame so re-segmentation can't strand it. + active_edit_byte: Option, + /// Which (content_version, block_start) the inline editor buffer was last + /// seeded for — prevents clobbering in-flight typing with re-seeds. + live_seeded_for: Option<(u64, usize)>, + /// PSE session: live text per top-level block, parallel to serialized + /// segmentation. Buffers hold marker-stripped text for styled kinds. + session_buffers: Vec, + /// Kinds per block at seed time (parallel to session_buffers). + session_kinds: Vec, + /// True when any session buffer differs from its seeded snapshot. + session_dirty: bool, + /// Last painted session blocks (survives cache-refresh gaps). + session_last_blocks: Vec, + /// Derived caches (`outline_headers`, `local_links`, `content_lines`, + /// `search_matches`) are stale relative to `content` after an edit. + derived_stale: bool, + /// Time of the last buffer modification, for debounced derived refresh. + last_edit_at: Option, + /// Set when the user chose "Save & Close" in the unsaved-changes dialog: + /// the tab is removed once its save completes cleanly. + close_after_save: bool, + /// Byte offset to move the source-editor caret to (search jumps, outline + /// clicks while in source mode). Consumed by the editor render pass. + pending_caret_byte: Option, + /// Top-left of the rendered content area last frame (inner_rect.min), + /// used to convert pointer clicks into content-relative y for + /// live-preview block hit-testing. + last_content_origin: Option, } impl Tab { @@ -1045,6 +1328,8 @@ impl Tab { cache.add_link_hook(link); } + let synced_content_hash = hash_content(&content); + Ok(Self { id: egui::Id::new(&path), path, @@ -1066,6 +1351,22 @@ impl Tab { history_forward: Vec::new(), search_matches: Vec::new(), content_version: 1, + synced_content_hash, + in_flight_save_hash: None, + dirty: false, + external_change_hash: None, + edit_mode: EditMode::Rendered, + active_edit_byte: None, + live_seeded_for: None, + session_buffers: Vec::new(), + session_kinds: Vec::new(), + session_dirty: false, + session_last_blocks: Vec::new(), + derived_stale: false, + last_edit_at: None, + close_after_save: false, + pending_caret_byte: None, + last_content_origin: None, }) } @@ -1076,12 +1377,200 @@ impl Tab { .unwrap_or_else(|| "Unknown".to_string()) } + /// Tab/window label including the unsaved-changes marker. + fn display_title(&self) -> String { + if self.dirty { + format!("{} •", self.title()) + } else { + self.title() + } + } + + /// Stable widget id for this tab's source editor. + fn source_editor_id(&self) -> egui::Id { + egui::Id::new(&self.path).with("source_editor") + } + fn reload(&mut self) -> io::Result<()> { let content = String::from_utf8_lossy(&fs::read(&self.path)?).into_owned(); self.apply_loaded_content(self.path.clone(), content, false); Ok(()) } + /// Record a user modification of the buffer. Cheap on purpose: heavy + /// derived work is deferred to the debounced `refresh_derived` pass. + fn mark_edited(&mut self, now: Instant) { + self.dirty = true; + self.derived_stale = true; + self.last_edit_at = Some(now); + } + + // ---- PSE (Persistent Styled Editors) session ---- + + /// Seed (or re-seed) session buffers from the serialized content's + /// top-level blocks. Marker-stripping applies to Heading (leading `# `) + /// and Quote (leading `> `); other kinds keep raw text. + fn session_seed(&mut self) { + let spans = self.cache.top_level_block_spans(&self.id); + let mut buffers = Vec::with_capacity(spans.len()); + let mut kinds = Vec::with_capacity(spans.len()); + for span in &spans { + if span.end > self.content.len() { + break; // stale cache; remaining blocks invalid this frame + } + let raw = &self.content[span.clone()]; + let kind = block_kind_of(raw); + let stripped = match kind { + EditBlockKind::Heading(_) => { + let t = raw.trim_start_matches('#'); + t.strip_prefix([' ', '\t']).unwrap_or(t).to_string() + } + EditBlockKind::Quote => raw + .lines() + .map(|l| l.strip_prefix("> ").unwrap_or(l.strip_prefix('>').unwrap_or(l))) + .collect::>() + .join("\n"), + _ => raw.to_string(), + }; + buffers.push(stripped.to_string()); + kinds.push(kind); + } + self.session_buffers = buffers; + self.session_kinds = kinds; + self.session_dirty = false; + } + + /// Materialize one buffer back into markdown for its kind. + fn session_materialize(kind: EditBlockKind, buf: &str) -> String { + match kind { + EditBlockKind::Heading(level) => { + let hashes = "#".repeat(level as usize); + let mut out = String::new(); + for (i, line) in buf.split_inclusive('\n').enumerate() { + if i == 0 { + out.push_str(&hashes); + out.push(' '); + } + out.push_str(line); + } + out + } + EditBlockKind::Quote => buf + .split_inclusive('\n') + .map(|l| format!("> {l}")) + .collect(), + _ => buf.to_string(), + } + } + + /// Fold session buffers back into `content`. Returns true when anything + /// changed (content replaced, derived caches invalidated). + fn session_serialize(&mut self) -> bool { + if !self.session_dirty { + return false; + } + let spans = self.cache.top_level_block_spans(&self.id); + let mut out = String::with_capacity(self.content.len() + 256); + let mut cursor = 0usize; + for (i, span) in spans.iter().enumerate() { + if span.start < cursor || span.end > self.content.len() { + break; + } + out.push_str(&self.content[cursor..span.start]); + match self.session_buffers.get(i) { + Some(buf) => { + let kind = self + .session_kinds + .get(i) + .copied() + .unwrap_or(EditBlockKind::Paragraph); + out.push_str(&Self::session_materialize(kind, buf)); + } + None => out.push_str(&self.content[span.clone()]), + } + cursor = span.end; + } + out.push_str(&self.content[cursor..]); + + if out == self.content { + self.session_dirty = false; + return false; + } + self.content = out; + self.content_lines = self.content.lines().count(); + self.content_version = self.content_version.wrapping_add(1); + self.mark_edited(Instant::now()); + true + } + + /// Switch editing mode. Leaving Source/Live with un-refreshed edits + /// rebuilds derived caches immediately so outline, links and search match + /// what gets rendered again. Deactivates any live-edit block. + fn set_edit_mode(&mut self, mode: EditMode) { + if self.edit_mode == mode { + return; + } + if self.edit_mode == EditMode::Live { + self.session_serialize(); + } + self.edit_mode = mode; + self.active_edit_byte = None; + self.live_seeded_for = None; + if mode == EditMode::Rendered && self.derived_stale { + self.refresh_derived(); + } + } + + /// Ctrl+E: cycle Rendered → Live → Source → Rendered so every press + /// visibly changes the surface. + fn cycle_edit_mode(&mut self) { + if self.edit_mode == EditMode::Live { + self.session_serialize(); + } + let next = match self.edit_mode { + EditMode::Rendered => EditMode::Live, + EditMode::Live => EditMode::Source, + EditMode::Source => EditMode::Rendered, + }; + log::info!("edit mode: {:?} -> {:?}", self.edit_mode, next); + self.set_edit_mode(next); + } + + /// Mark the buffer as in-sync with disk at `hash` (after load or a + /// completed save). Only meaningful if the current buffer still hashes to + /// `hash`; callers check that before calling. + /// + /// Deliberately does NOT touch `derived_stale`/`last_edit_at`: a save may + /// complete before the debounced derived-cache rebuild runs, and that + /// rebuild must still happen afterwards. + fn note_synced(&mut self, hash: u64) { + self.synced_content_hash = hash; + self.dirty = false; + self.external_change_hash = None; + self.in_flight_save_hash = None; + } + + /// Rebuild everything derived from `content` after edits (outline, + /// links, line count, renderer cache invalidation). Called debounced from + /// the update loop — never per keystroke. + fn refresh_derived(&mut self) { + self.content_lines = self.content.lines().count(); + self.cache = CommonMarkCache::default(); + self.content_version = self.content_version.wrapping_add(1); + + let parsed = parse_headers(&self.content); + self.document_title = parsed.document_title; + self.outline_headers = parsed.outline_headers; + + self.local_links = parse_local_links(&self.content, &self.path); + for link in &self.local_links { + self.cache.add_link_hook(link); + } + self.search_matches.clear(); + self.derived_stale = false; + self.last_edit_at = None; + } + /// Rebuild `search_matches` for `query`. Empty query clears matches. fn rebuild_search(&mut self, query: &str) { self.search_matches = find_matches(&self.content, query); @@ -1118,6 +1607,20 @@ impl Tab { // Stale byte ranges; caller rebuilds if search bar is open. self.search_matches.clear(); + + // The buffer now matches disk exactly. + self.synced_content_hash = hash_content(&self.content); + self.dirty = false; + self.external_change_hash = None; + self.in_flight_save_hash = None; + self.derived_stale = false; + self.last_edit_at = None; + + // PSE session state is derived from content; force re-seed. + self.session_buffers.clear(); + self.session_kinds.clear(); + self.session_last_blocks.clear(); + self.session_dirty = false; } fn navigate_to_link(&mut self, link: &str) -> io::Result { @@ -2172,6 +2675,19 @@ struct MarkdownApp { recent_files: Vec, // Welcome page: whether the recent list is expanded ("Show more") welcome_show_all: bool, + // ---- Editing / saving (Phase 0/1) ---- + // Sender for background save jobs; None if the worker thread failed to spawn. + save_tx: Option>, + // Completed save outcomes, polled non-blocking each frame. + save_rx: Receiver, + // Dirty state of the active tab last frame — drives the window-title marker. + last_active_dirty: bool, + // Awaiting confirmation: closing a tab or quitting with unsaved edits. + unsaved_confirm: Option, + // Headless e2e: synthetic click at this content-y, consumed once. + sim_click_pending: Option, + // Set when "Save All" was chosen in the quit dialog: quit once saves land. + quit_after_saves: bool, // MCP bridge for E2E testing #[cfg(feature = "mcp")] mcp_bridge: McpBridge, @@ -2361,6 +2877,42 @@ impl MarkdownApp { .map(|d| d != ":0" && d != ":0.0" && !d.is_empty()) .unwrap_or(false); + // Background save worker: the UI thread only clones + hashes and + // polls outcomes; all file I/O happens here (EGUI_WORKFLOW Phase 4). + // Writes go to a temp file followed by an atomic rename so a crash + // mid-write can never leave a truncated markdown file behind. + let (save_tx, save_job_rx) = mpsc::channel::(); + let (save_outcome_tx, save_rx) = mpsc::channel::(); + let save_worker_ok = std::thread::Builder::new() + .name("md-viewer-save".into()) + .spawn(move || { + while let Ok(job) = save_job_rx.recv() { + let tmp_path = { + let mut name = job.path.clone().into_os_string(); + name.push(format!(".mdv-tmp-{}", std::process::id())); + PathBuf::from(name) + }; + let result = fs::write(&tmp_path, job.content.as_bytes()) + .and_then(|()| fs::rename(&tmp_path, &job.path)); + if result.is_err() { + let _ = fs::remove_file(&tmp_path); + } + // If the app dropped the receiver we are shutting down. + if save_outcome_tx + .send(SaveOutcome { + path: job.path, + hash: job.hash, + result, + }) + .is_err() + { + break; + } + } + }) + .is_ok(); + let save_tx = save_worker_ok.then_some(save_tx); + let mut app = Self { tabs, active_tab, @@ -2420,6 +2972,14 @@ impl MarkdownApp { search: SearchState::default(), recent_files: persisted.recent_files.unwrap_or_default(), welcome_show_all: false, + save_tx, + save_rx, + last_active_dirty: false, + unsaved_confirm: None, + sim_click_pending: std::env::var("MDV_SIM_CLICK_Y") + .ok() + .and_then(|v| v.parse::().ok()), + quit_after_saves: false, #[cfg(feature = "mcp")] mcp_bridge, }; @@ -2442,7 +3002,7 @@ impl MarkdownApp { fn window_title(&self) -> String { if let Some(tab) = self.tabs.get(self.active_tab) { - format!("{} - Markdown Viewer", tab.title()) + format!("{} - Markdown Viewer", tab.display_title()) } else { "Markdown Viewer".to_string() } @@ -2608,7 +3168,301 @@ impl MarkdownApp { } fn close_active_tab(&mut self) { - self.close_tab(self.active_tab); + self.request_close_tab(self.active_tab); + } + + /// Close `idx`, but guard against silently abandoning unsaved edits: + /// a dirty tab raises an in-app confirmation instead of closing. + fn request_close_tab(&mut self, idx: usize) { + if idx >= self.tabs.len() { + return; + } + if self.tabs[idx].dirty { + let path = self.tabs[idx].path.clone(); + self.unsaved_confirm = Some(UnsavedConfirm::CloseTab(path)); + return; + } + self.close_tab(idx); + } + + /// Quit, guarding against unsaved edits in any open tab. + fn request_quit(&mut self, ctx: &egui::Context) { + if self.tabs.iter().any(|t| t.dirty) { + self.unsaved_confirm = Some(UnsavedConfirm::Quit); + } else { + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + fn any_dirty(&self) -> bool { + self.tabs.iter().any(|t| t.dirty) + } + + /// Enqueue the tab's buffer for background write. Cheap (clone + hash + /// only); the actual I/O happens on the save worker thread. A no-op when + /// nothing changed since the last sync/save or a save is already in + /// flight for this exact snapshot. + fn request_save(&mut self, idx: usize) -> bool { + let Some(tab) = self.tabs.get_mut(idx) else { + return false; + }; + if !tab.dirty && tab.in_flight_save_hash.is_none() { + return false; + } + if tab.edit_mode == EditMode::Live { + // Buffers are canonical while Live; fold them into `content` + // before hashing/writing so disk matches what the user sees. + tab.session_serialize(); + } + let hash = hash_content(&tab.content); + if Some(hash) == tab.in_flight_save_hash { + return false; + } + let job = SaveJob { + path: tab.path.clone(), + content: tab.content.clone(), + hash, + }; + let sent = match &self.save_tx { + Some(tx) => tx.send(job).is_ok(), + None => false, + }; + if sent { + if let Some(tab) = self.tabs.get_mut(idx) { + tab.in_flight_save_hash = Some(hash); + } + } + sent + } + + /// Consume completed save outcomes. Called once per frame from update(). + fn poll_save_outcomes(&mut self) { + while let Ok(outcome) = self.save_rx.try_recv() { + let idx = self + .tabs + .iter() + .position(|t| t.path == outcome.path && t.in_flight_save_hash == Some(outcome.hash)); + let Some(idx) = idx else { continue }; + match outcome.result { + Ok(()) => { + let still_matches = hash_content(&self.tabs[idx].content) == outcome.hash; + let close_requested = self.tabs[idx].close_after_save; + if still_matches { + self.tabs[idx].note_synced(outcome.hash); + } else { + // User typed while the write was in flight — keep the + // dirty state so the next save picks up the tail. + self.tabs[idx].in_flight_save_hash = None; + } + if close_requested && !self.tabs[idx].dirty { + self.close_tab(idx); + } + } + Err(e) => { + self.tabs[idx].in_flight_save_hash = None; + self.error_message = + Some(format!("Failed to save {}: {}", self.tabs[idx].title(), e)); + } + } + } + + // All pending saves settled cleanly → honor a deferred quit request. + if self.quit_after_saves + && !self.any_dirty() + && self.tabs.iter().all(|t| t.in_flight_save_hash.is_none()) + { + self.quit_after_saves = false; + self.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + } + + /// Thin hint bar shown in Live mode while no block is being edited, so + /// entering the mode is visible even though the canvas looks unchanged. + fn render_live_hint(&mut self, ctx: &egui::Context) { + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + if tab.edit_mode != EditMode::Live || tab.active_edit_byte.is_some() { + return; + } + egui::TopBottomPanel::bottom("live_hint_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("✎ Live preview — click any block to edit it · Esc stops editing · Ctrl+E cycles modes") + .weak(), + ); + }); + }); + } + + /// Banner shown when the active tab has unsaved edits AND the file changed + /// on disk (watcher detected an external modification). Offers explicit + /// resolution instead of silently clobbering either side. + fn render_conflict_banner(&mut self, ctx: &egui::Context) { + #[derive(Clone, Copy, PartialEq)] + enum Resolution { + ReloadFromDisk, + KeepMine, + } + + let Some(tab) = self.tabs.get(self.active_tab) else { + return; + }; + if tab.external_change_hash.is_none() { + return; + } + let file_name = tab.title(); + + let mut resolution: Option = None; + egui::TopBottomPanel::top("conflict_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + ui.label( + egui::RichText::new("⚠") + .color(egui::Color32::from_rgb(255, 170, 80)), + ); + ui.label(egui::RichText::new(format!( + "{file_name} changed on disk while you have unsaved edits." + ))); + if ui.button("Reload from disk").clicked() { + resolution = Some(Resolution::ReloadFromDisk); + } + if ui.button("Keep my version").clicked() { + resolution = Some(Resolution::KeepMine); + } + }); + }); + + match resolution { + Some(Resolution::ReloadFromDisk) => { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + if let Err(error) = tab.reload() { + self.error_message = Some(format!( + "Unable to reload {}: {error}", + tab.path.display() + )); + } + } + } + Some(Resolution::KeepMine) => { + // Drop the marker; the buffer stays dirty. Saving overwrites + // the external change; a *new* external edit re-prompts. + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.external_change_hash = None; + } + } + None => {} + } + } + + /// Modal-ish confirmation for closing a tab or quitting with unsaved + /// edits. Prevents silent data loss on Ctrl+W / Ctrl+Q / tab-bar ✕. + fn render_unsaved_confirm(&mut self, ctx: &egui::Context) { + enum Choice { + SaveThenProceed, + DiscardAndProceed, + Cancel, + } + + let Some(confirm) = self.unsaved_confirm.clone() else { + return; + }; + + let (message, is_quit) = match &confirm { + UnsavedConfirm::Quit => ("Quit with unsaved changes?".to_string(), true), + UnsavedConfirm::CloseTab(path) => ( + format!( + "\"{}\" has unsaved changes.", + path.file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_else(|| "This file".to_string()) + ), + false, + ), + }; + + let mut choice: Option = None; + egui::Window::new("Unsaved changes") + .id(egui::Id::new("unsaved_confirm_window")) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.label(message); + ui.add_space(8.0); + ui.horizontal(|ui| { + let save_label = if is_quit { "Save All" } else { "Save" }; + if ui.button(save_label).clicked() { + choice = Some(Choice::SaveThenProceed); + } + let discard_label = if is_quit { + "Discard All" + } else { + "Discard Changes" + }; + if ui.button(discard_label).clicked() { + choice = Some(Choice::DiscardAndProceed); + } + if ui.button("Cancel").clicked() { + choice = Some(Choice::Cancel); + } + }); + }); + + let idx_for = |app: &Self, confirm: &UnsavedConfirm| match confirm { + UnsavedConfirm::CloseTab(path) => app.tabs.iter().position(|t| t.path == *path), + UnsavedConfirm::Quit => None, + }; + + match choice { + Some(Choice::SaveThenProceed) => { + match &confirm { + UnsavedConfirm::CloseTab(_) => { + if let Some(idx) = idx_for(self, &confirm) { + self.tabs[idx].close_after_save = true; + if !self.request_save(idx) { + // Writer unavailable — keep the dialog state + // cleared and surface the error instead. + self.error_message = + Some("Save failed: file writer unavailable".to_string()); + } + } + } + UnsavedConfirm::Quit => { + let dirty: Vec = self + .tabs + .iter() + .enumerate() + .filter(|(_, t)| t.dirty) + .map(|(i, _)| i) + .collect(); + let mut all_enqueued = !dirty.is_empty(); + for idx in dirty { + if !self.request_save(idx) { + all_enqueued = false; + } + } + self.quit_after_saves = all_enqueued; + } + } + self.unsaved_confirm = None; + } + Some(Choice::DiscardAndProceed) => match &confirm { + UnsavedConfirm::CloseTab(_) => { + if let Some(idx) = idx_for(self, &confirm) { + self.close_tab(idx); + } + } + UnsavedConfirm::Quit => { + for tab in &mut self.tabs { + tab.dirty = false; // abandon buffers deliberately + } + self.quit_after_saves = false; + self.egui_ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } + }, + Some(Choice::Cancel) => self.unsaved_confirm = None, + None => {} + } } fn next_tab(&mut self) { @@ -2985,48 +3839,88 @@ impl MarkdownApp { let active_path = self.tabs.get(self.active_tab).map(|t| t.path.clone()); for path in changed_paths { - // Trigger flash effect for the changed file (use canonical path for consistent lookup) - let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); - self.flashing_paths.insert(canonical, now); - - // Also flash parent directories up to the explorer root - if let Some(root) = &self.file_explorer.root { - // Check if the changed path is within the explorer root - if path.starts_with(root) { - if let Some(parent) = path.parent() { - refresh_directories.insert(parent.to_path_buf()); - } - } + // ---- Classify the change against each open tab first ---- + // Self-writes (our own background saves) and pure echoes must not + // clobber editor buffers or trigger UI churn, so each matching + // tab decides: Skip / Reload / Conflict. + let mut matched_any = false; + let mut relevant_change = false; + let mut active_was_reloaded = false; - let mut current = path.parent(); - while let Some(parent) = current { - if parent.starts_with(root) || parent == root { - self.flashing_paths.insert( - parent - .canonicalize() - .unwrap_or_else(|_| parent.to_path_buf()), - now, - ); + for tab in self.tabs.iter_mut().filter(|t| t.path == path) { + matched_any = true; + let Ok(bytes) = fs::read(&tab.path) else { + continue; + }; + let incoming = hash_content(&String::from_utf8_lossy(&bytes)); + + match classify_disk_change( + tab.synced_content_hash, + hash_content(&tab.content), + tab.dirty, + tab.in_flight_save_hash, + incoming, + ) { + DiskChange::Ignore => continue, + DiskChange::Resync => { + tab.note_synced(incoming); + continue; } - if parent == root { - break; + DiskChange::Conflict => { + // Never touch the buffer automatically. Record the + // on-disk hash so repeat events dedupe; the banner + // asks the user what to do. + if tab.external_change_hash != Some(incoming) { + log::info!("External change while dirty (conflict): {:?}", tab.path); + tab.external_change_hash = Some(incoming); + } + relevant_change = true; + } + DiskChange::Reload => { + log::info!("Reloading tab: {:?}", path); + if let Err(error) = tab.reload() { + self.error_message = + Some(format!("Unable to reload {}: {error}", path.display())); + continue; + } + relevant_change = true; + if Some(&tab.path) == active_path.as_ref() { + active_was_reloaded = true; + } } - current = parent.parent(); } } - // Reload the tab content - let mut active_was_reloaded = false; - for tab in &mut self.tabs { - if tab.path == path { - log::info!("Reloading tab: {:?}", path); - if let Err(error) = tab.reload() { - self.error_message = - Some(format!("Unable to reload {}: {error}", path.display())); - continue; + // No open tab uses this path (explorer-only change): keep the old + // flash/refresh behavior. + if !matched_any || relevant_change { + // Trigger flash effect for the changed file (use canonical path for consistent lookup) + let canonical = path.canonicalize().unwrap_or_else(|_| path.clone()); + self.flashing_paths.insert(canonical, now); + + // Also flash parent directories up to the explorer root + if let Some(root) = &self.file_explorer.root { + // Check if the changed path is within the explorer root + if path.starts_with(root) { + if let Some(parent) = path.parent() { + refresh_directories.insert(parent.to_path_buf()); + } } - if Some(&tab.path) == active_path.as_ref() { - active_was_reloaded = true; + + let mut current = path.parent(); + while let Some(parent) = current { + if parent.starts_with(root) || parent == root { + self.flashing_paths.insert( + parent + .canonicalize() + .unwrap_or_else(|_| parent.to_path_buf()), + now, + ); + } + if parent == root { + break; + } + current = parent.parent(); } } } @@ -3174,6 +4068,16 @@ impl MarkdownApp { let Some(m) = tab.search_matches.get(idx) else { return; }; + // Source mode has no rendered layout to scroll; move the editor + // caret to the match instead. Live preview activates the block. + if tab.edit_mode == EditMode::Source { + tab.pending_caret_byte = Some(m.byte_start); + return; + } + if tab.edit_mode == EditMode::Live { + tab.active_edit_byte = Some(m.byte_start); + return; + } if tab.last_content_height <= 0.0 || tab.content_lines == 0 { return; } @@ -3250,7 +4154,7 @@ impl MarkdownApp { .tabs .iter() .enumerate() - .map(|(idx, tab)| (tab.title(), idx == self.active_tab)) + .map(|(idx, tab)| (tab.display_title(), idx == self.active_tab)) .collect(); let tab_count = tab_info.len(); @@ -3600,6 +4504,18 @@ impl MarkdownApp { // Calculate scroll target if header was clicked if let Some(idx) = clicked_header_index { if let Some(header) = tab.outline_headers.get(idx) { + // Source/Live modes have no full rendered layout: jump via the + // editor (caret in Source; activate the containing block in Live). + if tab.edit_mode == EditMode::Source { + tab.pending_caret_byte = + Some(byte_offset_of_line_start(&tab.content, header.line_number)); + return; + } + if tab.edit_mode == EditMode::Live { + tab.active_edit_byte = + Some(byte_offset_of_line_start(&tab.content, header.line_number)); + return; + } // The renderer records the same source-stable key, so formatting // and duplicate display titles cannot redirect the click. let key = header_position_key(header.source_start); @@ -3776,7 +4692,7 @@ impl MarkdownApp { }; // Push current search match ranges into the cache so the renderer can paint highlights - if search_is_open && !tab.search_matches.is_empty() { + if search_is_open && !tab.search_matches.is_empty() && tab.edit_mode != EditMode::Source { let ranges: Vec<_> = tab .search_matches .iter() @@ -3808,10 +4724,53 @@ impl MarkdownApp { ..Default::default() }) .show(ui, |ui| { + // Source editing mode: raw markdown editor instead of the + // rendered view. Renderer scroll bookkeeping is meaningless + // here, so skip the whole viewer pipeline. + if tab.edit_mode == EditMode::Source { + render_source_editor_ui(ui, tab); + return; + } + // Capture scroll input for manual handling during selection let raw_scroll = ui.ctx().input(|i| i.raw_scroll_delta.y); let content_rect = ui.available_rect_before_wrap(); + // ---- PSE session configuration ---- + let mut live_salt: Option = None; + let mut live_blocks: Option> = None; + if tab.edit_mode == EditMode::Live { + // Seed/refresh session buffers from serialized content. + // Empty spans == caches mid-refresh: fall back to last + // known blocks so editors never flicker away. + let spans = tab.cache.top_level_block_spans(&tab.id); + let span_count = spans.len(); + if span_count > 0 && tab.session_kinds.len() != span_count { + tab.session_seed(); + } + let blocks: Vec = if span_count > 0 + && tab.session_kinds.len() == span_count + { + spans + .iter() + .enumerate() + .filter_map(|(i, r)| { + tab.session_kinds.get(i).map(|k| SessionBlock { + src: r.clone(), + kind: *k, + }) + }) + .collect() + } else { + std::mem::take(&mut tab.session_last_blocks) + }; + if !blocks.is_empty() { + tab.session_last_blocks = blocks.clone(); + live_salt = Some(tab.source_editor_id().with("pse")); + live_blocks = Some(blocks); + } + } + // The renderer owns the ScrollArea now (via show_scrollable), // so we configure scroll_source / pending offset / content // version through builder methods. The returned ScrollAreaOutput @@ -3826,7 +4785,7 @@ impl MarkdownApp { // Line heights follow the emulated viewer (GitHub 1.5/1.45, // VS Code preview 1.6/1.36); the font preset owns the metrics. let (preset_line_height, preset_code_line_height) = self.font_preset.line_heights(); - let mut scroll_output = CommonMarkViewer::new() + let mut viewer = CommonMarkViewer::new() .default_implicit_uri_scheme(&tab.base_uri) .max_image_width(Some(800)) .default_width(default_width) @@ -3852,6 +4811,14 @@ impl MarkdownApp { .heading_spacing_below(0.75) .content_version(tab.content_version) .pending_scroll_offset(pending) + .record_block_layout(false); + if let Some(blocks) = live_blocks.clone() { + viewer = viewer.edit_session(Some(EditSessionConfig { + id_salt: tab.source_editor_id().with("pse"), + blocks, + })); + } + let mut scroll_output = viewer .force_full_render(force_full_render) .scroll_source(egui::scroll_area::ScrollSource { scroll_bar: true, @@ -3860,6 +4827,36 @@ impl MarkdownApp { }) .show_scrollable(tab.id, ui, &mut tab.cache, &tab.content); + // ---- PSE: fold per-block feedback into session buffers ---- + if live_salt.is_some() { + // Drop any legacy single-editor feedback; sessions use + // take_session_feedback exclusively. + tab.cache.take_edit_feedback(); + for fb in + tab.cache.take_session_feedback(&tab.source_editor_id().with("pse")) + { + if fb.changed { + if let Some(buf) = tab.session_buffers.get_mut(fb.index) { + if *buf != fb.text { + log::debug!( + "live: block {} changed ({} -> {} chars)", + fb.index, + buf.chars().count(), + fb.text.chars().count() + ); + *buf = fb.text; + tab.session_dirty = true; + tab.dirty = true; + tab.mark_edited(Instant::now()); + } + } + } + } + } + if tab.edit_mode == EditMode::Live { + tab.last_content_origin = Some(scroll_output.inner_rect.min); + } + tab.scroll_offset = scroll_output.state.offset.y; tab.last_viewport_height = scroll_output.inner_rect.height(); tab.last_content_height = scroll_output.content_size.y; @@ -4875,6 +5872,36 @@ impl eframe::App for MarkdownApp { self.reload_changed_tabs(changed_paths); } + // Collect completed background saves (never blocking). + self.poll_save_outcomes(); + + // Debounced rebuild of edit-derived caches (outline headers, local + // links, line counts). Only after an idle gap following the last + // keystroke — never per frame while typing. + let now = Instant::now(); + for idx in 0..self.tabs.len() { + let should_refresh = self.tabs[idx].derived_stale + && self.tabs[idx] + .last_edit_at + .is_some_and(|t| now.duration_since(t) >= DERIVED_REFRESH_DEBOUNCE); + if should_refresh { + self.tabs[idx].refresh_derived(); + if idx == self.active_tab && self.search.is_open { + // Matches were just cleared; force the find bar to rebuild + // (same invalidation trick the watcher-reload path uses). + self.search.last_tab = None; + } + } + } + + // Window title must reflect the active tab's dirty marker; track the + // transition instead of comparing strings every frame. + let active_dirty = self.tabs.get(self.active_tab).is_some_and(|t| t.dirty); + if active_dirty != self.last_active_dirty { + self.last_active_dirty = active_dirty; + self.title_dirty = true; + } + // Poll for asynchronous Explorer root scan completion. if self.file_explorer.pending_scan.is_some() { if self.file_explorer.poll_pending_scan() { @@ -5031,6 +6058,8 @@ impl eframe::App for MarkdownApp { let mut prev_match = false; let mut close_search_kb = false; let mut keyboard_scroll_action: Option = None; + let mut save_active = false; + let mut toggle_source_mode = false; // Ctrl+/- zoom: applies to lightbox when open, document otherwise. // Steps are multiplicative (×1.25) like browsers and the lightbox. @@ -5130,6 +6159,26 @@ impl eframe::App for MarkdownApp { if i.modifiers.ctrl && i.key_pressed(egui::Key::Q) { quit_app = true; } + // Ctrl+S: Save active tab (no-op when not dirty / no tab) + if i.modifiers.ctrl && !i.modifiers.shift && i.key_pressed(egui::Key::S) { + save_active = true; + } + // Ctrl+E: Toggle live-preview editing for the active tab + if i.modifiers.ctrl && !i.modifiers.shift && i.key_pressed(egui::Key::E) { + toggle_source_mode = true; + } + // Esc (Live mode, block active, find bar closed): stop + // editing the active block, return to the rendered view. + if i.key_pressed(egui::Key::Escape) && !self.search.is_open { + let deactivate = self.tabs.get(self.active_tab).is_some_and(|t| { + t.edit_mode == EditMode::Live && t.active_edit_byte.is_some() + }); + if deactivate { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.active_edit_byte = None; + } + } + } // Ctrl + scroll wheel / pinch to zoom (multiplicative, like // browsers). The lightbox captures wheel input itself in // raw_input_hook, so this only runs for the document. @@ -5168,9 +6217,16 @@ impl eframe::App for MarkdownApp { } // Plain document scroll keys reuse the existing pending-scroll pipeline. // Arrow keys stay available for search navigation while the find bar is open. + // In source mode the TextEdit owns caret movement/scrolling, so the + // app-level scroll shortcuts must not steal those keys (LESSONS: mode + // -specific keys take priority over document-level ones). let no_scroll_modifier = !i.modifiers.ctrl && !i.modifiers.alt && !i.modifiers.command; - if no_scroll_modifier { + let source_mode_owns_keys = self.tabs.get(self.active_tab).is_some_and(|t| { + t.edit_mode == EditMode::Source + || (t.edit_mode == EditMode::Live && t.active_edit_byte.is_some()) + }); + if no_scroll_modifier && !source_mode_owns_keys { if !self.search.is_open { if i.key_pressed(egui::Key::ArrowUp) { keyboard_scroll_action = Some(KeyboardScrollAction::LineUp); @@ -5211,7 +6267,26 @@ impl eframe::App for MarkdownApp { self.show_explorer = !self.show_explorer; } if quit_app { - ctx.send_viewport_cmd(egui::ViewportCommand::Close); + // Guarded: raises an unsaved-changes confirmation instead when + // any tab has unsaved edits. + self.request_quit(ctx); + } + if save_active { + if let Some(tab) = self.tabs.get(self.active_tab) { + if tab.dirty { + let idx = self.active_tab; + if !self.request_save(idx) { + self.error_message = + Some("Save failed: file writer unavailable".to_string()); + } + } + } + } + if toggle_source_mode { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.cycle_edit_mode(); + } + self.title_dirty = true; } if close_tab { self.close_active_tab(); @@ -5298,11 +6373,48 @@ impl eframe::App for MarkdownApp { ui.close(); } + let can_save = self + .tabs + .get(self.active_tab) + .is_some_and(|t| t.dirty); + let save_btn = ui.add_enabled( + can_save, + egui::Button::new("Save").shortcut_text("Ctrl+S"), + ); + #[cfg(feature = "mcp")] + self.mcp_bridge.register_widget( + "Menu: File → Save", + "button", + &save_btn, + None, + ); + if save_btn.clicked() + { + let idx = self.active_tab; + if !self.request_save(idx) { + self.error_message = + Some("Save failed: file writer unavailable".to_string()); + } + ui.close(); + } + + let has_tab = !self.tabs.is_empty(); + if ui + .add_enabled(has_tab, egui::Button::new("Open in External Editor")) + .on_hover_text("Opens the current file with the system's default application; live reload picks up changes") + .clicked() + { + if let Some(tab) = self.tabs.get(self.active_tab) { + open_external(&tab.path); + } + ui.close(); + } + if ui .add(egui::Button::new("Close Tab").shortcut_text("Ctrl+W")) .clicked() { - self.close_active_tab(); + self.request_close_tab(self.active_tab); ui.close(); } @@ -5383,6 +6495,73 @@ impl eframe::App for MarkdownApp { #[cfg_attr(not(feature = "mcp"), allow(unused_variables))] let view_menu = ui.menu_button("View", |ui| { + let current_mode = self + .tabs + .get(self.active_tab) + .map(|t| t.edit_mode) + .unwrap_or(EditMode::Rendered); + let mode_label = |mode: EditMode, label: &str| -> String { + if current_mode == mode { + format!("✓ {label}") + } else { + label.to_string() + } + }; + let mut picked_mode: Option = None; + #[cfg(feature = "mcp")] + let register = |name: String, btn: &egui::Response| { + self.mcp_bridge.register_widget(&name, "button", btn, None); + }; + #[cfg(not(feature = "mcp"))] + let register = |_name: String, _btn: &egui::Response| {}; + + let live_btn = ui.add( + egui::Button::new(mode_label( + EditMode::Live, + "Edit Markdown (Live Preview)", + )) + .shortcut_text("Ctrl+E"), + ); + let live_clicked = { + let b = live_btn.on_hover_text("Everything renders except the block you click into, which shows raw markdown"); + register("Menu: View → Live Preview".to_string(), &b); + b.clicked() + }; + if live_clicked { + picked_mode = Some(EditMode::Live); + } + + let source_btn = + ui.add(egui::Button::new(mode_label(EditMode::Source, "Edit Markdown (Source)"))); + let source_clicked = { + let b = source_btn.on_hover_text("Edit the whole file as raw markdown"); + register("Menu: View → Source".to_string(), &b); + b.clicked() + }; + if source_clicked { + picked_mode = Some(EditMode::Source); + } + + let rendered_btn = + ui.add(egui::Button::new(mode_label(EditMode::Rendered, "Rendered"))); + let rendered_clicked = { + let b = rendered_btn.on_hover_text("Reading view"); + register("Menu: View → Rendered".to_string(), &b); + b.clicked() + }; + if rendered_clicked { + picked_mode = Some(EditMode::Rendered); + } + if let Some(mode) = picked_mode { + if let Some(tab) = self.tabs.get_mut(self.active_tab) { + tab.set_edit_mode(mode); + } + self.title_dirty = true; + ui.close(); + } + + ui.separator(); + let theme_text = if self.dark_mode { "☀ Light Mode" } else { @@ -5681,6 +6860,11 @@ impl eframe::App for MarkdownApp { self.error_message = None; } + // ---- Editing UI state: external-change conflict + unsaved guard ---- + self.render_conflict_banner(ctx); + self.render_unsaved_confirm(ctx); + self.render_live_hint(ctx); + // Find bar (conditional, between error bar and tab bar) let search_outcome = self.render_search_bar(ctx); // Rebuild matches if query or tab changed (TextEdit may have mutated the query) @@ -5701,9 +6885,9 @@ impl eframe::App for MarkdownApp { tab_to_close = self.render_tab_bar(ui); }); - // Close tab if requested + // Close tab if requested (guarded for unsaved edits) if let Some(idx) = tab_to_close { - self.close_tab(idx); + self.request_close_tab(idx); } // File explorer (left sidebar) @@ -5722,7 +6906,7 @@ impl eframe::App for MarkdownApp { // Close tab from explorer (middle-click on open file) if let Some(path) = explorer_action.file_to_close { if let Some(idx) = self.tabs.iter().position(|t| t.path == path) { - self.close_tab(idx); + self.request_close_tab(idx); } } @@ -6967,6 +8151,142 @@ mod tests { ); } + // ---- Editing / saving (Phase 0/1) ---- + + #[test] + fn hash_content_is_deterministic_and_separates_inputs() { + let a = hash_content("# Hello\n"); + assert_eq!(a, hash_content("# Hello\n")); + assert_ne!(a, hash_content("# Hello\r\n")); + assert_ne!(a, hash_content("")); + } + + #[test] + fn byte_offset_to_char_index_ascii() { + let text = "abcdef"; + assert_eq!(byte_offset_to_char_index(text, 0), 0); + assert_eq!(byte_offset_to_char_index(text, 3), 3); + // Past-the-end clamps to char count. + assert_eq!(byte_offset_to_char_index(text, 100), 6); + } + + #[test] + fn byte_offset_to_char_index_multibyte_and_mid_sequence() { + // Each emoji is 4 bytes; CJK chars are 3 bytes each. + let text = "a🎉b中文"; + assert_eq!(byte_offset_to_char_index(text, 1), 1); // after 'a' + assert_eq!(byte_offset_to_char_index(text, 5), 2); // after emoji + assert_eq!(byte_offset_to_char_index(text, 9), 4); // after '中' + // Mid-UTF-8-sequence offsets round down to the containing char start. + assert_eq!(byte_offset_to_char_index(text, 2), 1); + assert_eq!(byte_offset_to_char_index(text, 7), 3); // inside '文' + assert_eq!(byte_offset_to_char_index(text, text.len()), 5); + } + + #[test] + fn byte_offset_of_line_start_basic_lines() { + let text = "one\ntwo\nthree"; + assert_eq!(byte_offset_of_line_start(text, 1), 0); + assert_eq!(byte_offset_of_line_start(text, 2), 4); + assert_eq!(byte_offset_of_line_start(text, 3), 8); + // Beyond the document → end of text. + assert_eq!(byte_offset_of_line_start(text, 99), text.len()); + } + + #[test] + fn byte_offset_of_line_start_empty_and_single_line() { + assert_eq!(byte_offset_of_line_start("", 1), 0); + assert_eq!(byte_offset_of_line_start("only", 1), 0); + assert_eq!(byte_offset_of_line_start("only\n", 2), 5); + } + + #[test] + fn classify_disk_change_ignores_echoes_and_self_writes() { + let disk = hash_content("disk"); + let other = hash_content("other"); + // Unchanged content re-reported by the watcher. + assert_eq!( + classify_disk_change(disk, disk, false, None, disk), + DiskChange::Ignore + ); + // Our own in-flight write landing on disk. + assert_eq!( + classify_disk_change(other, other, true, Some(disk), disk), + DiskChange::Ignore + ); + } + + #[test] + fn classify_disk_change_resyncs_reverted_edits() { + let saved = hash_content("saved"); + // Buffer was edited then undone back to the saved state: dirty but + // equal to disk → clear dirty instead of prompting/reloading. + assert_eq!( + classify_disk_change(saved, saved, true, None, saved), + DiskChange::Resync + ); + } + + #[test] + fn classify_disk_change_conflicts_only_when_dirty() { + let synced = hash_content("synced"); + let buffer = hash_content("buffer with edits"); + let external = hash_content("external edit"); + assert_eq!( + classify_disk_change(synced, buffer, true, None, external), + DiskChange::Conflict + ); + assert_eq!( + classify_disk_change(synced, buffer, false, None, external), + DiskChange::Reload + ); + // A pending save must not be mistaken for a resync opportunity. + let inflight = hash_content("in-flight snapshot"); + assert_eq!( + classify_disk_change(synced, buffer, false, Some(inflight), external), + DiskChange::Reload + ); + } + + #[test] + fn anchor_after_splice_keeps_relative_position() { + // Block [10..20), anchor at 15 (5 bytes in), text grows to 30 bytes. + assert_eq!(anchor_after_splice(15, 10, 20, 30), 15); + // Anchor at block start stays at block start. + assert_eq!(anchor_after_splice(10, 10, 20, 0), 10); + // Deleting the whole block clamps the anchor to its start. + assert_eq!(anchor_after_splice(18, 10, 20, 0), 10); + // Anchor beyond a shrunken block clamps to the new end. + assert_eq!(anchor_after_splice(19, 10, 20, 3), 13); + // Anchor before the range is untouched by clamping semantics. + assert_eq!(anchor_after_splice(4, 10, 20, 8), 10); + } + + #[test] + fn block_kind_detects_all_shapes() { + assert_eq!( + block_kind_of("## Heading\n\nbody"), + EditBlockKind::Heading(2) + ); + assert_eq!( + block_kind_of("- item one\n- item two"), + EditBlockKind::ListItem + ); + assert_eq!( + block_kind_of("12. ordered item"), + EditBlockKind::ListItem + ); + assert_eq!(block_kind_of("> quoted"), EditBlockKind::Quote); + assert_eq!( + block_kind_of("```rust\nfn x() {}\n```\n"), + EditBlockKind::CodeBlock + ); + assert_eq!( + block_kind_of("Just a paragraph.\n"), + EditBlockKind::Paragraph + ); + } + #[test] fn watcher_retry_sequence_stops_at_configured_limit() { assert_eq!(next_watcher_retry(0), Some(1)); diff --git a/test-doc.md b/test-doc.md new file mode 100644 index 0000000..c325338 --- /dev/null +++ b/test-doc.md @@ -0,0 +1,46 @@ +# Live Preview Test Doc + +Click into **any block** below — it becomes an editable raw-markdown box +while everything else keeps rendering. Press `Esc` to stop editing, +`Ctrl+E` to leave Live mode, `Ctrl+S` to save. + +## Paragraphs + +This paragraph supports *italics*, **bold**, `inline code`, and [links](https://example.com). +Type here and watch the rendered view update when you click away. + +## Lists + +- First item +- Second item + - Nested item +- Third item + +Task lists render as checkboxes: + +- [x] Ship Phase 0/1 (save pipeline + source mode) +- [ ] Try editing this checklist in Live mode + +> Blockquotes stay atomic while editing — +> the whole quote is one editor block. + +## Table + +| Feature | Status | Notes | +|---------|--------|-------| +| Source mode | ✅ | Ctrl+E cycles | +| Save pipeline | ✅ | Atomic writes | +| Live preview | ✅ | This mode! | + +## Code + +```rust +fn main() { + println!("code blocks are one editable unit"); +} +``` + +--- + +Search still works in Live mode: press `Ctrl+F`, type *table*, hit Enter — +the containing block activates.