From 7b5df4c9d528e055e0b5b804aa4321397d2c300c Mon Sep 17 00:00:00 2001 From: shawn Date: Mon, 1 Jun 2026 23:22:43 +0800 Subject: [PATCH] feat: add yazi-style File Browser with async live preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `termdown ` opens a File Browser: a left panel lists Markdown files, the right pane live-previews the highlighted file, and Enter commits it to the full-screen reader. - Live preview is built off-thread (PreviewWorker) so the event loop never blocks on heading-image rasterization; cursor moves stay instant. Builds are debounced (~120ms settle) and coalesced, and a generation counter discards results whose selection has moved on. - While a build is in flight the pane shows a spinner and places no images (no stale art). - Committing pushes onto the existing o/i history stack like a followed link; ephemeral previews never enter history. - Ephemeral preview image data is reclaimed (`d=I`) when the user scrolls past a file, so a long browse session doesn't accumulate orphaned PNGs in the terminal. - CONTEXT.md pins the "File Browser" (filesystem) vs "Contents/ToC" (headings) terminology to avoid the 目录 collision. Known issue (tracked in TODO.md): after Enter, the full-screen reader path has several rendering problems still to be fixed. Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTEXT.md | 17 ++ TODO.md | 7 + src/main.rs | 24 +- src/render.rs | 8 + src/tui/browser.rs | 84 ++++++ src/tui/kitty.rs | 35 +++ src/tui/mod.rs | 696 ++++++++++++++++++++++++++++++++++++++++----- 7 files changed, 801 insertions(+), 70 deletions(-) create mode 100644 src/tui/browser.rs diff --git a/CONTEXT.md b/CONTEXT.md index 21a430c..4e92410 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -8,6 +8,23 @@ vocabulary** here. ## Glossary +### File Browser +The interactive **filesystem** panel that scans a directory for Markdown files +and lets the user switch between them inside the TUI (inspired by yazi). A left +side panel coexisting with the reader. **Never call this "目录"** — see +[[Contents (ToC)]] for the collision. The user-facing word is **File Browser**; +in Chinese, **文件浏览器**. + +Status: proposed, not yet built. Reserve the name now so it can't drift. + +### Contents (ToC) +The heading-outline side panel toggled by `t`, titled `"Contents"`, built from +the active document's `RenderedDoc.headings`. In casual Chinese this is often +called "目录", which **collides** with the filesystem sense used by the +[[File Browser]]. In this project: **Contents / ToC = headings of one document**; +**File Browser = files on disk**. They are different panels with different data +sources; do not conflate them in code, docs, or conversation. + ### Frontmatter A block of metadata written at the **very beginning** of a Markdown file, fenced by either `---` (YAML syntax) or `+++` (TOML syntax). Used by static site diff --git a/TODO.md b/TODO.md index 6414dd1..5fe7bf8 100644 --- a/TODO.md +++ b/TODO.md @@ -11,3 +11,10 @@ - [ ] t 开启目录时,支持左右等方向键在目录和内容之间切换,并可以有一些界面上的 focus 提示 - [ ] 整理项目文档 - [x] 整理测试用的 markdown 文件,现在太乱 +- [ ] 文件浏览器 File Browser(`termdown `,yazi 式实时预览,draft PR) + - [ ] **commit(Enter)进入全屏阅读器后渲染有多个问题,待排查修复**(已知,先记着) + - [ ] 阅读器内按键唤回浏览器(提交后目前回不去) + - [ ] 子目录进入 / 递归、隐藏文件、.gitignore + - [ ] 构建缓存(path+mtime,重访秒开) + - [ ] File Browser 与 ToC(目录)面板的共存规则 + - [ ] 预览态可滚动 / 更轻的纯文本「安静模式」 diff --git a/src/main.rs b/src/main.rs index 0d4371e..746b377 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,7 @@ fn main() { println!(); println!("Arguments:"); println!(" FILE Markdown file to render (use - or omit for stdin)"); + println!(" DIR Directory to browse for Markdown files (yazi-style file browser)"); println!(); println!("Options:"); println!(" -h, --help Show this help message"); @@ -36,7 +37,8 @@ fn main() { println!(" --cat Force non-interactive cat-style output"); println!(" --no-bell Disable the edge-scroll terminal bell"); println!(); - println!("By default, passing FILE opens it in the interactive TUI."); + println!("By default, passing FILE opens it in the interactive TUI, and"); + println!("passing a directory opens the File Browser (j/k move, Enter opens, q quits)."); println!("Piped/redirected stdout and stdin input automatically use cat mode."); println!(); println!("Config: ~/.config/termdown/config.toml"); @@ -94,11 +96,27 @@ fn main() { found }; + let stdout_tty = io::stdout().is_tty(); + let path_is_dir = file_arg + .as_deref() + .map(|p| p != "-" && std::path::Path::new(p).is_dir()) + .unwrap_or(false); + + // `termdown ` on a TTY launches the File Browser: a yazi-style panel + // that scans the directory for Markdown files and live-previews them. + if !cat_flag && stdout_tty && path_is_dir { + let dir = file_arg.expect("path_is_dir implies a path"); + tui::run_browser(&dir, &config, theme); + return; + } + // TUI is the default when we have a real file path and stdout is a // terminal; --cat, piping/redirecting, or stdin input all fall through // to cat mode so scripts like `termdown foo.md | less` keep working. - let want_tui = - !cat_flag && matches!(file_arg.as_deref(), Some(p) if p != "-") && io::stdout().is_tty(); + let want_tui = !cat_flag + && stdout_tty + && !path_is_dir + && matches!(file_arg.as_deref(), Some(p) if p != "-"); if want_tui { let path = file_arg.expect("want_tui implies a file path"); diff --git a/src/render.rs b/src/render.rs index f322254..76a5664 100644 --- a/src/render.rs +++ b/src/render.rs @@ -428,6 +428,14 @@ pub fn delete_placement(w: &mut W, id: u32) -> std::io::Result<()> { write!(w, "\x1b_Ga=d,d=i,i={id},q=2;\x1b\\") } +/// Delete `id`'s placements AND free its cached image data (`d=I`, capital). +/// Unlike `delete_placement` (`d=i`), the terminal reclaims the stored PNG. +/// Used to reap ephemeral File Browser previews the user has scrolled past so +/// a long browse session doesn't accumulate orphaned image data. +pub fn delete_image_data(w: &mut W, id: u32) -> std::io::Result<()> { + write!(w, "\x1b_Ga=d,d=I,i={id},q=2;\x1b\\") +} + /// Delete all placements and image data this client has created. Used at /// TUI exit to clean up the terminal. pub fn delete_all_for_client(w: &mut W) -> std::io::Result<()> { diff --git a/src/tui/browser.rs b/src/tui/browser.rs new file mode 100644 index 0000000..d0b81ee --- /dev/null +++ b/src/tui/browser.rs @@ -0,0 +1,84 @@ +//! File Browser state (yazi-style): scans a directory for Markdown files and +//! tracks the cursor + debounce timing that drives the live preview. +//! +//! This is the **filesystem** browser — distinct from the Table of Contents +//! ("Contents") panel, which lists the *headings of one document*. See +//! `CONTEXT.md` for the 目录 terminology collision. + +use std::path::{Path, PathBuf}; +use std::time::Instant; + +pub struct FileBrowser { + /// Directory being browsed (as given on the CLI; may be relative). + pub dir: PathBuf, + /// Markdown files in `dir`, sorted. Single level, no recursion (HALF 1). + pub entries: Vec, + /// Index into `entries` of the highlighted row. + pub cursor: usize, + /// `Some(t)` = the cursor moved at `t` and we're waiting for it to settle + /// before (re)building the preview. `None` = settled/idle. + pub last_move: Option, + /// Path the current preview was built for; `None` until the first build or + /// after a commit (so re-entering the browser rebuilds). + pub preview_path: Option, +} + +impl FileBrowser { + /// Scan `dir` for `*.md` / `*.markdown` files (single level). Returns an + /// error only if the directory itself can't be read; an empty result is a + /// valid (caller-handled) outcome. + pub fn scan(dir: &Path) -> std::io::Result { + let mut entries: Vec = std::fs::read_dir(dir)? + .filter_map(|e| e.ok()) + .map(|e| e.path()) + .filter(|p| p.is_file() && is_markdown(p)) + .collect(); + entries.sort(); + Ok(FileBrowser { + dir: dir.to_path_buf(), + entries, + cursor: 0, + // None so the first loop iteration treats the selection as + // "settled" and builds the initial preview immediately. + last_move: None, + preview_path: None, + }) + } + + pub fn selected(&self) -> Option<&PathBuf> { + self.entries.get(self.cursor) + } + + /// Display name for a row (file name only). + pub fn name_at(&self, idx: usize) -> String { + self.entries + .get(idx) + .and_then(|p| p.file_name()) + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default() + } +} + +fn is_markdown(p: &Path) -> bool { + matches!( + p.extension() + .and_then(|e| e.to_str()) + .map(|e| e.to_ascii_lowercase()) + .as_deref(), + Some("md") | Some("markdown") + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn is_markdown_matches_extensions() { + assert!(is_markdown(Path::new("a.md"))); + assert!(is_markdown(Path::new("a.markdown"))); + assert!(is_markdown(Path::new("DIR/A.MD"))); + assert!(!is_markdown(Path::new("a.txt"))); + assert!(!is_markdown(Path::new("README"))); + } +} diff --git a/src/tui/kitty.rs b/src/tui/kitty.rs index 8cbb2ed..2b03bcf 100644 --- a/src/tui/kitty.rs +++ b/src/tui/kitty.rs @@ -70,6 +70,20 @@ impl ImageLifecycle { Ok(()) } + /// Free the cached image *data* for `ids` (not just their placements), so + /// the terminal reclaims memory, and drop them from our tracking. Used to + /// reap ephemeral File Browser previews the user scrolled past — without + /// this, a long browse session leaves orphaned PNGs cached in the terminal + /// until exit. + pub fn forget(&mut self, w: &mut W, ids: &[u32]) -> io::Result<()> { + for &id in ids { + render::delete_image_data(w, id)?; + self.transmitted.remove(&id); + self.placed.remove(&id); + } + Ok(()) + } + /// Delete every placement + cached image data this client created. /// Clears our tracking. Called on TUI exit. pub fn cleanup(&mut self, w: &mut W) -> io::Result<()> { @@ -183,4 +197,25 @@ mod tests { assert!(lc.placed.is_empty()); assert!(lc.transmitted.is_empty()); } + + #[test] + fn forget_frees_data_and_drops_tracking() { + let mut lc = ImageLifecycle::default(); + let mut buf = Vec::new(); + lc.register(&mut buf, 7, b"png").unwrap(); + let mut desired = HashMap::new(); + desired.insert(7u32, (0, 0)); + lc.sync(&mut buf, &desired).unwrap(); + buf.clear(); + + lc.forget(&mut buf, &[7]).unwrap(); + let s = String::from_utf8(buf).unwrap(); + // d=I (capital) frees the image data, not just the placement. + assert!( + s.contains("a=d,d=I,i=7"), + "expected data delete, got: {s:?}" + ); + assert!(!lc.transmitted.contains(&7)); + assert!(!lc.placed.contains_key(&7)); + } } diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 084ec26..5b6e67b 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -1,5 +1,6 @@ //! Interactive TUI mode. +mod browser; mod input; mod kitty; mod search; @@ -7,7 +8,12 @@ mod viewport; use std::collections::HashMap; use std::io::{self, Write}; -use std::time::Duration; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use browser::FileBrowser; use crossterm::event::{self, Event}; use crossterm::terminal::{ @@ -29,6 +35,79 @@ use viewport::Viewport; /// Width of the Table-of-Contents side panel when it is open. const TOC_PANEL_WIDTH: u16 = 30; +/// Width of the File Browser side panel (the filesystem file list). +const BROWSER_PANEL_WIDTH: u16 = 32; + +/// How long the cursor must sit still in the File Browser before we dispatch a +/// preview build. Fast scrolling never triggers a build; the preview is only +/// requested once the selection settles. See `feat/file-browser`. +const PREVIEW_DEBOUNCE: Duration = Duration::from_millis(120); + +/// A request to the preview worker: build the doc at `path`, tagged with the +/// `generation` that was current when it was dispatched. +struct PreviewRequest { + generation: u64, + path: PathBuf, +} + +/// A finished preview build coming back from the worker. `doc` is `None` if the +/// file couldn't be read. `generation` lets the main thread discard results +/// whose selection has since moved on. +struct PreviewResponse { + generation: u64, + path: PathBuf, + doc: Option, +} + +/// Off-thread Markdown builder for the File Browser. Heading-image +/// rasterization (the dominant per-doc cost) runs here so the event loop never +/// blocks on a settle. The worker coalesces queued requests — if several pile +/// up it only builds the most recent — and the main thread additionally drops +/// any result whose `generation` is stale. The worker exits when the request +/// channel closes (i.e. when `App`/`PreviewWorker` is dropped at TUI teardown). +struct PreviewWorker { + req_tx: mpsc::Sender, + res_rx: mpsc::Receiver, +} + +impl PreviewWorker { + fn spawn(config: Config, theme: Theme) -> Self { + let (req_tx, req_rx) = mpsc::channel::(); + let (res_tx, res_rx) = mpsc::channel::(); + thread::spawn(move || { + while let Ok(mut req) = req_rx.recv() { + // Coalesce: if the cursor raced ahead and queued newer + // requests, skip straight to the latest and drop the rest. + while let Ok(newer) = req_rx.try_recv() { + req = newer; + } + let doc = std::fs::read_to_string(&req.path) + .ok() + .map(|src| layout::build(&src, &config, theme)); + if res_tx + .send(PreviewResponse { + generation: req.generation, + path: req.path, + doc, + }) + .is_err() + { + break; // main thread gone + } + } + }); + PreviewWorker { req_tx, res_rx } + } + + fn request(&self, generation: u64, path: PathBuf) { + let _ = self.req_tx.send(PreviewRequest { generation, path }); + } + + fn try_recv(&self) -> Option { + self.res_rx.try_recv().ok() + } +} + enum Mode { Normal, Search { @@ -82,6 +161,26 @@ struct App { /// that may leave stale terminal cells behind (scroll, toc toggle, doc /// switch, resize). needs_full_redraw: bool, + /// File Browser state, present when termdown was pointed at a directory. + /// `None` for the plain single-file reader. Survives a commit so the + /// browser can be re-opened later (HALF 2). + browser: Option, + /// True while the File Browser panel is showing and holds focus (Browse + /// mode). False = Read mode (the normal full-screen reader). + browsing: bool, + /// The ephemeral preview document for the browser's current selection. + /// Rebuilt on settle; never enters the `docs` history stack until the user + /// commits it with Enter. + preview: Option, + /// Background Markdown builder for previews. `None` for the single-file + /// reader. + preview_worker: Option, + /// Monotonic "latest browser interaction" id. Bumped on every cursor move + /// so an in-flight build whose generation no longer matches is discarded. + preview_gen: u64, + /// The generation we have already dispatched a build for, so we don't + /// re-request the same selection every loop iteration while it builds. + dispatched_gen: u64, } impl App { @@ -107,11 +206,53 @@ impl App { theme, cell_px_height: 0, needs_full_redraw: true, + browser: None, + browsing: false, + preview: None, + preview_worker: None, + preview_gen: 0, + dispatched_gen: 0, }; app.push_new_doc(path, doc); app } + /// Construct an App that opens directly into the File Browser (Browse + /// mode) with no committed document yet. The first event-loop iteration + /// builds the preview for the first file. + fn new_browser( + browser: FileBrowser, + body_height: u16, + width: u16, + config: crate::config::Config, + theme: crate::theme::Theme, + ) -> Self { + let worker = PreviewWorker::spawn(config.clone(), theme); + App { + docs: Vec::new(), + cursor: 0, + history: Vec::new(), + forward: Vec::new(), + mode: Mode::Normal, + images: kitty::ImageLifecycle::default(), + next_image_id: 1, + term_size: (width, body_height), + should_quit: false, + config, + theme, + cell_px_height: 0, + needs_full_redraw: true, + browser: Some(browser), + browsing: true, + preview: None, + preview_worker: Some(worker), + // Start at 1 with dispatched_gen 0 so the first selection is + // eligible to dispatch on the very first iteration. + preview_gen: 1, + dispatched_gen: 0, + } + } + fn active(&self) -> &DocEntry { &self.docs[self.cursor] } @@ -125,33 +266,7 @@ impl App { /// that point at them) from the global allocator so ids never collide /// across docs in a single session. fn push_new_doc(&mut self, path: String, mut doc: layout::RenderedDoc) -> usize { - let offset = self.next_image_id; - // layout::build() assigns ids starting at 1; shift each by (offset - 1) - // so the first image of this doc becomes `offset`. - let mut id_map: std::collections::HashMap = std::collections::HashMap::new(); - for img in &mut doc.images { - let new_id = offset + (img.id - 1); - id_map.insert(img.id, new_id); - img.id = new_id; - } - if let Some(max) = doc.images.iter().map(|i| i.id).max() { - self.next_image_id = max + 1; - } - // Patch Span::HeadingImage and LineKind::Heading { id } references. - for line in &mut doc.lines { - for span in &mut line.spans { - if let layout::Span::HeadingImage { id, .. } = span { - if let Some(&new) = id_map.get(id) { - *id = new; - } - } - } - if let layout::LineKind::Heading { id: Some(hid), .. } = &mut line.kind { - if let Some(&new) = id_map.get(hid) { - *hid = new; - } - } - } + renumber_doc_ids(&mut doc, &mut self.next_image_id); let (width, height) = self.term_size; let viewport = Viewport::new(height, width); let mut entry = DocEntry { @@ -189,6 +304,19 @@ impl App { Ok(()) } + /// Transmit the current preview doc's images (Browse mode). No-op when no + /// preview is built yet. + fn register_preview_images(&mut self, w: &mut W) -> io::Result<()> { + let doc_images: Vec<(u32, Vec)> = match &self.preview { + Some(p) => p.doc.images.iter().map(|i| (i.id, i.png.clone())).collect(), + None => return Ok(()), + }; + for (id, png) in &doc_images { + self.images.register(w, *id, png)?; + } + Ok(()) + } + /// Open a link target. If it's a local `.md` file, pushes a new DocEntry /// onto the history stack and makes it active. Otherwise, spawns the /// platform URL handler. @@ -240,6 +368,168 @@ pub fn run(path: &str, config: &Config, theme: Theme) { } } +/// Entry point for `termdown ` — open the File Browser on a directory. +pub fn run_browser(dir: &str, config: &Config, theme: Theme) { + let browser = match FileBrowser::scan(std::path::Path::new(dir)) { + Ok(b) => b, + Err(e) => { + eprintln!("termdown: error reading directory {dir}: {e}"); + std::process::exit(1); + } + }; + if browser.entries.is_empty() { + eprintln!("termdown: no Markdown files found in {dir}"); + std::process::exit(1); + } + if let Err(e) = run_browser_ui(browser, config.clone(), theme) { + eprintln!("termdown: tui error: {e}"); + std::process::exit(1); + } +} + +fn run_browser_ui(browser: FileBrowser, config: Config, theme: Theme) -> io::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + crossterm::execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + let size = terminal.size()?; + let body_height = size.height.saturating_sub(1); + let mut app = App::new_browser(browser, body_height, size.width, config, theme); + app.cell_px_height = query_cell_px_height(); + + let result = event_loop(&mut terminal, &mut app); + + { + let mut out = io::stdout().lock(); + let _ = app.images.cleanup(&mut out); + let _ = out.flush(); + } + + disable_raw_mode()?; + crossterm::execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + result +} + +/// Shift all heading-image ids in `doc` so they start at `*next_image_id`, +/// then advance the allocator past them. Patches both `Span::HeadingImage` and +/// `LineKind::Heading { id }` references. Shared by the history stack +/// (`push_new_doc`) and the ephemeral browser preview so ids never collide +/// across docs in one session. +fn renumber_doc_ids(doc: &mut layout::RenderedDoc, next_image_id: &mut u32) { + let offset = *next_image_id; + // layout::build() assigns ids starting at 1; shift each by (offset - 1) + // so the first image of this doc becomes `offset`. + let mut id_map: HashMap = HashMap::new(); + for img in &mut doc.images { + let new_id = offset + (img.id - 1); + id_map.insert(img.id, new_id); + img.id = new_id; + } + if let Some(max) = doc.images.iter().map(|i| i.id).max() { + *next_image_id = max + 1; + } + for line in &mut doc.lines { + for span in &mut line.spans { + if let layout::Span::HeadingImage { id, .. } = span { + if let Some(&new) = id_map.get(id) { + *id = new; + } + } + } + if let layout::LineKind::Heading { id: Some(hid), .. } = &mut line.kind { + if let Some(&new) = id_map.get(hid) { + *hid = new; + } + } + } +} + +/// True when the highlighted file differs from the one the current preview was +/// built for — i.e. a build is pending/in-flight and the pane should show a +/// loading indicator rather than stale content or images. +fn browse_selection_pending(app: &App) -> bool { + match app.browser.as_ref() { + Some(b) => { + let sel = b.selected(); + sel.is_some() && sel != b.preview_path.as_ref() + } + None => false, + } +} + +/// Adopt a finished preview build from the worker: renumber ids onto the global +/// allocator, refine image rows to the real cell height, transmit the PNGs, and +/// mark this path as the one currently shown. A read error (`doc == None`) +/// clears the preview but still records the path so the pane shows the +/// "unreadable" message instead of a perpetual spinner. +fn accept_preview(app: &mut App, resp: PreviewResponse) { + let (term_width, body_height) = app.term_size; + let body_width = term_width.saturating_sub(BROWSER_PANEL_WIDTH); + + // Reap the outgoing preview's image data first — it's ephemeral (never + // committed; commit takes `app.preview` via `take()` so we never reach here + // for a kept doc) and would otherwise stay cached in the terminal. + let stale_ids: Vec = app + .preview + .as_ref() + .map(|p| p.doc.images.iter().map(|i| i.id).collect()) + .unwrap_or_default(); + if !stale_ids.is_empty() { + let mut out = io::stdout().lock(); + let _ = app.images.forget(&mut out, &stale_ids); + let _ = out.flush(); + } + + match resp.doc { + Some(mut doc) => { + renumber_doc_ids(&mut doc, &mut app.next_image_id); + refine_image_rows(&mut doc, app.cell_px_height); + app.preview = Some(DocEntry { + path: resp.path.display().to_string(), + doc, + viewport: Viewport::new(body_height, body_width), + search: None, + pending_g: false, + toc_open: false, + metadata_expanded: false, + }); + let mut out = io::stdout().lock(); + let _ = app.register_preview_images(&mut out); + let _ = out.flush(); + } + None => { + app.preview = None; + } + } + + if let Some(b) = app.browser.as_mut() { + b.preview_path = Some(resp.path); + } +} + +/// Synchronously build a `DocEntry` for `path` (used on commit when the async +/// preview isn't ready yet — a one-off blocking build on a deliberate Enter is +/// acceptable). Returns `None` if the file can't be read. +fn build_doc_entry_sync(app: &mut App, path: &std::path::Path) -> Option { + let (term_width, body_height) = app.term_size; + let body_width = term_width.saturating_sub(BROWSER_PANEL_WIDTH); + let src = std::fs::read_to_string(path).ok()?; + let mut doc = layout::build(&src, &app.config, app.theme); + renumber_doc_ids(&mut doc, &mut app.next_image_id); + refine_image_rows(&mut doc, app.cell_px_height); + Some(DocEntry { + path: path.display().to_string(), + doc, + viewport: Viewport::new(body_height, body_width), + search: None, + pending_g: false, + toc_open: false, + metadata_expanded: false, + }) +} + fn run_ui(doc: layout::RenderedDoc, path: String, config: Config, theme: Theme) -> io::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); @@ -289,14 +579,58 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu // wrap cache (`ensure_wrap` re-wraps when `self.width != cache_width`). let size = terminal.size()?; let body_height = size.height.saturating_sub(1); - let body_width = if app.active().toc_open { - size.width.saturating_sub(TOC_PANEL_WIDTH) - } else { - size.width - }; app.term_size = (size.width, body_height); let show_metadata = app.config.metadata.unwrap_or(true); - { + + if app.browsing { + // 1. Adopt any finished builds, discarding ones the cursor has + // since moved past (stale generation). + let mut accepted: Vec = Vec::new(); + if let Some(worker) = app.preview_worker.as_ref() { + while let Some(resp) = worker.try_recv() { + if resp.generation == app.preview_gen { + accepted.push(resp); + } + } + } + // Only the newest accepted result matters; drop the rest. + if let Some(resp) = accepted.pop() { + accept_preview(app, resp); + } + + // 2. Dispatch a build once the selection settles (debounced) and we + // haven't already requested this generation. + let (sel, settled) = match app.browser.as_ref() { + Some(b) => ( + b.selected().cloned(), + b.last_move.is_none_or(|t| t.elapsed() >= PREVIEW_DEBOUNCE), + ), + None => (None, false), + }; + if settled && app.dispatched_gen != app.preview_gen && browse_selection_pending(app) { + // Mark this generation dispatched before borrowing the worker so + // we never re-request it; one build per settle. + let gen = app.preview_gen; + app.dispatched_gen = gen; + if let (Some(worker), Some(path)) = (app.preview_worker.as_ref(), sel) { + worker.request(gen, path); + } + } + + // 3. Size the preview viewport (only meaningful when one is shown). + let body_width = size.width.saturating_sub(BROWSER_PANEL_WIDTH); + if let Some(p) = app.preview.as_mut() { + p.viewport.width = body_width; + p.viewport.height = body_height; + let expanded = p.metadata_expanded; + p.viewport.ensure_wrap(&p.doc, show_metadata, expanded); + } + } else { + let body_width = if app.active().toc_open { + size.width.saturating_sub(TOC_PANEL_WIDTH) + } else { + size.width + }; let active = app.active_mut(); if active.viewport.width != body_width || active.viewport.height != body_height { active.viewport.width = body_width; @@ -324,21 +658,45 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu app.images.reset_transmissions(); let mut out = io::stdout().lock(); let _ = app.images.reset_placements(&mut out); - let _ = app.register_active_images(&mut out); + if app.browsing { + let _ = app.register_preview_images(&mut out); + } else { + let _ = app.register_active_images(&mut out); + } let _ = out.flush(); app.needs_full_redraw = false; } - terminal.draw(|frame| draw(frame, app))?; + if app.browsing { + terminal.draw(|frame| draw_browse(frame, app))?; + } else { + terminal.draw(|frame| draw(frame, app))?; + } + + // While a preview is still loading (selection differs from the shown + // doc) we place no images — the pane shows a spinner, not stale art. + let browse_pending = app.browsing && browse_selection_pending(app); { let mut stdout = io::stdout().lock(); - let desired = desired_image_placements(app); + let desired = if app.browsing { + match (browse_pending, app.preview.as_ref()) { + (false, Some(p)) => { + placements_for(p, BROWSER_PANEL_WIDTH, p.viewport.height, None) + } + _ => HashMap::new(), + } + } else { + desired_image_placements(app) + }; let _ = app.images.sync(&mut stdout, &desired); let _ = stdout.flush(); } - if event::poll(Duration::from_millis(50))? { + // Poll faster while a build is in flight so the finished preview pops in + // promptly; idle otherwise to keep CPU low. + let poll_ms = if browse_pending { 16 } else { 50 }; + if event::poll(Duration::from_millis(poll_ms))? { let ev = event::read()?; // Resize is the one event crossterm surfaces that must trigger a // full redraw regardless of mode. @@ -346,11 +704,15 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu app.needs_full_redraw = true; continue; } - match &mut app.mode { - Mode::Normal => handle_normal_key(app, &ev)?, - Mode::Search { .. } => handle_search_key(app, ev)?, - Mode::LinkSelect { .. } => handle_link_select_key(app, ev)?, - Mode::Help => handle_help_key(app, ev)?, + if app.browsing { + handle_browse_key(app, &ev); + } else { + match &mut app.mode { + Mode::Normal => handle_normal_key(app, &ev)?, + Mode::Search { .. } => handle_search_key(app, ev)?, + Mode::LinkSelect { .. } => handle_link_select_key(app, ev)?, + Mode::Help => handle_help_key(app, ev)?, + } } if app.should_quit { return Ok(()); @@ -366,6 +728,92 @@ fn event_loop(terminal: &mut Terminal, app: &mut App) -> io::Resu } } +/// Key handling while the File Browser holds focus (Browse mode). +fn handle_browse_key(app: &mut App, ev: &Event) { + let Event::Key(key) = ev else { + return; + }; + if key.kind != event::KeyEventKind::Press { + return; + } + let ctrl = key.modifiers.contains(event::KeyModifiers::CONTROL); + match key.code { + event::KeyCode::Char('q') => app.should_quit = true, + event::KeyCode::Char('c') if ctrl => app.should_quit = true, + event::KeyCode::Char('j') | event::KeyCode::Down => browse_move(app, 1), + event::KeyCode::Char('k') | event::KeyCode::Up => browse_move(app, -1), + event::KeyCode::Enter => browse_commit(app), + event::KeyCode::Esc => { + // No committed doc to return to (launched straight into the + // browser) → quit; otherwise drop back to the reader. + if app.docs.is_empty() { + app.should_quit = true; + } else { + app.browsing = false; + app.needs_full_redraw = true; + } + } + _ => {} + } +} + +/// Move the browser cursor by `delta`, clamped to the list bounds. Records the +/// move time (for debounce) and bumps the generation so any in-flight build for +/// the previous selection is discarded when it returns. +fn browse_move(app: &mut App, delta: i32) { + let moved = if let Some(b) = app.browser.as_mut() { + if b.entries.is_empty() { + false + } else { + let len = b.entries.len() as i32; + let next = (b.cursor as i32 + delta).clamp(0, len - 1) as usize; + if next != b.cursor { + b.cursor = next; + b.last_move = Some(Instant::now()); + true + } else { + false + } + } + } else { + false + }; + if moved { + app.preview_gen += 1; + } +} + +/// Commit the selected file: reuse its preview if the async build already +/// landed, otherwise build it synchronously now (a one-off blocking build on a +/// deliberate Enter is acceptable). Then move it onto the history stack as the +/// active document and leave Browse mode. +fn browse_commit(app: &mut App) { + let Some(sel) = app.browser.as_ref().and_then(|b| b.selected().cloned()) else { + return; + }; + let preview_ready = !browse_selection_pending(app) && app.preview.is_some(); + let entry = if preview_ready { + app.preview.take().expect("preview_ready implies Some") + } else { + match build_doc_entry_sync(app, &sel) { + Some(e) => e, + None => return, // unreadable file — stay in the browser + } + }; + if !app.docs.is_empty() { + app.history.push(app.cursor); + } + app.forward.clear(); + app.docs.push(entry); + app.cursor = app.docs.len() - 1; + app.browsing = false; + // Forget the preview tracking so re-opening the browser rebuilds it. + if let Some(b) = app.browser.as_mut() { + b.preview_path = None; + } + app.needs_full_redraw = true; +} + /// Apply a scroll delta and ring the edge bell if the viewport didn't budge. /// Detection lives here (not in `Viewport`) so the data layer stays free of /// `App`/`Config`/audio coupling and `gg`/`G`/`]`/`[` — which bypass this @@ -950,31 +1398,23 @@ fn clipped_spans( out } -fn draw(frame: &mut ratatui::Frame, app: &App) { - use ratatui::layout::{Constraint, Direction, Layout}; - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Min(1), Constraint::Length(1)]) - .split(frame.area()); - - let active = app.active(); - - // Body +/// Render one document's visible region into ratatui lines. Shared by the +/// full-screen reader (`draw`) and the File Browser preview (`draw_browse`). +fn render_doc_body(entry: &DocEntry, theme: Theme) -> Vec> { // Precompute the current-match identity for "is this the current one" checks. - let current_logical: Option<(usize, usize)> = active.search.as_ref().and_then(|s| { + let current_logical: Option<(usize, usize)> = entry.search.as_ref().and_then(|s| { s.current.map(|i| { let m = &s.matches[i]; (m.line_index, m.byte_range.start) }) }); - let mut rendered: Vec = Vec::new(); - for vl in active.viewport.visible() { + let mut rendered: Vec> = Vec::new(); + for vl in entry.viewport.visible() { if let Some(role) = vl.metadata_row { - let body_cols = active.viewport.width as usize; + let body_cols = entry.viewport.width as usize; rendered.push(render_metadata_row( - active + entry .doc .metadata .as_ref() @@ -991,22 +1431,131 @@ fn draw(frame: &mut ratatui::Frame, app: &App) { // indexing `doc.lines` so the metadata block's trailing blank (whose // `logical_index` is a sentinel) never dereferences a real line. if vl.is_spacer { - rendered.push(RLine::from(Vec::::new())); + rendered.push(RLine::from(Vec::>::new())); continue; } - let logical = &active.doc.lines[vl.logical_index]; + let logical = &entry.doc.lines[vl.logical_index]; let matches = visible_matches_for_line( - active.search.as_ref(), + entry.search.as_ref(), vl.logical_index, vl.byte_start, vl.byte_end, current_logical, ); - let rspans = clipped_spans(logical, vl.byte_start, vl.byte_end, &matches, app.theme); + let rspans = clipped_spans(logical, vl.byte_start, vl.byte_end, &matches, theme); rendered.push(RLine::from(rspans)); } + rendered +} + +/// Render the File Browser: file-list panel on the left, live preview on the +/// right, status row at the bottom. +fn draw_browse(frame: &mut ratatui::Frame, app: &App) { + use ratatui::layout::{Constraint, Direction, Layout}; + use ratatui::style::{Modifier, Style as RStyle}; + use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + + let split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(BROWSER_PANEL_WIDTH), Constraint::Min(20)]) + .split(chunks[0]); + + let Some(browser) = app.browser.as_ref() else { + return; + }; + + // File list panel. + let items: Vec = (0..browser.entries.len()) + .map(|i| ListItem::new(browser.name_at(i))) + .collect(); + let list = List::new(items) + .block(Block::default().borders(Borders::RIGHT).title(" Files ")) + .highlight_style(RStyle::default().add_modifier(Modifier::REVERSED)); + let mut state = ListState::default(); + if !browser.entries.is_empty() { + state.select(Some(browser.cursor)); + } + frame.render_stateful_widget(list, split[0], &mut state); + + // Preview pane. While the selection is still building (async), show a + // spinner instead of stale content; otherwise the rendered doc, or an + // "unreadable" note if the build came back empty. + let dim = RStyle::default().add_modifier(Modifier::DIM); + if browse_selection_pending(app) { + let msg = RLine::from(RSpan::styled(" ⟳ 加载中…", dim)); + frame.render_widget(Paragraph::new(msg), split[1]); + } else { + match app.preview.as_ref() { + Some(entry) => { + let rendered = render_doc_body(entry, app.theme); + frame.render_widget(Paragraph::new(rendered), split[1]); + } + None => { + let msg = RLine::from(RSpan::styled(" (无法读取该文件)", dim)); + frame.render_widget(Paragraph::new(msg), split[1]); + } + } + } + + render_browse_status(frame, chunks[1], app); +} + +/// Status row for Browse mode: directory + position. +fn render_browse_status(frame: &mut ratatui::Frame, area: ratatui::layout::Rect, app: &App) { + use ratatui::style::Style as RStyle; + use ratatui::widgets::Paragraph; + use unicode_width::UnicodeWidthStr; + + let total = area.width as usize; + if total == 0 { + return; + } + let Some(browser) = app.browser.as_ref() else { + return; + }; + + let (bg, fg) = status_colors(app.theme); + let style = RStyle::default().bg(bg).fg(fg); + + let dir = browser.dir.display().to_string(); + let pos = if browser.entries.is_empty() { + "0/0".to_string() + } else { + format!("{}/{}", browser.cursor + 1, browser.entries.len()) + }; + let right = format!(" {pos} "); + let left = format!(" {dir} "); + let used = left.width() + right.width(); + let pad = total.saturating_sub(used); + let line = if used <= total { + format!("{left}{}{right}", " ".repeat(pad)) + } else { + // Tight: middle-truncate the dir, keep the position. + let max_dir = total.saturating_sub(right.width() + 2); + let t = truncate_middle(&dir, max_dir); + format!(" {t} {right}") + }; + frame.render_widget(Paragraph::new(line).style(style), area); +} + +fn draw(frame: &mut ratatui::Frame, app: &App) { + use ratatui::layout::{Constraint, Direction, Layout}; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + + let active = app.active(); + + let rendered = render_doc_body(active, app.theme); let body_area = if active.toc_open { let split = ratatui::layout::Layout::default() @@ -1431,16 +1980,29 @@ fn desired_image_placements(app: &App) -> HashMap { } else { None }; + placements_for(active, col_offset, active.viewport.height, popup_rows) +} + +/// Compute the desired `id → (col, row)` heading-image placement map for one +/// document's visible region. `col_offset` shifts images past a left panel +/// (ToC or File Browser); `popup_rows`, if set, suppresses images overlapping +/// the help popup. Shared by the reader and the browser preview. +fn placements_for( + entry: &DocEntry, + col_offset: u16, + body_height: u16, + popup_rows: Option<(u16, u16)>, +) -> HashMap { let mut out = HashMap::new(); // wrap_all emits one VisualLine per screen row (headings expand into // N rows: main + spacers), so visual_row just increments by 1 each // iteration and matches the row count used by draw() + the viewport. - let body_height = active.viewport.height; - for (visual_row, vl) in active.viewport.visible().iter().enumerate() { - if vl.is_spacer || vl.byte_start != 0 { + for (visual_row, vl) in entry.viewport.visible().iter().enumerate() { + // Metadata rows carry a sentinel logical_index and never hold images. + if vl.metadata_row.is_some() || vl.is_spacer || vl.byte_start != 0 { continue; } - let logical = &active.doc.lines[vl.logical_index]; + let logical = &entry.doc.lines[vl.logical_index]; let vr = visual_row as u16; for span in &logical.spans { if let layout::Span::HeadingImage { id, rows } = span {