From cd5d5aef9703cfb0cbacf435e358ec9324ef403e Mon Sep 17 00:00:00 2001 From: Justice Date: Thu, 15 Oct 2026 23:32:54 +0800 Subject: [PATCH] Add a document outline: headings in a pop-up, and a panel that follows the cursor --- README.md | 23 ++- crates/stet-app/src/app.rs | 17 +- crates/stet-app/src/view.rs | 211 ++++++++++++++++++++-- crates/stet-core/src/config.rs | 3 + crates/stet-core/src/editor/command.rs | 5 +- crates/stet-core/src/editor/margin.rs | 1 + crates/stet-core/src/editor/mod.rs | 16 +- crates/stet-core/src/editor/outline.rs | 237 +++++++++++++++++++++++++ crates/stet-core/src/editor/palette.rs | 48 ++++- crates/stet-core/src/editor/tabs.rs | 7 + crates/stet-core/src/editor/tests.rs | 134 ++++++++++++++ crates/stet-core/src/editor/vim.rs | 14 ++ crates/stet-core/src/markdown.rs | 194 ++++++++++++++++++++ 13 files changed, 883 insertions(+), 27 deletions(-) create mode 100644 crates/stet-core/src/editor/outline.rs diff --git a/README.md b/README.md index b8f7e78..2280bbb 100644 --- a/README.md +++ b/README.md @@ -78,7 +78,7 @@ Vim is on by default (`:novim` or `vim = false` for a standard text field). 4. **Visual** — `v`, `V`, and block `Ctrl-v` (`I`, `A`, `c`, `d`, `y`, `r`, `~ u U`, `> <`); `o` swaps ends. 5. **Registers and macros** — `"a`–`"z`, `"A` append, `"0`, `"_`, `"+`; the unnamed register is the system clipboard. `qa … q` records, `@a` and `@@` replay. 6. **Search** — `/` and `?` take regular expressions (smart case, live highlight; an invalid pattern is searched literally). `:s/a/b/g` and `:%s/(\w+)@(\w+)/\2 at \1/g` support `\1`…`\9` and `&`. -7. **Command line** — `:w :q :wq :wa :qa :e :tabnew :theme :font :monofont :files :sidebar :backlinks :recover :help :`; Tab completes theme and font names. +7. **Command line** — `:w :q :wq :wa :qa :e :tabnew :theme :font :monofont :files :sidebar :outline :outlinebar :backlinks :recover :help :`; Tab completes theme and font names. 8. **Markdown** — Enter continues lists, tasks and quotes; Tab/Shift-Tab indent items; `gt` toggles a task. Shortcuts use Cmd on macOS and Ctrl elsewhere (in vim mode on Ctrl platforms, @@ -189,9 +189,10 @@ over the built-in ones, shortcuts included: ``` Every action has a command to bind (the menu lists them): `:w :q :tabnew -:tabnext :files :sidebar :follow :back :forward :backlinks :actions :agent -:suggest :accept :reject :acceptall :rejectall :nextsuggestion :bold :italic -:link :task :theme :font :focus :typewriter :zoom in :fullscreen :help`. +:tabnext :files :sidebar :outline :outlinebar :follow :back :forward +:backlinks :actions :agent :suggest :accept :reject :acceptall :rejectall +:nextsuggestion :bold :italic :link :task :theme :font :focus :typewriter +:zoom in :fullscreen :help`. ## Assistants @@ -237,6 +238,20 @@ closes one, `t` opens in a new tab, `-` widens to the parent folder, `r` re-reads, Esc returns to the text. Clicking works too. `sidebar = true` shows it at startup. +## Outline + +`g o` or `:outline` lists the document's headings in a pop-up, indented by +level and opening on the one the cursor is in. `j`/`k` or the arrows move, +Enter or a click jumps there and closes, `←`/`→` fold a heading's +subheadings away and back, Esc or Tab returns to the text, and typing +filters. Setext headings count; headings inside code fences do not. + +`g O`, `:outlinebar` or `:sidebar outline` opens the same headings as a +panel beside the text: it stays while you write, marks the heading the +cursor is in, and scrolls to it when you move to another section. Clicking +a row jumps, clicking `▸`/`▾` folds. Folds are shared with the pop-up and +remembered per document. `outline = true` shows the panel at startup. + ## Code Fenced code blocks and front matter are colored by language using Sublime diff --git a/crates/stet-app/src/app.rs b/crates/stet-app/src/app.rs index 38b4f54..dc01700 100644 --- a/crates/stet-app/src/app.rs +++ b/crates/stet-app/src/app.rs @@ -338,6 +338,9 @@ impl Running { editor.toggle_sidebar(); editor.sidebar.focused = false; } + if editor.config.outline { + editor.toggle_outline_panel(); + } let mut view = View::new(fonts, Images::new(wake.clone(), platform::cache_dir())); view.scale = window.scale_factor() as f32; view.zoom = state.zoom.unwrap_or(1.0).clamp(0.5, 4.0); @@ -782,6 +785,9 @@ impl Running { Some(Target::Tab(index)) => editor.switch_tab(index), Some(Target::SidebarRow(index)) => editor.sidebar_click(index, command), Some(Target::Sidebar) => editor.sidebar.focused = true, + Some(Target::OutlineRow(index)) => editor.outline_panel_click(index), + Some(Target::OutlineFold(index)) => editor.outline_panel_fold(index), + Some(Target::Outline) => {} Some(Target::MenuHint) => editor.open_palette(PaletteKind::Help), Some(Target::Note(index)) => editor.open_note(index), Some(Target::Sheet) => {} @@ -904,7 +910,16 @@ impl Running { if session.editor.palette.is_some() || session.editor.sheet.is_some() || session.view.retouch.is_some() { return; } - if matches!(over, Some(Target::Sidebar | Target::SidebarRow(_))) { + if matches!( + over, + Some(Target::Outline | Target::OutlineRow(_) | Target::OutlineFold(_)) + ) { + let rows = (pixels / (28.0 * session.view.scale)).round() as isize; + session.view.scroll_outline( + &mut session.editor, + if rows == 0 { pixels.signum() as isize } else { rows }, + ); + } else if matches!(over, Some(Target::Sidebar | Target::SidebarRow(_))) { let rows = (pixels / (28.0 * session.view.scale)).round() as isize; session.view.scroll_sidebar( &mut session.editor, diff --git a/crates/stet-app/src/view.rs b/crates/stet-app/src/view.rs index f0cb16d..dbfe950 100644 --- a/crates/stet-app/src/view.rs +++ b/crates/stet-app/src/view.rs @@ -55,6 +55,10 @@ pub enum Target { Tab(usize), SidebarRow(usize), Sidebar, + /// A row of the outline panel, and its fold arrow. + OutlineRow(usize), + OutlineFold(usize), + Outline, PaletteRow(usize), Palette, ContextRow(usize), @@ -180,6 +184,9 @@ pub struct View { pub reduce_motion: bool, sidebar_selected: usize, palette_scroll: usize, + /// The outline row the cursor was in last frame, so the panel only + /// scrolls to follow when it changes. + outline_current: Option, /// Bottom-left of the text cursor in the last frame, if it was on screen. cursor_point: Option<(f32, f32)>, /// Margin sections and where they are pinned, for this frame. @@ -243,6 +250,7 @@ impl View { reduce_motion: false, sidebar_selected: usize::MAX, palette_scroll: 0, + outline_current: None, cursor_point: None, pins: Vec::new(), pin_tops: Vec::new(), @@ -425,11 +433,20 @@ impl View { } } + /// The outline panel's width on the right of the window. + fn outline_width(&self, ed: &Editor) -> f32 { + if ed.outline_panel.visible { + (230.0 * self.scale).min(self.width * 0.25).round() + } else { + 0.0 + } + } + /// Where the document pane ends and the margin begins, if it is open. fn split(&self, ed: &Editor) -> Option { - let chrome = self.sidebar_width(ed); - ed.margin_visible() - .then(|| (chrome + (self.width - chrome) * 0.58).round()) + let chrome = self.sidebar_width(ed) + self.outline_width(ed); + let right = self.width - self.outline_width(ed); + ed.margin_visible().then(|| (chrome + (right - chrome) * 0.58).round()) } /// True if a window x position is over the pane that does not have the @@ -437,6 +454,7 @@ impl View { pub fn over_other_pane(&self, ed: &Editor, x: f32) -> bool { self.split(ed) .is_some_and(|split| (x >= split) != ed.in_margin() && x >= self.sidebar_width(ed)) + && x < self.width - self.outline_width(ed) } fn metrics(&mut self, ed: &Editor) -> Metrics { @@ -469,11 +487,12 @@ impl View { }; // With the margin open the editor area is two panes; these are the // metrics of whichever one `ed` currently holds. - let chrome = self.sidebar_width(ed); + let chrome = self.sidebar_width(ed) + self.outline_width(ed); + let edge = self.width - self.outline_width(ed); let (origin, right) = match self.split(ed) { - Some(split) if ed.in_margin() => (split, self.width), + Some(split) if ed.in_margin() => (split, edge), Some(split) => (chrome, split), - None => (chrome, self.width), + None => (chrome, edge), }; let available = right - origin; // Leave room for hanging heading markers; less in a narrow pane. @@ -1024,6 +1043,11 @@ impl View { ed.sidebar.scroll = (ed.sidebar.scroll as isize + rows).clamp(0, last) as usize; } + pub fn scroll_outline(&mut self, ed: &mut Editor, rows: isize) { + let last = ed.outline_rows().len().saturating_sub(1) as isize; + ed.outline_panel.scroll = (ed.outline_panel.scroll as isize + rows).clamp(0, last) as usize; + } + pub fn frame(&mut self, ed: &mut Editor) -> Frame { ed.refresh(); self.frame += 1; @@ -1088,6 +1112,7 @@ impl View { self.status(ed, &m, &mut frame.base); self.tabs(ed, &m, &mut frame.base); self.sidebar(ed, &m, &mut frame.base); + self.outline_panel(ed, &m, &mut frame.base); self.palette(ed, &m, &mut frame.over); self.context_menu(ed, &m, &mut frame.over); self.retouching(ed, m.ui, &mut frame.over); @@ -1705,6 +1730,125 @@ impl View { } } + /// The outline panel: the document's headings beside the text, the one + /// the cursor is in marked, and the list following it when it changes. + fn outline_panel(&mut self, ed: &mut Editor, m: &Metrics, layer: &mut Layer) { + if !ed.outline_panel.visible { + self.outline_current = None; + return; + } + let c = ed.theme.colors; + let s = self.scale; + let width = self.outline_width(ed); + let left = self.width - width; + layer.back.push(Quad { + rect: [left, 0.0, width, self.height], + color: linear(c.panel, 1.0), + radius: 0.0, + }); + layer.back.push(Quad { + rect: [left, 0.0, s.max(1.0), self.height], + color: linear(c.rule, 0.6), + radius: 0.0, + }); + self.targets.push(([left, 0.0, width, self.height], Target::Outline)); + + let pad = (14.0 * s).round(); + let indent = (14.0 * s).round(); + let header_top = self.titlebar.max(10.0 * s) + 6.0 * s; + let key = self.label(ed, "OUTLINE", c.status, m.ui * 0.82, Face::Ui, true); + self.put( + layer, + key, + left + pad, + header_top + m.row / 2.0, + [left, 0.0, self.width - pad, self.height], + c.status, + ); + + let top = (header_top + m.row + 4.0 * s).round(); + let visible = (((self.height - top) / m.row).floor() as usize).max(1); + let count = ed.outline_rows().len(); + // The heading the cursor is in: the last one at or above it. + let line = ed.buf.line_of(ed.cursor); + let current = ed.outline_rows().iter().rposition(|row| row.line <= line); + // Follow it only when it changes, so wheeling the list by hand sticks. + if current != self.outline_current { + self.outline_current = current; + if let Some(at) = current { + if at < ed.outline_panel.scroll { + ed.outline_panel.scroll = at; + } else if at >= ed.outline_panel.scroll + visible { + ed.outline_panel.scroll = at + 1 - visible; + } + } + } + ed.outline_panel.scroll = ed.outline_panel.scroll.min(count.saturating_sub(visible)); + + let inset = (6.0 * s).round(); + for (slot, index) in (ed.outline_panel.scroll..count).take(visible + 1).enumerate() { + let row = &ed.outline_rows()[index]; + let y = top + slot as f32 * m.row; + let rect = [left + inset, y, width - inset * 2.0, m.row]; + let here = current == Some(index); + if here { + layer.back.push(Quad { + rect, + color: linear(c.panel_active, 1.0), + radius: 7.0 * s, + }); + } + self.targets.push((rect, Target::OutlineRow(index))); + let middle = y + m.row / 2.0; + let clip = [left + pad, top, self.width - pad, self.height]; + // One indent step per level below the top. + let mut x = left + pad + row.depth as f32 * indent; + // A fold arrow where there is something to fold; every row keeps + // the space so the text stays aligned. + let chevron = self.label( + ed, + if row.folded { "▸" } else { "▾" }, + c.status, + m.ui * 1.1, + Face::Ui, + false, + ); + let chevron_width = self.layouts[&chevron].rows[0].width; + if row.foldable { + self.targets + .push(([x, y, chevron_width + 6.0 * s, m.row], Target::OutlineFold(index))); + self.put(layer, chevron, x, middle, clip, c.status); + } + x += chevron_width + 6.0 * s; + if here { + // A mark on the heading the cursor is in. + layer.back.push(Quad { + rect: [ + left + 2.0 * s, + y + 6.0 * s, + (3.0 * s).round().max(1.0), + m.row - 12.0 * s, + ], + color: linear(c.cursor, 1.0), + radius: 2.0 * s, + }); + } + let key = self.label(ed, &row.text, c.text, m.ui, Face::Ui, here); + self.put(layer, key, x, middle, clip, c.text); + } + if count == 0 { + let key = self.label(ed, "No headings", c.status, m.ui, Face::Ui, false); + self.put( + layer, + key, + left + pad, + top + m.row / 2.0, + [0.0, 0.0, self.width - pad, self.height], + c.status, + ); + } + } + /// The right-click menu, at the pointer or under the cursor. fn context_menu(&mut self, ed: &Editor, m: &Metrics, layer: &mut Layer) { let Some(menu) = &ed.context_menu else { return }; @@ -2362,6 +2506,14 @@ impl View { let headers = (0..shown.len()).filter(|&slot| starts_section(slot)).count(); let list_height = (shown.len().max(1) as f32 * row + headers as f32 * header).round(); let height = input_height + list_height + footer + 8.0 * s; + // The outline indents by heading level and marks where the cursor is. + let outline = palette.kind == PaletteKind::Outline; + let at_cursor = outline + .then(|| { + let line = ed.buf.line_of(ed.cursor); + palette.outline.iter().rposition(|row| row.line <= line) + }) + .flatten(); layer.back.push(Quad { rect: [0.0, 0.0, self.width, self.height], @@ -2448,15 +2600,38 @@ impl View { [detail_left, top, left + width - pad, top + height], c.status, ); - let key = self.label(ed, &item.label, c.text, m.ui * 1.05, Face::Ui, false); - self.put( - layer, - key, - left + pad, - middle, - [left + pad, top, detail_left - pad * 0.5, top + height], - c.text, - ); + let clip = [left + pad, top, detail_left - pad * 0.5, top + height]; + let mut x = left + pad; + if let Some(heading) = outline.then(|| &palette.outline[palette.matches[at]]) { + // One indent step per level below the top. + x += heading.depth as f32 * (14.0 * s).round(); + // A fold arrow where there is something to fold; every row + // keeps the space so the text stays aligned. + let chevron = self.label( + ed, + if heading.folded { "▸" } else { "▾" }, + c.status, + m.ui * 1.1, + Face::Ui, + false, + ); + let chevron_width = self.layouts[&chevron].rows[0].width; + if heading.foldable { + self.put(layer, chevron, x, middle, clip, c.status); + } + x += chevron_width + 6.0 * s; + // The heading the cursor is in. + if at_cursor == Some(palette.matches[at]) { + layer.back.push(Quad { + rect: [left + 2.0 * s, y + 6.0 * s, (3.0 * s).round().max(1.0), row - 12.0 * s], + color: linear(c.cursor, 1.0), + radius: 2.0 * s, + }); + } + } + let here = outline && at_cursor == Some(palette.matches[at]); + let key = self.label(ed, &item.label, c.text, m.ui * 1.05, Face::Ui, here); + self.put(layer, key, x, middle, clip, c.text); y += row; } if shown.is_empty() { @@ -2471,7 +2646,11 @@ impl View { }; // A sheet opened from here takes over this panel at this height. self.palette_seen = Some((self.frame, height)); - let hint = format!("{position}↑↓ move ↵ choose esc close"); + let hint = if outline { + format!("{position}↑↓ move ↵ jump ← → fold esc close") + } else { + format!("{position}↑↓ move ↵ choose esc close") + }; let key = self.label(ed, &hint, c.status, m.ui * 0.85, Face::Ui, false); let hint_width = self.layouts[&key].rows[0].width; self.put( diff --git a/crates/stet-core/src/config.rs b/crates/stet-core/src/config.rs index a52f2ab..b75dcf6 100644 --- a/crates/stet-core/src/config.rs +++ b/crates/stet-core/src/config.rs @@ -43,6 +43,8 @@ pub struct Config { pub link_completion: bool, /// Show the file browser at startup. pub sidebar: bool, + /// Show the document's headings beside the text at startup. + pub outline: bool, /// While writing, show margin notes beside the text they are pinned to /// (false lists the margin from top to bottom at all times). pub pinned_notes: bool, @@ -118,6 +120,7 @@ impl Default for Config { highlight: true, link_completion: true, sidebar: false, + outline: false, pinned_notes: true, smart_paste: true, copy_html: true, diff --git a/crates/stet-core/src/editor/command.rs b/crates/stet-core/src/editor/command.rs index b31c45c..f84bf34 100644 --- a/crates/stet-core/src/editor/command.rs +++ b/crates/stet-core/src/editor/command.rs @@ -153,7 +153,10 @@ impl Editor { text: String::new(), }) } - "sidebar" | "browse" | "tree" | "Ex" | "Explore" => self.toggle_sidebar(), + "sidebar" | "browse" | "tree" | "Ex" | "Explore" if arg.is_empty() => self.toggle_sidebar(), + "outlinebar" | "outlinepanel" => self.toggle_outline_panel(), + "sidebar" if arg == "outline" => self.toggle_outline_panel(), + "outline" | "toc" => self.open_palette(PaletteKind::Outline), "backlinks" => self.open_palette(PaletteKind::Backlinks), "agent" | "ask" if arg.is_empty() => self.agent_prompt(), "agent" | "ask" => { diff --git a/crates/stet-core/src/editor/margin.rs b/crates/stet-core/src/editor/margin.rs index ba2ae19..696fbcc 100644 --- a/crates/stet-core/src/editor/margin.rs +++ b/crates/stet-core/src/editor/margin.rs @@ -68,6 +68,7 @@ impl Editor { scroll_px: 0.0, doc_id: self.next_doc_id, snapshot: None, + outline_folded: Default::default(), } } diff --git a/crates/stet-core/src/editor/mod.rs b/crates/stet-core/src/editor/mod.rs index 7e1b305..93351f3 100644 --- a/crates/stet-core/src/editor/mod.rs +++ b/crates/stet-core/src/editor/mod.rs @@ -7,6 +7,7 @@ mod links; mod margin; mod menu; mod motion; +mod outline; mod palette; mod paste; mod pins; @@ -25,6 +26,7 @@ pub use links::{Backlink, Link, link_in, rewrite_destinations}; pub use margin::margin_path; pub use menu::{ContextMenu, MenuAt, MenuItem}; pub use motion::Matcher; +pub use outline::{OutlinePanel, OutlineRow}; pub use palette::{Act, Item, Palette, PaletteKind}; pub use paste::{Clip, is_picture, is_video}; pub use pins::Pin; @@ -40,7 +42,7 @@ use crate::input::{Key, KeyEvent, Mods}; use crate::markdown::{self, Doc}; use crate::theme::{Theme, Themes}; use std::cell::RefCell; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -283,6 +285,13 @@ pub struct Editor { code_cache: RefCell, /// Tables of the current analysis. table_cache: RefCell, + /// Headings folded away in the outline, by level and text. + outline_folded: HashSet, + /// The outline panel beside the text. + pub outline_panel: OutlinePanel, + /// The panel's rows, and the `(document, revision)` they were built at. + outline_rows: Vec, + outline_rows_key: Option<(u64, u64)>, } impl Editor { @@ -351,6 +360,10 @@ impl Editor { highlighter: RefCell::new(Highlighter::default()), code_cache: RefCell::new((None, HashMap::new())), table_cache: RefCell::new((None, Vec::new())), + outline_folded: HashSet::new(), + outline_panel: OutlinePanel::default(), + outline_rows: Vec::new(), + outline_rows_key: None, config, } } @@ -374,6 +387,7 @@ impl Editor { // Block indices belong to this analysis only. self.code_cache.borrow_mut().1.clear(); } + self.refresh_outline_rows(); } pub fn doc(&self) -> &Doc { diff --git a/crates/stet-core/src/editor/outline.rs b/crates/stet-core/src/editor/outline.rs new file mode 100644 index 0000000..0630399 --- /dev/null +++ b/crates/stet-core/src/editor/outline.rs @@ -0,0 +1,237 @@ +//! The outline: the document's headings as a foldable tree. Two views share +//! one state — the pop-up menu (`g o`, keyboard first) and the panel beside +//! the text (mouse first, follows the cursor) — and one set of folds, keyed +//! by heading so an edit that moves text around keeps them. + +use super::{Act, Editor, Item, Palette}; +use crate::input::{Key, KeyEvent}; +use crate::markdown::Heading; +use std::collections::HashSet; + +/// A folded heading, by its level and text. The text keeps the fold on the +/// right heading as lines move; renaming or removing it drops the fold. +pub(super) type Fold = (u8, String); + +/// One row of the outline, in both the menu and the panel. +pub struct OutlineRow { + /// 0 for a top-level heading. + pub depth: u8, + /// The heading's first line. + pub line: usize, + /// Headings nest under this one, so it can be folded. + pub foldable: bool, + pub folded: bool, + pub text: String, +} + +/// The panel beside the text: the document's headings, following the cursor. +#[derive(Default)] +pub struct OutlinePanel { + pub visible: bool, + pub scroll: usize, +} + +/// The visible headings as rows, subtrees of folded headings dropped. +pub(super) fn rows(entries: &[Heading], folded: &HashSet) -> Vec { + let mut out = Vec::with_capacity(entries.len()); + // A folded heading hides everything under it until one of its own level + // or shallower turns up. + let mut hidden: Option = None; + for (index, heading) in entries.iter().enumerate() { + if let Some(level) = hidden { + if heading.level > level { + continue; + } + hidden = None; + } + let foldable = entries.get(index + 1).is_some_and(|next| next.level > heading.level); + let folds = foldable + && folded + .iter() + .any(|(level, text)| *level == heading.level && text == &heading.text); + if folds { + hidden = Some(heading.level); + } + out.push(OutlineRow { + depth: heading.level.saturating_sub(1), + line: heading.line, + foldable, + folded: folds, + text: heading.text.clone(), + }); + } + out +} + +/// The visible headings as menu items, with a row beside each. +pub(super) fn build(entries: &[Heading], folded: &HashSet) -> (Vec, Vec) { + let rows = rows(entries, folded); + let items = rows + .iter() + .map(|row| Item { + label: row.text.clone(), + detail: String::new(), + section: String::new(), + act: Act::Goto { line: row.line }, + }) + .collect(); + (items, rows) +} + +/// Index of the row the cursor is in: the last heading at or above it. +pub(super) fn at_cursor(rows: &[OutlineRow], line: usize) -> usize { + rows.iter().rposition(|row| row.line <= line).unwrap_or(0) +} + +impl Editor { + /// Opens the outline menu, starting on the heading the cursor is in. + pub(super) fn open_outline(&mut self) { + self.refresh(); + let entries = self.doc.headings(); + if entries.is_empty() { + return self.info("no headings in this document"); + } + let (items, rows) = build(&entries, &self.outline_folded); + let mut palette = Palette::outline(items, rows); + palette.selected = at_cursor(&palette.outline, self.buf.line_of(self.cursor)); + self.palette = Some(palette); + } + + /// Shows or hides the outline panel. + pub fn toggle_outline_panel(&mut self) { + self.outline_panel.visible = !self.outline_panel.visible; + self.outline_panel.scroll = 0; + // The rows are rebuilt on the next frame's refresh. + self.outline_rows_key = None; + } + + /// The panel's rows for the current analysis. + pub fn outline_rows(&self) -> &[OutlineRow] { + &self.outline_rows + } + + /// Rebuilds the panel's rows when the analysis, or the document, changed. + pub(super) fn refresh_outline_rows(&mut self) { + if !self.outline_panel.visible { + return; + } + let key = (self.doc_id, self.buf.revision()); + if self.outline_rows_key == Some(key) { + return; + } + self.outline_rows = rows(&self.doc.headings(), &self.outline_folded); + self.outline_rows_key = Some(key); + } + + /// Puts the cursor on `line`'s first character, as a jump back can find. + pub(super) fn goto_line(&mut self, line: usize) { + self.push_jump(); + self.cursor = self.buf.line_start(line.min(self.buf.line_count() - 1)); + self.clamp_cursor(); + } + + /// A click on the panel's row `index`: jump to its heading. + pub fn outline_panel_click(&mut self, index: usize) { + let Some(line) = self.outline_rows.get(index).map(|row| row.line) else { + return; + }; + self.goto_line(line); + } + + /// A click on the panel's row `index` fold arrow. The rows rebuild on the + /// next frame's refresh. + pub fn outline_panel_fold(&mut self, index: usize) { + let Some(row) = self.outline_rows.get(index) else { + return; + }; + if !row.foldable { + return; + } + let fold = (row.depth + 1, row.text.clone()); + if row.folded { + self.outline_folded.remove(&fold); + } else { + self.outline_folded.insert(fold); + } + self.outline_rows_key = None; + } + + /// Folds or unfolds the selected heading's subtree, keeping the + /// selection on it. + pub(super) fn outline_fold(&mut self, fold: bool) { + let selected = self.palette.as_ref().and_then(|palette| { + let row = palette.outline.get(palette.matches.get(palette.selected).copied()?)?; + Some((row.line, row.depth + 1, row.text.clone(), row.foldable, row.folded)) + }); + let Some((line, level, text, foldable, folded)) = selected else { + return; + }; + if fold == folded || (fold && !foldable) { + return; + } + if fold { + self.outline_folded.insert((level, text)); + } else { + self.outline_folded.remove(&(level, text)); + } + self.outline_rebuild(Some(line)); + } + + /// Rebuilds the rows after a fold change, keeping the selection on the + /// same heading and replaying the query. + fn outline_rebuild(&mut self, keep: Option) { + self.refresh(); + let (items, rows) = build(&self.doc.headings(), &self.outline_folded); + let Some(palette) = &mut self.palette else { + return; + }; + palette.items = items; + palette.outline = rows; + palette.filter(); + if let Some(line) = keep { + palette.selected = palette + .matches + .iter() + .position(|&index| palette.outline[index].line == line) + .unwrap_or(0); + } + } + + /// Keys while the outline is open. Esc and Tab put the keyboard back in + /// the document; j/k and the arrows move; Left/Right fold; everything + /// else filters. + pub(super) fn outline_key(&mut self, event: KeyEvent) { + let ctrl = event.mods.ctrl; + let plain = event.plain_char(); + match event.key { + Key::Esc | Key::Tab => self.close_palette(), + Key::Char('c' | '[' | 'g') if ctrl => self.close_palette(), + Key::Enter => self.palette_run(), + Key::Left => self.outline_fold(true), + Key::Right => self.outline_fold(false), + Key::Down => self.palette_move(1), + Key::Up => self.palette_move(-1), + Key::PageDown => self.palette_move(8), + Key::PageUp => self.palette_move(-8), + Key::Char('j') if plain.is_some() => self.palette_move(1), + Key::Char('k') if plain.is_some() => self.palette_move(-1), + Key::Char('n') if ctrl => self.palette_move(1), + Key::Char('p') if ctrl => self.palette_move(-1), + Key::Char('d') if ctrl => self.palette_move(4), + Key::Char('u') if ctrl => self.palette_move(-4), + _ => { + let Some(palette) = self.palette.as_mut() else { + return; + }; + match event.key { + Key::Backspace => { + palette.query.pop(); + } + Key::Char(c) if plain.is_some() => palette.query.push(c), + _ => return, + } + palette.filter(); + } + } + } +} diff --git a/crates/stet-core/src/editor/palette.rs b/crates/stet-core/src/editor/palette.rs index f7b8deb..a97632a 100644 --- a/crates/stet-core/src/editor/palette.rs +++ b/crates/stet-core/src/editor/palette.rs @@ -2,7 +2,7 @@ //! its shortcut, markdown syntax, vim keys), opening files, backlinks, and //! picking themes and fonts. -use super::{Editor, Mode, Primary}; +use super::{Editor, Mode, OutlineRow, Primary}; use crate::input::{Key, KeyEvent}; use std::path::PathBuf; @@ -15,6 +15,8 @@ pub enum PaletteKind { Notes, Themes, Fonts, + /// The document's headings. + Outline, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -30,6 +32,10 @@ pub enum Act { path: PathBuf, line: Option, }, + /// Jump to a line of this document. + Goto { + line: usize, + }, /// Reference rows do nothing. None, } @@ -53,6 +59,8 @@ pub struct Palette { pub selected: usize, /// Command that undoes a live preview when the menu is dismissed. revert: Option, + /// Rows of the outline, parallel to `items`; empty for every other kind. + pub outline: Vec, } impl Palette { @@ -65,12 +73,29 @@ impl Palette { items, selected: 0, revert: None, + outline: Vec::new(), + }; + palette.filter(); + palette + } + + /// The outline menu: headings as items, their rows beside them. + pub(super) fn outline(items: Vec, rows: Vec) -> Palette { + let mut palette = Palette { + kind: PaletteKind::Outline, + placeholder: "Document outline".to_string(), + query: String::new(), + matches: Vec::new(), + items, + selected: 0, + revert: None, + outline: rows, }; palette.filter(); palette } - fn filter(&mut self) { + pub(super) fn filter(&mut self) { let query = self.query.to_lowercase(); let words: Vec<&str> = query.split_whitespace().collect(); let mut ranked: Vec<(u8, usize)> = self @@ -156,6 +181,10 @@ const VIM: &[(&str, &str)] = &[ ("Marks", "ma `a 'a"), ("Scroll", "Ctrl-d Ctrl-u Ctrl-f Ctrl-b zz zt zb"), ("Links", "gf or Enter follows Ctrl-o back Ctrl-i forward"), + ( + "Outline", + "g o opens headings g O the panel j k move ↵ jumps ← → fold", + ), ("Tabs", "]t [t :tabnew :q"), ("Tasks and suggestions", "gt toggles a task ]s [s gsa gsr"), ( @@ -202,6 +231,8 @@ impl Editor { command("Back", format!("{} Ctrl-o", chord(false, "[")), "back"), command("Forward", format!("{} Ctrl-i", chord(false, "]")), "forward"), command("Notes linking here…", chord(true, "L"), "backlinks"), + command("Document outline…", "g o".to_string(), "outline"), + command("Document outline panel", "g O".to_string(), "outlinebar"), command( "Show or hide the margin (research notes)", format!("{} Ctrl-w m", chord(true, "M")), @@ -329,6 +360,7 @@ impl Editor { let (placeholder, items) = match kind { PaletteKind::Help => ("Search commands, shortcuts and markdown", self.help_items()), PaletteKind::Files => ("Go to file", self.file_items()), + PaletteKind::Outline => return self.open_outline(), PaletteKind::Notes => { let mut items = self.file_items(); for item in &mut items { @@ -430,7 +462,7 @@ impl Editor { } } - fn palette_move(&mut self, delta: isize) { + pub(super) fn palette_move(&mut self, delta: isize) { let Some(palette) = &mut self.palette else { return }; let count = palette.matches.len() as isize; if count == 0 { @@ -456,6 +488,13 @@ impl Editor { pub(super) fn palette_key(&mut self, event: KeyEvent) { let ctrl = event.mods.ctrl; + if self + .palette + .as_ref() + .is_some_and(|palette| palette.kind == PaletteKind::Outline) + { + return self.outline_key(event); + } match event.key { Key::Esc => self.close_palette(), Key::Char('c' | '[' | 'g') if ctrl => self.close_palette(), @@ -500,7 +539,7 @@ impl Editor { } } - fn palette_run(&mut self) { + pub(super) fn palette_run(&mut self) { let Some(palette) = self.palette.take() else { return }; let Some(item) = palette.selected_item() else { return }; match item.act.clone() { @@ -517,6 +556,7 @@ impl Editor { Err(err) => self.error(err), } } + Act::Goto { line } => self.goto_line(line), Act::Insert { text, block } => { let line = self.buf.line_of(self.cursor); if self.mode != Mode::Insert { diff --git a/crates/stet-core/src/editor/tabs.rs b/crates/stet-core/src/editor/tabs.rs index 00531b1..5279b5e 100644 --- a/crates/stet-core/src/editor/tabs.rs +++ b/crates/stet-core/src/editor/tabs.rs @@ -3,9 +3,11 @@ //! The active document lives in the editor's own fields; the others are //! stashed whole and swapped in when their tab is selected. +use super::outline; use super::{Editor, Effect, Mode}; use crate::buffer::Buffer; use crate::markdown::Doc; +use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::sync::mpsc::{Sender, channel}; use std::sync::{Arc, Condvar, Mutex}; @@ -24,6 +26,8 @@ pub(super) struct Stash { pub(super) scroll_px: f32, pub(super) doc_id: u64, pub(super) snapshot: Option, + /// Outline folds belong to the document, not the window. + pub(super) outline_folded: HashSet, } pub struct TabInfo { @@ -151,6 +155,7 @@ impl Editor { scroll_px: self.scroll_px, doc_id: self.doc_id, snapshot: self.snapshot.take(), + outline_folded: std::mem::take(&mut self.outline_folded), } } @@ -165,6 +170,7 @@ impl Editor { self.scroll_px = stash.scroll_px; self.doc_id = stash.doc_id; self.snapshot = stash.snapshot; + self.outline_folded = stash.outline_folded; self.anchor = None; self.group_open = false; self.code_cache.borrow_mut().0 = None; @@ -205,6 +211,7 @@ impl Editor { swap(&mut self.scroll_px, &mut other.scroll_px); swap(&mut self.doc_id, &mut other.doc_id); swap(&mut self.snapshot, &mut other.snapshot); + swap(&mut self.outline_folded, &mut other.outline_folded); self.code_cache.borrow_mut().0 = None; self.table_cache.borrow_mut().0 = None; } diff --git a/crates/stet-core/src/editor/tests.rs b/crates/stet-core/src/editor/tests.rs index 2dcaf20..83c61c4 100644 --- a/crates/stet-core/src/editor/tests.rs +++ b/crates/stet-core/src/editor/tests.rs @@ -1007,6 +1007,140 @@ fn the_menu_lists_runs_and_inserts() { assert_eq!(ed.config.prose_font[..2], ["Menlo".to_string(), "Georgia".to_string()]); } +#[test] +fn the_outline_lists_headings_and_jumps() { + let mut ed = editor("# Top\n\nintro\n\n## Middle\n\nbo|dy\n\n### Deep\n\nend"); + // Opens on the heading the cursor is in, as an indented list. + keys(&mut ed, ":outline"); + let labels = |ed: &Editor| { + ed.palette + .as_ref() + .unwrap() + .items + .iter() + .map(|item| item.label.clone()) + .collect::>() + }; + assert_eq!(ed.palette.as_ref().unwrap().kind, PaletteKind::Outline); + assert_eq!(labels(&ed), ["Top", "Middle", "Deep"]); + let depths = |ed: &Editor| { + ed.palette + .as_ref() + .unwrap() + .outline + .iter() + .map(|row| row.depth) + .collect::>() + }; + assert_eq!(depths(&ed), [0, 1, 2]); + assert_eq!(ed.palette.as_ref().unwrap().selected, 1); + // j/k move; Enter jumps and closes. + keys(&mut ed, "j"); + assert_eq!(ed.palette.as_ref().unwrap().selected, 2); + keys(&mut ed, "k"); + assert_eq!(ed.palette.as_ref().unwrap().selected, 1); + keys(&mut ed, ""); + assert!(ed.palette.is_none()); + assert_eq!(show(&ed), "# Top\n\nintro\n\n|## Middle\n\nbody\n\n### Deep\n\nend"); + // Typing filters the visible headings. + keys(&mut ed, ":outlinedeep"); + assert!(ed.palette.is_none()); + assert_eq!(show(&ed), "# Top\n\nintro\n\n## Middle\n\nbody\n\n|### Deep\n\nend"); + // Esc and Tab put the keyboard back. + keys(&mut ed, ":outline"); + assert!(ed.palette.is_none()); + keys(&mut ed, ":outline"); + assert!(ed.palette.is_none()); + // `g o` opens it too, on the heading under the cursor. + keys(&mut ed, "gg:outlinejgg"); + assert_eq!(show(&ed), "|# Top\n\nintro\n\n## Middle\n\nbody\n\n### Deep\n\nend"); + keys(&mut ed, "go"); + assert_eq!(ed.palette.as_ref().unwrap().kind, PaletteKind::Outline); + assert_eq!(ed.palette.as_ref().unwrap().selected, 0); + // Left folds a heading's subtree away; Right brings it back. + keys(&mut ed, ""); + assert_eq!(labels(&ed), ["Top"]); + assert_eq!(ed.palette.as_ref().unwrap().selected, 0); + keys(&mut ed, ""); + assert_eq!(labels(&ed), ["Top", "Middle", "Deep"]); + assert_eq!(ed.palette.as_ref().unwrap().selected, 0); + // Folding a heading without children does nothing. + keys(&mut ed, "jj"); + assert_eq!(labels(&ed), ["Top", "Middle", "Deep"]); + // An edit that leaves the headings alone keeps the folds: a fold is + // keyed by the heading's text, not by its line. + keys(&mut ed, "kkGAx:outline"); + assert_eq!(labels(&ed), ["Top"]); + assert_eq!(ed.buf.text(), "# Top\n\nintro\n\n## Middle\n\nbody\n\n### Deep\n\nendx"); + // A document without headings says so instead of opening. + let mut plain = editor("|just text"); + keys(&mut plain, ":outline"); + assert!(plain.palette.is_none()); + assert_eq!(plain.message.as_ref().unwrap().text, "no headings in this document"); +} + +#[test] +fn the_outline_panel_follows_and_folds() { + let mut ed = editor("# Top\n\nintro\n\n## Middle\n\nbody\n"); + let labels = |ed: &Editor| ed.outline_rows().iter().map(|row| row.text.clone()).collect::>(); + // Hidden until asked for, by any of its commands. + assert!(!ed.outline_panel.visible); + assert!(!Config::default().outline); + keys(&mut ed, ":outlinebar"); + assert!(ed.outline_panel.visible); + keys(&mut ed, ":outlinebar"); + assert!(!ed.outline_panel.visible); + keys(&mut ed, ":sidebar outline"); + assert!(ed.outline_panel.visible); + keys(&mut ed, ":outlinepanel"); + assert!(!ed.outline_panel.visible); + keys(&mut ed, "gO"); + assert!(ed.outline_panel.visible); + ed.refresh(); + assert_eq!(labels(&ed), ["Top", "Middle"]); + assert_eq!( + ed.outline_rows() + .iter() + .map(|row| (row.depth, row.line, row.foldable)) + .collect::>(), + [(0, 0, true), (1, 4, false)] + ); + // A click on a row jumps to its heading. + ed.outline_panel_click(1); + assert_eq!(show(&ed), "# Top\n\nintro\n\n|## Middle\n\nbody\n"); + // Folding hides the subtree, and the pop-up sees the same folds. + ed.outline_panel_fold(0); + ed.refresh(); + assert_eq!(labels(&ed), ["Top"]); + keys(&mut ed, ":outline"); + let menu = ed.palette.as_ref().unwrap(); + assert_eq!(menu.kind, PaletteKind::Outline); + assert_eq!( + menu.items.iter().map(|item| item.label.as_str()).collect::>(), + ["Top"] + ); + keys(&mut ed, ""); + // The folds survive an edit that leaves the headings alone. + keys(&mut ed, "GAx"); + ed.refresh(); + assert_eq!(labels(&ed), ["Top"]); + // Renaming the heading drops its fold. + keys(&mut ed, "ggwcwTops"); + ed.refresh(); + assert_eq!(labels(&ed), ["Tops", "Middle"]); + // Folds belong to the document: a new tab starts unfolded. + ed.outline_panel_fold(0); + ed.refresh(); + assert_eq!(labels(&ed), ["Tops"]); + keys(&mut ed, ":tabnew"); + ed.set_text("# Top\n\n## Middle\n"); + ed.refresh(); + assert_eq!(labels(&ed), ["Top", "Middle"]); + keys(&mut ed, ":tabprevious"); + ed.refresh(); + assert_eq!(labels(&ed), ["Tops"]); +} + #[test] fn the_file_browser_walks_and_opens() { let dir = temp_dir("browser"); diff --git a/crates/stet-core/src/editor/vim.rs b/crates/stet-core/src/editor/vim.rs index fe500af..c2180da 100644 --- a/crates/stet-core/src/editor/vim.rs +++ b/crates/stet-core/src/editor/vim.rs @@ -216,6 +216,10 @@ enum Action { ToggleTask, ContextMenu, AgentPrompt, + /// The document's headings, in the pop-up menu. + Outline, + /// The document's headings, in the panel beside the text. + OutlinePanel, /// `Ctrl-w` and a letter: the margin and its pane. Pane(char), RecordMacro(char), @@ -466,6 +470,14 @@ fn parse_action(event: KeyEvent, r: &mut Reader, visual: bool) -> Result { + r.at += 1; + Outline + } + Some('O') => { + r.at += 1; + OutlinePanel + } Some('a') => { r.at += 1; AgentPrompt @@ -825,6 +837,8 @@ impl Editor { } Action::Resolve { accept } => self.resolve_at_cursor(accept), Action::ToggleTask => self.toggle_task(), + Action::Outline => self.open_palette(super::PaletteKind::Outline), + Action::OutlinePanel => self.toggle_outline_panel(), Action::ContextMenu => self.open_context_menu(super::MenuAt::Cursor), Action::AgentPrompt => self.agent_prompt(), Action::Pane(key) => match key { diff --git a/crates/stet-core/src/markdown.rs b/crates/stet-core/src/markdown.rs index 6a36c9a..6319204 100644 --- a/crates/stet-core/src/markdown.rs +++ b/crates/stet-core/src/markdown.rs @@ -86,6 +86,16 @@ pub struct CodeBlock { pub lines: Vec<(u32, u32)>, } +/// A heading of the document, for the outline. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Heading { + /// The heading's first line. + pub line: usize, + pub level: u8, + /// The heading's text, with ATX or setext markers stripped. + pub text: String, +} + #[derive(Default)] pub struct Doc { lines: Vec, @@ -150,6 +160,112 @@ impl Doc { pub fn in_fence(&self, line: usize) -> bool { self.fences.iter().any(|fence| fence.contains(&line)) } + /// Every heading in document order. ATX headings are one line; a setext + /// heading is its content lines, the underline dropped. Text inside a + /// fence never carries a heading block, so none of it shows up here. + pub fn headings(&self) -> Vec { + let line = |index: usize| { + let start = self.line_starts[index]; + let end = self + .line_starts + .get(index + 1) + .map_or(self.source.len(), |next| next - 1); + &self.source[start..end.max(start)] + }; + let mut out = Vec::new(); + let mut at = 0; + while at < self.lines.len() { + let Block::Heading(level) = self.lines[at].block else { + at += 1; + continue; + }; + let first = markers_stripped(line(at)); + if first.starts_with('#') { + // ATX: exactly one line, optional closing hashes. + at += 1; + let text = atx_text(first); + if !text.is_empty() { + out.push(Heading { + line: at - 1, + level, + text, + }); + } + continue; + } + // Setext: content lines that neither open an ATX heading nor are + // the underline, which closes the heading. + let mut last = at; + while last + 1 < self.lines.len() && self.lines[last + 1].block == Block::Heading(level) && { + let next = markers_stripped(line(last + 1)); + !next.starts_with('#') && !is_underline(next) + } { + last += 1; + } + let underline = last + 1 < self.lines.len() + && self.lines[last + 1].block == Block::Heading(level) + && is_underline(markers_stripped(line(last + 1))); + let (content_end, next) = if underline { + (last + 1, last + 2) + } else { + (last + 1, last + 1) + }; + let text = (at..content_end) + .map(|index| markers_stripped(line(index)).trim()) + .collect::>() + .join(" "); + if !text.is_empty() { + out.push(Heading { line: at, level, text }); + } + at = next.max(at + 1); + } + out + } +} + +/// The line with quote and list markers and surrounding space removed. +fn markers_stripped(line: &str) -> &str { + let mut text = line.trim_start(); + loop { + let rest = text.trim_start_matches([' ', '\t']); + let after = if let Some(after) = rest.strip_prefix('>') { + Some(after.trim_start_matches([' ', '\t'])) + } else if let Some(after) = rest + .strip_prefix(['-', '*', '+']) + .filter(|_| rest[1..].starts_with([' ', '\t'])) + { + Some(after.trim_start_matches([' ', '\t'])) + } else { + // An ordered list marker: digits, `.` or `)`, then space. + let digits = rest.len() - rest.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + (digits > 0 + && digits <= 9 + && rest[digits..].starts_with(['.', ')']) + && matches!(rest.as_bytes().get(digits + 1), Some(b' ' | b'\t'))) + .then(|| rest[digits + 1..].trim_start_matches([' ', '\t'])) + }; + match after { + Some(after) if after.len() < rest.len() => text = after, + _ => return rest, + } + } +} + +/// True for a setext underline: nothing but `=` or `-`. +fn is_underline(text: &str) -> bool { + !text.is_empty() && text.bytes().all(|b| b == b'=' || b == b'-') +} + +/// The text of an ATX heading line, hashes and closing sequence removed. +fn atx_text(line: &str) -> String { + let text = line.trim_start_matches('#').trim(); + let hashes = text.len() - text.trim_end_matches('#').len(); + let text = if hashes > 0 && (text.len() == hashes || text.as_bytes()[text.len() - hashes - 1] == b' ') { + text[..text.len() - hashes].trim_end() + } else { + text + }; + text.to_string() } pub(crate) fn options() -> Options { @@ -1092,6 +1208,84 @@ mod tests { assert_eq!(styled("Setext\n===", 1), vec![s("===", MARKER)]); } + #[test] + fn headings_outline_extracts_levels_and_text() { + let doc = parse( + "# One\n\n## Two *em*\n\nSetext\n===\n\nDeep\n---\n\n> # Quoted\n\n- # In a list\n\n```\n# not a heading\n```\n\n#### Four ####\n\n##\n", + ); + assert_eq!( + doc.headings(), + vec![ + Heading { + line: 0, + level: 1, + text: "One".into() + }, + Heading { + line: 2, + level: 2, + text: "Two *em*".into() + }, + Heading { + line: 4, + level: 1, + text: "Setext".into() + }, + Heading { + line: 7, + level: 2, + text: "Deep".into() + }, + Heading { + line: 10, + level: 1, + text: "Quoted".into() + }, + Heading { + line: 12, + level: 1, + text: "In a list".into() + }, + Heading { + line: 18, + level: 4, + text: "Four".into() + }, + ] + ); + } + + #[test] + fn headings_outline_multiline_and_adjacent_setext() { + let doc = parse("Title\nmore\n===\n\nOther\n===\n\n# Atx\n\nThird\n===\n"); + assert_eq!( + doc.headings(), + vec![ + Heading { + line: 0, + level: 1, + text: "Title more".into() + }, + Heading { + line: 4, + level: 1, + text: "Other".into() + }, + Heading { + line: 7, + level: 1, + text: "Atx".into() + }, + Heading { + line: 9, + level: 1, + text: "Third".into() + }, + ] + ); + assert!(parse("just text\n\nmore text").headings().is_empty()); + } + #[test] fn links_images_and_autolinks() { assert_eq!(