git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

src/main.rs (93.9K)

   1 //! `cce-notes` — the vault's notes editor (Obsidian-on-cce, milestone 2).
   2 //!
   3 //! A file tree on the left and one note on the right, read as rendered
   4 //! Markdown (the reading view) or edited in live preview (markup shown
   5 //! only on the caret's lines, cce-ui's `DocEditor`), toggled with Ctrl+E as
   6 //! in Obsidian; Ctrl+Shift+E turns the preview off (source mode). Links
   7 //! follow on click — or Ctrl+click on the caret's line — and a link to a
   8 //! note that does not exist creates it. Ctrl+O is the quick switcher;
   9 //! Alt+←/→ walk the history.
  10 //!
  11 //! The files are the source of truth and there is no daemon: the app
  12 //! embeds a `cce_vault::Index` and a watcher, so an edit from Obsidian,
  13 //! Dropbox or another cce app arrives like any other change. A clean
  14 //! buffer reloads silently; a dirty one is flagged as a conflict and never
  15 //! overwritten. Editing saves a moment after typing stops, on leaving the
  16 //! note, and on exit.
  17 //!
  18 //! The vault comes from `--vault <dir>`, `$CCE_VAULT`, or
  19 //! `vault { path "…" }` in `~/.config/cce/config.kdl`. The app runs single
  20 //! instance (see [`instance`]); `cce-notes <note>` opens a note in it.
  21 
  22 mod images;
  23 mod instance;
  24 mod reading;
  25 mod tree;
  26 mod complete;
  27 mod mcp;
  28 mod panel;
  29 mod paste;
  30 mod side;
  31 
  32 use std::path::{Path, PathBuf};
  33 use std::sync::OnceLock;
  34 use std::time::{Duration, Instant};
  35 
  36 use cce_ui::widget::Handle;
  37 use cce_ui::engine::{Application, CursorIcon, LogicalPosition, LogicalSize, WindowSettings};
  38 use cce_ui::scene::layout::Rect;
  39 use cce_ui::scene::paint::{DisplayList, PaintCtx};
  40 use cce_ui::widget::{
  41     Adapted, Bounds, ElementState, Event, Key, KeyEvent, MouseButton, MouseScrollDelta, NamedKey, ScrollMotion,
  42     TextBox, WidgetHost,
  43 };
  44 use cce_ui::widget::doc_editor::{self, DocEditor, EditorTheme, Pos};
  45 use cce_vault::markdown::{Block, SpanLink};
  46 use cce_vault::{FileKind, Index, VaultWatcher};
  47 
  48 use instance::Command;
  49 use panel::{Action, Panel};
  50 use reading::{srgb_u8, Hit, Layout, ShapingMeasure, Theme};
  51 
  52 const BAND_H: f32 = 40.0;
  53 const STATUS_H: f32 = 26.0;
  54 const TREE_W: f32 = 240.0;
  55 const TREE_ROW_H: f32 = 24.0;
  56 /// Obsidian's "readable line length": the reading column's widest.
  57 const READ_MAX_W: f32 = 720.0;
  58 const READ_PAD: f32 = 28.0;
  59 const READ_SIZE: f32 = 15.0;
  60 const SWITCHER_W: f32 = 560.0;
  61 const SWITCHER_ROW_H: f32 = 28.0;
  62 const SWITCHER_ROWS: usize = 10;
  63 const SIDE_W: f32 = 260.0;
  64 /// The note pane's narrowest before the side pane gives way to it.
  65 const NOTE_MIN_W: f32 = 360.0;
  66 const COMPLETE_W: f32 = 320.0;
  67 const COMPLETE_ROW_H: f32 = 24.0;
  68 /// Editing saves once typing has paused this long.
  69 const AUTOSAVE_AFTER: Duration = Duration::from_millis(1500);
  70 
  71 const FG: [f32; 4] = cce_ui::colors::TEXT_FG;
  72 const DIM: [f32; 4] = cce_ui::colors::TEXT_DIM;
  73 const ROW_HOVER: [f32; 4] = [1.0, 1.0, 1.0, 0.05];
  74 const ROW_CURRENT: [f32; 4] = [1.0, 1.0, 1.0, 0.10];
  75 const CONFLICT: [f32; 4] = [0.95, 0.45, 0.35, 1.0];
  76 
  77 #[derive(Debug, Clone)]
  78 pub enum Message {
  79     Command(Command),
  80     VaultChanged(Vec<PathBuf>),
  81     Mcp(cce_ui::mcp::McpToolCall),
  82     /// An embedded image finished decoding (vault path, pixels or failure).
  83     ImageDecoded(String, Option<images::Decoded>),
  84     /// Links resolved to images already decoded: lay out again.
  85     ImagesReady,
  86     Exit,
  87 }
  88 
  89 /// Startup arguments, fixed before the engine starts (`Application::new`
  90 /// takes none).
  91 struct Startup {
  92     vault: Result<PathBuf, String>,
  93     command: Command,
  94 }
  95 
  96 static STARTUP: OnceLock<Startup> = OnceLock::new();
  97 
  98 /// Shortcuts, from input.kdl's `cce-notes` domain over the `cce-ui` one.
  99 struct Keys {
 100     switcher: String,
 101     toggle_mode: String,
 102     toggle_source: String,
 103     save: String,
 104     reload: String,
 105     back: String,
 106     forward: String,
 107     toggle_tree: String,
 108     toggle_side: String,
 109     search: String,
 110     rename: String,
 111     graph: String,
 112     daily: String,
 113     quit: String,
 114 }
 115 
 116 impl Keys {
 117     fn load() -> Keys {
 118         let get = cce_ui::input::app_chord;
 119         Keys {
 120             switcher: get("quick_switcher", "ctrl+o"),
 121             toggle_mode: get("toggle_mode", "ctrl+e"),
 122             toggle_source: get("toggle_source", "ctrl+shift+e"),
 123             save: get("save", "ctrl+s"),
 124             reload: get("reload", "ctrl+r"),
 125             back: get("back", "alt+arrowleft"),
 126             forward: get("forward", "alt+arrowright"),
 127             toggle_tree: get("toggle_tree", "ctrl+\\"),
 128             toggle_side: get("toggle_side", "ctrl+]"),
 129             search: get("search", "ctrl+shift+f"),
 130             rename: get("rename", "f2"),
 131             graph: get("graph", "ctrl+g"),
 132             daily: get("daily", "alt+d"),
 133             quit: get("quit", "ctrl+q"),
 134         }
 135     }
 136 }
 137 
 138 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 139 enum Mode {
 140     Reading,
 141     /// The editor: live preview, or source with the preview off.
 142     Source,
 143 }
 144 
 145 /// The left pane's tabs.
 146 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 147 enum LeftTab {
 148     Files,
 149     Search,
 150 }
 151 
 152 /// The right pane's tabs.
 153 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 154 enum SideTab {
 155     Backlinks,
 156     Outline,
 157 }
 158 
 159 /// `[[` completion while editing: the link being typed and its choices.
 160 struct Completion {
 161     /// The caret's line, and the char index in it just past the `[[`.
 162     line: usize,
 163     start: usize,
 164     query: String,
 165     /// Vault paths, best first.
 166     choices: Vec<String>,
 167     selected: usize,
 168 }
 169 
 170 /// One quick-switcher result: an existing note, or "create this".
 171 /// `alias` is the name the query matched when that is not the note's own
 172 /// (an alias): the row shows the path, an `arrow-left` glyph, then it.
 173 #[derive(Clone, Debug)]
 174 enum Choice {
 175     Note { path: String, alias: Option<String> },
 176     Create(String),
 177 }
 178 
 179 struct Switcher {
 180     choices: Vec<Choice>,
 181     selected: usize,
 182     /// The query the choices were computed for.
 183     query: String,
 184     /// A rename prompt for this note rather than a switcher.
 185     rename: Option<String>,
 186     /// A line under the input: what a rename will do, or why it cannot.
 187     hint: Option<(String, bool)>,
 188 }
 189 
 190 struct NotesApp {
 191     keys: Keys,
 192     index: Option<Index>,
 193     vault_error: Option<String>,
 194     _watcher: Option<VaultWatcher>,
 195 
 196     tree: tree::Tree,
 197     rows: Vec<tree::Row>,
 198     show_tree: bool,
 199     tree_scroll: f32,
 200     tree_motion: ScrollMotion,
 201     tree_hover: Option<usize>,
 202 
 203     /// The open note's vault path.
 204     current: Option<String>,
 205     /// The note's text as last read from or written to disk.
 206     saved: String,
 207     blocks: Vec<Block>,
 208     theme: Theme,
 209     layout: Option<(f32, Layout)>,
 210     /// Shapes for layout widths; loads the same fonts as the renderer
 211     /// (system fonts included, see `load_system_fonts`). Made on first use.
 212     measure: Option<ShapingMeasure>,
 213     mode: Mode,
 214     read_scroll: f32,
 215     read_motion: ScrollMotion,
 216     /// A source line to bring into view once the layout exists.
 217     pending_line: Option<usize>,
 218     hover_hit: bool,
 219 
 220     editor: DocEditor,
 221     /// Embedded images, shared with the editor (see `images.rs`).
 222     images: images::Images,
 223     sender: calloop::channel::Sender<Message>,
 224     /// A renderer has been made before: the next one is a reconnect, and
 225     /// every image id died with the old one.
 226     seen_renderer: bool,
 227     /// The editor's revision when its text last matched the disk.
 228     saved_rev: u64,
 229     /// The revision as last seen by the autosave check, and when it changed.
 230     edit_seen: u64,
 231     edit_changed_at: Instant,
 232 
 233     history: Vec<String>,
 234     hist_pos: usize,
 235 
 236     switcher: Option<Switcher>,
 237     switcher_input: Handle<Adapted<TextBox>>,
 238 
 239     left_tab: LeftTab,
 240     search_input: Handle<Adapted<TextBox>>,
 241     search_panel: Panel,
 242     /// The query the search panel shows results for.
 243     search_seen: String,
 244 
 245     show_side: bool,
 246     side_tab: SideTab,
 247     side_panel: Panel,
 248     /// What the side panel was built for; a change resets its scroll.
 249     side_key: Option<(String, SideTab)>,
 250     side_dirty: bool,
 251 
 252     completion: Option<Completion>,
 253     /// A `[[` (line, start) whose completion was dismissed with Escape: it
 254     /// stays shut until the caret leaves that link.
 255     completion_dismissed: Option<(usize, usize)>,
 256 
 257     conflict: bool,
 258     status: Option<(String, bool)>,
 259 
 260     width: u32,
 261     height: u32,
 262     scale: f64,
 263     needs_rebuild: bool,
 264     ui_context: cce_ui::context::UiContext,
 265     pointer: (f32, f32),
 266 }
 267 
 268 /// Where everything sits for a window size.
 269 struct Metrics {
 270     /// The whole left pane: tabs, then the tree or the search.
 271     tree: Rect,
 272     left_tabs: Rect,
 273     /// The search box, when the search tab shows.
 274     search_box: Rect,
 275     /// Below the tabs (and search box): the tree's rows or the results.
 276     tree_body: Rect,
 277     note: Rect,
 278     side: Rect,
 279     side_tabs: Rect,
 280     side_body: Rect,
 281     /// The note's name in the band; a click renames it.
 282     title: Rect,
 283     mode_chip: Rect,
 284 }
 285 
 286 impl NotesApp {
 287     fn metrics(&self) -> Metrics {
 288         let (w, h) = (self.width as f32, self.height as f32);
 289         let body_h = (h - BAND_H - STATUS_H).max(0.0);
 290         let tree_w = if self.show_tree && self.index.is_some() { TREE_W.min(w * 0.4) } else { 0.0 };
 291         let side_w = if self.show_side && self.current.is_some() && w - tree_w - SIDE_W >= NOTE_MIN_W {
 292             SIDE_W
 293         } else {
 294             0.0
 295         };
 296         let inset = cce_ui::layout::root_plate_inset();
 297         // The tab strips, mode chip and search box at the toolkit's control height.
 298         let tab_h = cce_ui::layout::button_height();
 299         let search_h = cce_ui::layout::textbox_height();
 300         let chip_h = cce_ui::layout::button_height();
 301         let tree = Rect { x: 0.0, y: BAND_H, width: tree_w, height: body_h };
 302         let left_tabs = Rect { x: 0.0, y: BAND_H, width: tree_w, height: tab_h };
 303         let search_box = Rect { x: 8.0, y: BAND_H + tab_h + 4.0, width: (tree_w - 16.0).max(0.0), height: search_h };
 304         let body_top = if self.left_tab == LeftTab::Search { search_box.y + search_h + 4.0 } else { BAND_H + tab_h };
 305         let side = Rect { x: w - side_w, y: BAND_H, width: side_w, height: body_h };
 306         let mode_chip = Rect { x: w - inset - 84.0, y: (BAND_H - chip_h) / 2.0, width: 84.0, height: chip_h };
 307         Metrics {
 308             tree,
 309             left_tabs,
 310             search_box,
 311             tree_body: Rect { x: 0.0, y: body_top, width: tree_w, height: (BAND_H + body_h - body_top).max(0.0) },
 312             note: Rect { x: tree_w, y: BAND_H, width: w - tree_w - side_w, height: body_h },
 313             side,
 314             side_tabs: Rect { x: side.x, y: BAND_H, width: side_w, height: tab_h },
 315             side_body: Rect { x: side.x, y: BAND_H + tab_h, width: side_w, height: (body_h - tab_h).max(0.0) },
 316             title: Rect { x: inset, y: 0.0, width: (mode_chip.x - 12.0 - inset).max(0.0), height: BAND_H },
 317             mode_chip,
 318         }
 319     }
 320 
 321     /// The reading column's origin and width inside the note pane.
 322     fn reading_frame(&self, m: &Metrics) -> (f32, f32, f32) {
 323         let width = (m.note.width - 2.0 * READ_PAD).min(READ_MAX_W).max(80.0);
 324         let x = m.note.x + (m.note.width - width) / 2.0;
 325         (x, m.note.y + READ_PAD, width)
 326     }
 327 
 328     /// The editor fills the note pane; it centres its own column.
 329     fn editor_rect(&self, m: &Metrics) -> Rect {
 330         m.note
 331     }
 332 
 333     /// The editor takes keys: editing, with nothing else holding them.
 334     fn editor_focused(&self) -> bool {
 335         self.mode == Mode::Source && self.switcher.is_none() && !self.ui_context[self.search_input].editing
 336     }
 337 
 338     fn rebuild_rows(&mut self) {
 339         let Some(ix) = &self.index else { return };
 340         let notes = ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, _)| p.as_str());
 341         self.rows = self.tree.rows(notes);
 342         self.tree_hover = None;
 343     }
 344 
 345     fn editor_text(&self) -> String {
 346         self.editor.text()
 347     }
 348 
 349     fn dirty(&self) -> bool {
 350         self.mode == Mode::Source && self.current.is_some() && self.editor.buf.revision != self.saved_rev
 351     }
 352 
 353     fn set_status(&mut self, msg: impl Into<String>, error: bool) {
 354         self.status = Some((msg.into(), error));
 355     }
 356 
 357     fn invalidate(&mut self) {
 358         self.layout = None;
 359         self.needs_rebuild = true;
 360     }
 361 
 362     // ---- notes -------------------------------------------------------
 363 
 364     /// Show `text` as the current note's content, as on disk.
 365     fn load_text(&mut self, text: String) {
 366         self.images.note_changed();
 367         self.blocks = cce_vault::markdown::blocks(&text);
 368         // Clears the undo history too: undo must not step back into another
 369         // note's (or the pre-reload) text.
 370         self.editor.set_text(&text);
 371         self.saved_rev = self.editor.buf.revision;
 372         self.completion = None;
 373         self.edit_seen = self.saved_rev;
 374         self.saved = text;
 375         self.conflict = false;
 376         self.side_dirty = true;
 377         self.invalidate();
 378     }
 379 
 380     /// Write the source buffer if it differs from disk.
 381     fn save(&mut self) -> bool {
 382         self.save_now(false)
 383     }
 384 
 385     /// [`save`](Self::save), or with `force` write even an unchanged buffer.
 386     fn save_now(&mut self, force: bool) -> bool {
 387         if !self.dirty() && !(force && self.mode == Mode::Source) {
 388             return true;
 389         }
 390         let text = self.editor_text();
 391         let rev = self.editor.buf.revision;
 392         if text == self.saved && !force {
 393             // Edited back to what is on disk: nothing to write.
 394             self.saved_rev = rev;
 395             return true;
 396         }
 397         let (Some(cur), Some(ix)) = (self.current.clone(), self.index.as_mut()) else { return false };
 398         match ix.write_text(&cur, &text) {
 399             Ok(()) => {
 400                 self.saved = text;
 401                 self.saved_rev = rev;
 402                 self.conflict = false;
 403                 true
 404             }
 405             Err(e) => {
 406                 self.set_status(format!("Could not save {cur}: {e}"), true);
 407                 false
 408             }
 409         }
 410     }
 411 
 412     /// Open a vault path, recording it in the history unless walking it.
 413     fn open_path(&mut self, path: &str, line: Option<usize>, record: bool) {
 414         let Some(ix) = &self.index else { return };
 415         let kind = ix.entry(path).map(|e| e.kind);
 416         match kind {
 417             Some(FileKind::Note) | None => {}
 418             Some(_) => {
 419                 // Canvases and attachments open in their own apps.
 420                 let abs = ix.abs(path);
 421                 let _ = std::process::Command::new("xdg-open").arg(abs).spawn();
 422                 return;
 423             }
 424         }
 425         if self.current.as_deref() != Some(path) {
 426             if self.conflict {
 427                 self.set_status("Resolve the conflict first: Ctrl+S keeps yours, Ctrl+R loads the disk copy", true);
 428                 return;
 429             }
 430             if !self.save() {
 431                 return;
 432             }
 433         }
 434         let Some(ix) = &self.index else { return };
 435         let text = match ix.read_text(path) {
 436             Ok(t) => t,
 437             Err(e) => {
 438                 self.set_status(format!("Could not read {path}: {e}"), true);
 439                 return;
 440             }
 441         };
 442         let same = self.current.as_deref() == Some(path);
 443         self.current = Some(path.to_string());
 444         instance::set_current(Some(path));
 445         self.load_text(text);
 446         if !same {
 447             self.read_scroll = 0.0;
 448             self.read_motion = ScrollMotion::new();
 449         }
 450         if let Some(l) = line {
 451             self.goto_line(l);
 452         }
 453         self.tree.reveal(path);
 454         self.rebuild_rows();
 455         if record {
 456             self.history.truncate(self.hist_pos + 1);
 457             if self.history.last().map(String::as_str) != Some(path) {
 458                 self.history.push(path.to_string());
 459             }
 460             self.hist_pos = self.history.len() - 1;
 461         }
 462         self.status = None;
 463     }
 464 
 465     /// Open what a command or argv names: an absolute file in the vault,
 466     /// a vault path, or a note name; with an optional `#heading`.
 467     fn open_target(&mut self, target: &str, line: Option<usize>) {
 468         let Some(ix) = &self.index else { return };
 469         let (name, sub) = match target.split_once('#') {
 470             Some((n, s)) => (n, Some(s)),
 471             None => (target, None),
 472         };
 473         let path = if Path::new(name).is_absolute() {
 474             let p = Path::new(name);
 475             p.canonicalize().ok().and_then(|c| ix.rel(&c)).or_else(|| ix.rel(p))
 476         } else {
 477             ix.lookup(name)
 478         };
 479         match path {
 480             Some(p) => {
 481                 let line = line.map(|l| l.saturating_sub(1)).or_else(|| sub.and_then(|s| self.subpath_line(&p, s)));
 482                 self.open_path(&p, line, true);
 483             }
 484             None => self.set_status(format!("No note named {name}"), true),
 485         }
 486     }
 487 
 488     /// The source line of `#Heading` or `#^block` in a note.
 489     fn subpath_line(&self, path: &str, sub: &str) -> Option<usize> {
 490         let note = self.index.as_ref()?.note(path)?;
 491         if let Some(id) = sub.strip_prefix('^') {
 492             return note.blocks.iter().find(|b| b.id == id).map(|b| b.line);
 493         }
 494         let want = sub.trim().to_lowercase();
 495         note.headings.iter().find(|h| h.text.trim().to_lowercase() == want).map(|h| h.line)
 496     }
 497 
 498     fn follow(&mut self, link: SpanLink) {
 499         match link {
 500             SpanLink::Note { target, subpath } | SpanLink::Embed { target, subpath } => {
 501                 let Some(ix) = &self.index else { return };
 502                 if target.is_empty() {
 503                     // `[[#Heading]]`: within this note.
 504                     if let (Some(cur), Some(s)) = (self.current.clone(), subpath) {
 505                         self.pending_line = self.subpath_line(&cur, &s);
 506                     }
 507                     return;
 508                 }
 509                 match ix.resolve_text(self.current.as_deref(), &target) {
 510                     Some(p) => {
 511                         let line = subpath.and_then(|s| self.subpath_line(&p, &s));
 512                         self.open_path(&p, line, true);
 513                     }
 514                     None => self.create_and_open(&target),
 515                 }
 516             }
 517             SpanLink::Url(url) => {
 518                 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
 519             }
 520             SpanLink::Tag(tag) => self.open_search(&format!("#{tag}")),
 521         }
 522     }
 523 
 524     /// Create a note for a link or switcher query with no target, and open
 525     /// it in source mode, as Obsidian does on a click on an unresolved link.
 526     fn create_and_open(&mut self, name: &str) {
 527         let Some(ix) = self.index.as_mut() else { return };
 528         let name = name.trim().trim_start_matches('/');
 529         let path = if name.to_lowercase().ends_with(".md") { name.to_string() } else { format!("{name}.md") };
 530         match ix.create(&path, "") {
 531             Ok(rel) => {
 532                 self.open_path(&rel, None, true);
 533                 self.set_mode(Mode::Source);
 534             }
 535             Err(e) => self.set_status(format!("Could not create {path}: {e}"), true),
 536         }
 537     }
 538 
 539     fn toggle_task(&mut self, line: usize, status: char) {
 540         let (Some(cur), Some(ix)) = (self.current.clone(), self.index.as_mut()) else { return };
 541         let next = if status == ' ' { 'x' } else { ' ' };
 542         match ix.set_task(&cur, line, next).and_then(|_| ix.read_text(&cur).map_err(Into::into)) {
 543             Ok(text) => {
 544                 let scroll = self.read_scroll;
 545                 self.load_text(text);
 546                 self.read_scroll = scroll;
 547             }
 548             Err(e) => self.set_status(format!("Could not update the task: {e}"), true),
 549         }
 550     }
 551 
 552     fn set_mode(&mut self, mode: Mode) {
 553         if mode == self.mode || self.current.is_none() {
 554             return;
 555         }
 556         if mode == Mode::Reading {
 557             if !self.save() {
 558                 return;
 559             }
 560             let text = self.editor_text();
 561             self.blocks = cce_vault::markdown::blocks(&text);
 562             self.completion = None;
 563             self.invalidate();
 564         } else {
 565             self.ui_context.unfocus_id(self.search_input.id());
 566         }
 567         self.mode = mode;
 568         self.needs_rebuild = true;
 569     }
 570 
 571     fn go_history(&mut self, delta: isize) {
 572         let next = self.hist_pos as isize + delta;
 573         if next < 0 || next as usize >= self.history.len() {
 574             return;
 575         }
 576         let path = self.history[next as usize].clone();
 577         if self.index.as_ref().is_some_and(|ix| ix.entry(&path).is_some()) {
 578             self.hist_pos = next as usize;
 579             self.open_path(&path, None, false);
 580         } else {
 581             // Deleted since: drop it and keep walking.
 582             self.history.remove(next as usize);
 583             if (next as usize) < self.hist_pos {
 584                 self.hist_pos -= 1;
 585             }
 586             self.go_history(delta);
 587         }
 588     }
 589 
 590     fn open_daily(&mut self, date: Option<chrono::NaiveDate>) {
 591         let Some(ix) = self.index.as_mut() else { return };
 592         let date = date.unwrap_or_else(|| chrono::Local::now().date_naive());
 593         match ix.daily(date, true) {
 594             Ok((path, _created)) => self.open_path(&path, None, true),
 595             Err(e) => self.set_status(format!("Could not open the daily note: {e}"), true),
 596         }
 597     }
 598 
 599     fn run_command(&mut self, cmd: Command) {
 600         match cmd {
 601             Command::Open { target, line } => self.open_target(&target, line),
 602             Command::Daily(d) => self.open_daily(d),
 603             Command::Search(q) => self.open_search(&q),
 604             Command::Show => {}
 605         }
 606         self.needs_rebuild = true;
 607     }
 608 
 609     // ---- vault changes -------------------------------------------------
 610 
 611     fn vault_changed(&mut self, paths: Vec<PathBuf>) {
 612         let Some(ix) = self.index.as_mut() else { return };
 613         // A path the index has not seen is a file (or folder) that just
 614         // appeared; with a removal, the vault's set of files changed — the
 615         // only change that can move what a link resolves to.
 616         let appeared = paths.iter().any(|p| ix.rel(p).is_some_and(|r| ix.entry(&r).is_none()));
 617         let changes = ix.apply_changes(&paths);
 618         // An image added, changed or removed: links resolve again, and the
 619         // editor asks again (it only asks while laying a line out).
 620         let images_touched = paths.iter().any(|p| cce_vault::markdown::is_image(&p.to_string_lossy()));
 621         let relink = appeared || !changes.removed.is_empty() || images_touched;
 622         self.images.vault_changed(ix, &paths, relink);
 623         if images_touched {
 624             self.editor.invalidate();
 625         }
 626         self.rebuild_rows();
 627         // Link colours, backlinks and search hits depend on every file.
 628         self.layout = None;
 629         self.side_dirty = true;
 630         if self.left_tab == LeftTab::Search {
 631             self.refresh_search(true);
 632         }
 633         let Some(cur) = self.current.clone() else { return };
 634         let Some(ix) = self.index.as_ref() else { return };
 635         let abs = ix.abs(&cur);
 636         let canon = abs.canonicalize().ok();
 637         let touches = |p: &PathBuf| p == &abs || (canon.is_some() && p.canonicalize().ok() == canon);
 638         if !paths.iter().any(touches) {
 639             return;
 640         }
 641         match ix.read_text(&cur) {
 642             Ok(disk) if disk == self.saved => {}
 643             Ok(disk) => {
 644                 if self.dirty() {
 645                     self.conflict = true;
 646                 } else {
 647                     let scroll = self.read_scroll;
 648                     self.load_text(disk);
 649                     self.read_scroll = scroll;
 650                 }
 651             }
 652             Err(_) => self.set_status(format!("{cur} was deleted or moved on disk"), true),
 653         }
 654     }
 655 
 656     // ---- quick switcher -------------------------------------------------
 657 
 658     fn open_switcher(&mut self, query: &str) {
 659         self.ui_context[self.switcher_input].text = query.to_string();
 660         self.ui_context[self.switcher_input].edit_buffer = query.to_string();
 661         self.ui_context[self.switcher_input].cursor_idx = query.chars().count();
 662         self.ui_context[self.switcher_input].select_anchor = None;
 663         self.switcher =
 664             Some(Switcher { choices: Vec::new(), selected: 0, query: "\u{0}".into(), rename: None, hint: None });
 665         self.ui_context.set_focused_id(self.switcher_input.id());
 666         WidgetHost::focus(&mut self.ui_context[self.switcher_input]);
 667         self.refresh_switcher();
 668     }
 669 
 670     fn close_switcher(&mut self) {
 671         self.switcher = None;
 672         self.ui_context.unfocus_id(self.switcher_input.id());
 673         self.needs_rebuild = true;
 674     }
 675 
 676     fn switcher_query(&self) -> String {
 677         if self.ui_context[self.switcher_input].editing {
 678             self.ui_context[self.switcher_input].edit_buffer.clone()
 679         } else {
 680             self.ui_context[self.switcher_input].text.clone()
 681         }
 682     }
 683 
 684     fn refresh_switcher(&mut self) {
 685         let query = self.switcher_query();
 686         let Some(ix) = &self.index else { return };
 687         let Some(sw) = self.switcher.as_mut() else { return };
 688         if sw.query == query {
 689             return;
 690         }
 691         if let Some(from) = sw.rename.clone() {
 692             sw.choices = Vec::new();
 693             sw.hint = Some(match rename_target(&from, &query) {
 694                 None => ("Type the note's new name or path".into(), false),
 695                 Some(to) if to == from => ("Unchanged".into(), false),
 696                 Some(to) => match ix.plan_rename(&from, &to) {
 697                     Ok(plan) => {
 698                         let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
 699                         let what = if plan.edits.is_empty() {
 700                             "no links to update".to_string()
 701                         } else {
 702                             format!("updates {} in {}", side::plural(plan.edits.len(), "link"), side::plural(notes.len(), "note"))
 703                         };
 704                         (format!("Enter renames to {to} — {what}"), false)
 705                     }
 706                     Err(e) => (e.to_string(), true),
 707                 },
 708             });
 709             sw.query = query;
 710             self.needs_rebuild = true;
 711             return;
 712         }
 713         sw.choices = if query.trim().is_empty() {
 714             // Recent first: the history, then the newest files.
 715             let mut seen = std::collections::HashSet::new();
 716             let mut out = Vec::new();
 717             for p in self.history.iter().rev() {
 718                 if ix.entry(p).is_some() && seen.insert(p.clone()) {
 719                     out.push(p.clone());
 720                 }
 721             }
 722             let mut notes: Vec<(&String, u64)> =
 723                 ix.files().iter().filter(|(_, e)| e.kind == FileKind::Note).map(|(p, e)| (p, e.mtime)).collect();
 724             notes.sort_by(|a, b| b.1.cmp(&a.1));
 725             for (p, _) in notes {
 726                 if seen.insert(p.clone()) {
 727                     out.push(p.clone());
 728                 }
 729             }
 730             out.into_iter()
 731                 .take(SWITCHER_ROWS)
 732                 .map(|p| Choice::Note { path: p, alias: None })
 733                 .collect()
 734         } else {
 735             let mut c: Vec<Choice> = ix
 736                 .find(&query, SWITCHER_ROWS)
 737                 .into_iter()
 738                 .filter(|m| ix.entry(&m.path).is_some_and(|e| e.kind == FileKind::Note))
 739                 .map(|m| {
 740                     let alias = (!m.path.trim_end_matches(".md").ends_with(m.matched.as_str())).then_some(m.matched);
 741                     Choice::Note { path: m.path, alias }
 742                 })
 743                 .collect();
 744             if ix.lookup(&query).is_none() {
 745                 c.push(Choice::Create(query.trim().to_string()));
 746             }
 747             c
 748         };
 749         sw.selected = 0;
 750         sw.query = query;
 751         self.needs_rebuild = true;
 752     }
 753 
 754     fn switcher_pick(&mut self, create: bool) {
 755         let Some(sw) = &self.switcher else { return };
 756         let query = self.switcher_query();
 757         if let Some(from) = sw.rename.clone() {
 758             match rename_target(&from, &query) {
 759                 Some(to) if to != from => {
 760                     if sw.hint.as_ref().is_some_and(|h| h.1) {
 761                         return; // the plan already said why it cannot
 762                     }
 763                     self.close_switcher();
 764                     self.rename(&from, &to);
 765                 }
 766                 Some(_) => self.close_switcher(),
 767                 None => {}
 768             }
 769             return;
 770         }
 771         let choice = if create { Some(Choice::Create(query.trim().to_string())) } else { sw.choices.get(sw.selected).cloned() };
 772         self.close_switcher();
 773         match choice {
 774             Some(Choice::Note { path, .. }) => self.open_path(&path, None, true),
 775             Some(Choice::Create(name)) if !name.is_empty() => self.create_and_open(&name),
 776             _ => {}
 777         }
 778     }
 779 
 780     fn switcher_rect(&self) -> Rect {
 781         let w = SWITCHER_W.min(self.width as f32 - 32.0);
 782         let n = self.switcher.as_ref().map_or(0, |s| s.choices.len() + usize::from(s.hint.is_some()));
 783         Rect {
 784             x: (self.width as f32 - w) / 2.0,
 785             y: BAND_H + 24.0,
 786             width: w,
 787             height: switcher_rows_top() + n as f32 * SWITCHER_ROW_H + 6.0,
 788         }
 789     }
 790 
 791     fn switcher_row_at(&self, x: f32, y: f32) -> Option<usize> {
 792         let r = self.switcher_rect();
 793         let top = r.y + switcher_rows_top();
 794         if x < r.x || x > r.x + r.width || y < top {
 795             return None;
 796         }
 797         let i = ((y - top) / SWITCHER_ROW_H) as usize;
 798         (i < self.switcher.as_ref()?.choices.len()).then_some(i)
 799     }
 800 
 801     // ---- rename ----------------------------------------------------------
 802 
 803     fn open_rename(&mut self) {
 804         let Some(cur) = self.current.clone() else { return };
 805         let shown = cur.strip_suffix(".md").unwrap_or(&cur).to_string();
 806         self.open_switcher(&shown);
 807         if let Some(sw) = self.switcher.as_mut() {
 808             sw.rename = Some(cur);
 809             sw.query = "\u{0}".into();
 810         }
 811         self.refresh_switcher();
 812     }
 813 
 814     /// Rename (or move) a note and rewrite every link to it, as Obsidian
 815     /// does with "automatically update internal links" on.
 816     fn rename(&mut self, from: &str, to: &str) {
 817         if self.current.as_deref() == Some(from) && !self.save() {
 818             return;
 819         }
 820         let Some(ix) = self.index.as_mut() else { return };
 821         match ix.rename(from, to) {
 822             Ok(plan) => {
 823                 for h in &mut self.history {
 824                     if h == from {
 825                         *h = plan.to.clone();
 826                     }
 827                 }
 828                 let notes: std::collections::BTreeSet<&str> = plan.edits.iter().map(|e| e.path.as_str()).collect();
 829                 let msg = format!(
 830                     "Renamed to {} · updated {} in {}",
 831                     plan.to,
 832                     side::plural(plan.edits.len(), "link"),
 833                     side::plural(notes.len(), "note")
 834                 );
 835                 if self.current.as_deref() == Some(from) {
 836                     // Its own self-links may have been rewritten too.
 837                     let to = plan.to.clone();
 838                     self.current = Some(to.clone());
 839                     instance::set_current(Some(&to));
 840                     if let Ok(text) = self.index.as_ref().map(|ix| ix.read_text(&to)).transpose().map(Option::unwrap_or_default) {
 841                         let scroll = self.read_scroll;
 842                         self.load_text(text);
 843                         self.read_scroll = scroll;
 844                     }
 845                     self.tree.reveal(&to);
 846                 }
 847                 self.rebuild_rows();
 848                 self.side_dirty = true;
 849                 self.layout = None;
 850                 self.set_status(msg, false);
 851             }
 852             Err(e) => self.set_status(format!("Could not rename: {e}"), true),
 853         }
 854     }
 855 
 856     // ---- side panes ------------------------------------------------------
 857 
 858     fn refresh_side(&mut self) {
 859         self.side_dirty = false;
 860         let (Some(ix), Some(cur)) = (&self.index, self.current.clone()) else {
 861             self.side_panel.set(Vec::new(), true);
 862             self.side_key = None;
 863             return;
 864         };
 865         let items = match self.side_tab {
 866             SideTab::Backlinks => side::backlinks(ix, &cur),
 867             SideTab::Outline => side::outline(ix, &cur),
 868         };
 869         let key = (cur, self.side_tab);
 870         let reset = self.side_key.as_ref() != Some(&key);
 871         self.side_panel.set(items, reset);
 872         self.side_key = Some(key);
 873     }
 874 
 875     fn search_query(&self) -> String {
 876         if self.ui_context[self.search_input].editing {
 877             self.ui_context[self.search_input].edit_buffer.clone()
 878         } else {
 879             self.ui_context[self.search_input].text.clone()
 880         }
 881     }
 882 
 883     /// Rerun the search if the query changed, or always with `force`.
 884     fn refresh_search(&mut self, force: bool) {
 885         let q = self.search_query();
 886         if !force && q == self.search_seen {
 887             return;
 888         }
 889         let Some(ix) = &self.index else { return };
 890         let reset = q != self.search_seen;
 891         self.search_panel.set(side::search(ix, &q), reset);
 892         self.search_seen = q;
 893         self.needs_rebuild = true;
 894     }
 895 
 896     /// Show the search tab holding `query` (a tag click, Ctrl+Shift+F).
 897     fn open_search(&mut self, query: &str) {
 898         self.left_tab = LeftTab::Search;
 899         self.show_tree = true;
 900         self.ui_context[self.search_input].text = query.to_string();
 901         self.ui_context[self.search_input].edit_buffer = query.to_string();
 902         self.ui_context[self.search_input].cursor_idx = query.chars().count();
 903         self.ui_context[self.search_input].select_anchor = None;
 904         self.ui_context[self.search_input].sync_editor_state();
 905         self.ui_context.set_focused_id(self.search_input.id());
 906         WidgetHost::focus(&mut self.ui_context[self.search_input]);
 907         self.refresh_search(true);
 908         self.invalidate();
 909     }
 910 
 911     fn run_action(&mut self, action: Action) {
 912         match action {
 913             Action::Open { path, line } => self.open_path(&path, line, true),
 914             Action::Line(line) => self.goto_line(line),
 915         }
 916     }
 917 
 918     /// Bring a 0-based source line of the open note into view: scroll the
 919     /// reading view to its block, or put the editor's caret at its start.
 920     fn goto_line(&mut self, line: usize) {
 921         match self.mode {
 922             Mode::Reading => self.pending_line = Some(line),
 923             Mode::Source => {
 924                 self.ui_context.unfocus_id(self.search_input.id());
 925                 self.editor.reveal_line(line);
 926             }
 927         }
 928         self.needs_rebuild = true;
 929     }
 930 
 931     // ---- [[ completion -----------------------------------------------------
 932 
 933     /// The caret's line and its char index in it.
 934     fn caret_chars(&self) -> (usize, usize) {
 935         let p = self.editor.buf.caret;
 936         let line = self.editor.buf.line(p.line);
 937         (p.line, line[..p.col.min(line.len())].chars().count())
 938     }
 939 
 940     /// Ctrl+V with a picture (or copied image files) on the clipboard: save
 941     /// them in the vault's attachment folder for this note, as Obsidian
 942     /// does, and embed them at the caret, each on its own line. False when
 943     /// the clipboard holds nothing of the kind (the text paste runs).
 944     fn paste_images(&mut self) -> bool {
 945         if self.index.is_none() || self.current.is_none() {
 946             return false;
 947         }
 948         let Some(clip) = paste::read() else {
 949             log::debug!("paste: nothing to attach, pasting text");
 950             return false;
 951         };
 952         let (a, b) = self.editor.buf.selection().unwrap_or((self.editor.buf.caret, self.editor.buf.caret));
 953         self.attach(clip, a, b, "Pasted");
 954         true
 955     }
 956 
 957     /// Store `clip` in the attachment folder for the open note and put an
 958     /// embed for each file in place of `a..b`, each on its own line.
 959     /// `verb` names the action in the status line.
 960     fn attach(&mut self, clip: paste::Clip, a: Pos, b: Pos, verb: &str) {
 961         let (Some(ix), Some(cur)) = (self.index.as_ref(), self.current.clone()) else { return };
 962         let root = ix.root().to_path_buf();
 963         let folder = cce_vault::attachments::folder(&root, &cur);
 964         let written = match paste::store(clip, &root.join(&folder), chrono::Local::now().naive_local()) {
 965             Ok(w) => w,
 966             Err(e) => {
 967                 self.set_status(format!("Could not save the image: {e}"), true);
 968                 return;
 969             }
 970         };
 971         // The bare name, as Obsidian writes it — unless another file of that
 972         // name would win the link, then the vault path. (The index learns of
 973         // the new file from the watcher, after this.)
 974         let embeds: Vec<String> = written
 975             .iter()
 976             .filter_map(|p| {
 977                 let rel = ix.rel(p)?;
 978                 let name = p.file_name()?.to_string_lossy().into_owned();
 979                 let link = if ix.resolve_text(Some(&cur), &name).is_some_and(|other| other != rel) { rel } else { name };
 980                 Some(format!("![[{link}]]"))
 981             })
 982             .collect();
 983         if embeds.is_empty() {
 984             return;
 985         }
 986         let before = self.editor.buf.line(a.line)[..a.col].to_string();
 987         let after = self.editor.buf.line(b.line)[b.col..].to_string();
 988         let (text, at) = paste::insertion(&embeds, &before, &after);
 989         let head = &text[..at];
 990         let caret = match head.rfind('\n') {
 991             Some(nl) => Pos::new(a.line + head.matches('\n').count(), head.len() - nl - 1),
 992             None => Pos::new(a.line, a.col + head.len()),
 993         };
 994         self.editor.edit(a, b, &text, caret);
 995         let n = written.len();
 996         let place = if folder.is_empty() { "the vault".to_string() } else { folder };
 997         self.set_status(format!("{verb} {n} image{} into {place}", if n == 1 { "" } else { "s" }), false);
 998     }
 999 
1000     /// A drop on the window: images (pixels from a browser, or image files
1001     /// from a file manager) become attachments embedded after the line they
1002     /// were dropped on. In reading view the note switches to editing and they go at
1003     /// its end.
1004     fn dropped(&mut self, mime: &str, data: &[u8], x: f32, y: f32) {
1005         if self.index.is_none() || self.current.is_none() {
1006             self.set_status("Open a note to drop images into it", true);
1007             return;
1008         }
1009         let Some(clip) = paste::from_drop(mime, data) else {
1010             self.set_status("Only images can be dropped into a note", true);
1011             return;
1012         };
1013         let at = if self.mode == Mode::Source && self.editor_rect(&self.metrics()).contains(x, y) {
1014             // After the line under the pointer: dropping onto a word must
1015             // not split it around the picture.
1016             let line = self.editor.pos_at(x, y).line;
1017             Pos::new(line, self.editor.buf.line(line).len())
1018         } else {
1019             self.set_mode(Mode::Source);
1020             let last = self.editor.buf.line_count() - 1;
1021             Pos::new(last, self.editor.buf.line(last).len())
1022         };
1023         self.attach(clip, at, at, "Dropped");
1024     }
1025 
1026     /// Open, update or close the completion for the caret's position.
1027     fn update_completion(&mut self) {
1028         if !self.editor_focused() || self.editor.buf.selection().is_some() {
1029             self.completion = None;
1030             return;
1031         }
1032         let (line, col) = self.caret_chars();
1033         let found = complete::open_link(self.editor.buf.line(line), col);
1034         let Some((start, query)) = found else {
1035             self.completion = None;
1036             self.completion_dismissed = None;
1037             return;
1038         };
1039         if self.completion_dismissed == Some((line, start)) {
1040             return;
1041         }
1042         if self.completion.as_ref().is_some_and(|c| c.line == line && c.start == start && c.query == query) {
1043             return;
1044         }
1045         let Some(ix) = &self.index else { return };
1046         let choices = complete::choices(ix, &query);
1047         self.completion = Some(Completion { line, start, query, choices, selected: 0 });
1048         self.needs_rebuild = true;
1049     }
1050 
1051     /// Keys the open completion takes; true when it took this one.
1052     fn completion_key(&mut self, key: &Key) -> bool {
1053         let Some(c) = self.completion.as_mut() else { return false };
1054         if c.choices.is_empty() {
1055             return false;
1056         }
1057         let n = c.choices.len();
1058         match key {
1059             Key::Named(NamedKey::ArrowDown) => c.selected = (c.selected + 1) % n,
1060             Key::Named(NamedKey::ArrowUp) => c.selected = (c.selected + n - 1) % n,
1061             Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Tab) => self.accept_completion(),
1062             Key::Named(NamedKey::Escape) => {
1063                 self.completion_dismissed = Some((c.line, c.start));
1064                 self.completion = None;
1065             }
1066             _ => return false,
1067         }
1068         self.needs_rebuild = true;
1069         true
1070     }
1071 
1072     /// Write the chosen link into the caret's line: one undo step.
1073     fn accept_completion(&mut self) {
1074         let Some(c) = self.completion.take() else { return };
1075         let (Some(ix), Some(path)) = (&self.index, c.choices.get(c.selected)) else { return };
1076         let link = complete::link_text(ix, self.current.as_deref(), path);
1077         let (line, col) = self.caret_chars();
1078         if line != c.line {
1079             return;
1080         }
1081         let text = self.editor.buf.line(line).to_string();
1082         let (next, caret) = complete::apply(&text, c.start, col, &link);
1083         let caret_byte = next.char_indices().nth(caret).map_or(next.len(), |(b, _)| b);
1084         self.editor.edit(Pos::new(line, 0), Pos::new(line, text.len()), &next, Pos::new(line, caret_byte));
1085     }
1086 
1087     /// The completion popup's rect, under the caret and inside the note
1088     /// pane. Valid after the editor is prepared for this frame.
1089     fn completion_rect(&mut self, m: &Metrics) -> Option<Rect> {
1090         let n = self.completion.as_ref().filter(|c| !c.choices.is_empty())?.choices.len();
1091         let r = self.editor_rect(m);
1092         let caret = self.editor.caret_rect();
1093         let h = n as f32 * COMPLETE_ROW_H + 8.0;
1094         let w = COMPLETE_W.min(r.width);
1095         let x = caret.x.min(r.x + r.width - w).max(r.x);
1096         // Below the caret line, or above it when there is no room below.
1097         let below = caret.y + caret.height + 2.0;
1098         let y = if below + h <= r.y + r.height { below } else { (caret.y - h - 2.0).max(r.y) };
1099         Some(Rect { x, y, width: w, height: h })
1100     }
1101 
1102     fn completion_row_at(&mut self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
1103         let r = self.completion_rect(m)?;
1104         if !r.contains(x, y) {
1105             return None;
1106         }
1107         let i = ((y - r.y - 4.0) / COMPLETE_ROW_H).floor();
1108         (i >= 0.0 && (i as usize) < self.completion.as_ref()?.choices.len()).then_some(i as usize)
1109     }
1110 
1111     // ---- layout and scrolling -------------------------------------------
1112 
1113     fn ensure_layout(&mut self, width: f32) {
1114         if matches!(&self.layout, Some((w, _)) if (w - width).abs() < 0.5) {
1115             return;
1116         }
1117         let m = self.measure.get_or_insert_with(|| ShapingMeasure::new(true));
1118         let index = &self.index;
1119         let cur = self.current.as_deref();
1120         let resolved = |l: &SpanLink| match l {
1121             SpanLink::Note { target, .. } | SpanLink::Embed { target, .. } => {
1122                 target.is_empty() || index.as_ref().is_some_and(|ix| ix.resolve_text(cur, target).is_some())
1123             }
1124             _ => true,
1125         };
1126         let images = &self.images;
1127         let laid = reading::layout_with(&self.blocks, width, &self.theme, m, &resolved, &|t| images.lookup(t));
1128         self.layout = Some((width, laid));
1129     }
1130 
1131     fn read_max_scroll(&self, m: &Metrics) -> f32 {
1132         let h = self.layout.as_ref().map_or(0.0, |(_, l)| l.height);
1133         (h + 2.0 * READ_PAD - m.note.height).max(0.0)
1134     }
1135 
1136     fn tree_max_scroll(&self, m: &Metrics) -> f32 {
1137         (self.rows.len() as f32 * TREE_ROW_H + 8.0 - m.tree_body.height).max(0.0)
1138     }
1139 
1140     fn tree_row_at(&self, m: &Metrics, x: f32, y: f32) -> Option<usize> {
1141         if self.left_tab != LeftTab::Files || !m.tree_body.contains(x, y) {
1142             return None;
1143         }
1144         let i = ((y - m.tree_body.y - 4.0 + self.tree_scroll) / TREE_ROW_H).floor();
1145         (i >= 0.0 && (i as usize) < self.rows.len()).then_some(i as usize)
1146     }
1147 
1148     /// The reading view's click target under a window point.
1149     fn reading_hit(&self, x: f32, y: f32) -> Option<Hit> {
1150         let m = self.metrics();
1151         if self.mode != Mode::Reading || self.switcher.is_some() || !m.note.contains(x, y) {
1152             return None;
1153         }
1154         let (ox, oy, _) = self.reading_frame(&m);
1155         let (_, l) = self.layout.as_ref()?;
1156         l.hit(x - ox, y - oy + self.read_scroll).cloned()
1157     }
1158 
1159     // ---- painting --------------------------------------------------------
1160 
1161     fn paint_band(&self, pc: &mut PaintCtx, m: &Metrics) {
1162         let w = self.width as f32;
1163         pc.recess_edges(
1164             Rect { x: 0.0, y: 0.0, width: w, height: BAND_H },
1165             (0.0, 0.0, 0.0, 0.0),
1166             cce_ui::layout::bar_wall_width(),
1167             (false, false, true, false),
1168         );
1169         let (family, size) = cce_ui::layout::menubar_font_parsed();
1170         let inset = cce_ui::layout::root_plate_inset();
1171         let ty = cce_ui::layout::align_text_y(0.0, BAND_H, size, 0.0);
1172         let right = m.mode_chip.x - 12.0;
1173         match &self.current {
1174             Some(cur) => {
1175                 let (dir, file) = match cur.rsplit_once('/') {
1176                     Some((d, f)) => (format!("{d} / "), f),
1177                     None => (String::new(), cur.as_str()),
1178                 };
1179                 let name = file.strip_suffix(".md").unwrap_or(file);
1180                 let bounds = Some([inset, 0.0, right, BAND_H]);
1181                 let dir_w = dir.chars().count() as f32 * size * 0.6;
1182                 let name_w = name.chars().count() as f32 * size * 0.6;
1183                 pc.text_with(dir, inset, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
1184                 pc.text_with(name.to_string(), inset + dir_w, ty, size, srgb_u8(FG), Some(family.clone()), bounds);
1185                 if self.dirty() {
1186                     // Unsaved: a small dot after the name.
1187                     let side = (size * 0.45).round().max(4.0);
1188                     let dot = Rect { x: inset + dir_w + name_w + size * 0.5, y: (BAND_H - side) / 2.0, width: side, height: side };
1189                     if dot.x + dot.width <= right {
1190                         pc.icon("circle", dot, cce_ui::colors::to_srgb(FG));
1191                     }
1192                 }
1193             }
1194             None => {
1195                 let vault = self.index.as_ref().map(|ix| vault_name(ix.root())).unwrap_or_else(|| "Notes".into());
1196                 pc.text_with(vault, inset, ty, size, srgb_u8(FG), Some(family.clone()), None);
1197             }
1198         }
1199         if self.current.is_some() {
1200             let label = match self.mode {
1201                 Mode::Reading => "Reading",
1202                 Mode::Source if self.editor.preview => "Editing",
1203                 Mode::Source => "Source",
1204             };
1205             let c = m.mode_chip;
1206             pc.rounded_rect(c, 6.0, (true, true, true, true), [1.0, 1.0, 1.0, 0.06]);
1207             let lw = label.len() as f32 * size * 0.6;
1208             pc.text_with(
1209                 label.to_string(),
1210                 c.x + (c.width - lw) / 2.0,
1211                 cce_ui::layout::align_text_y(c.y, c.height, size, 0.0),
1212                 size,
1213                 srgb_u8(DIM),
1214                 Some(family),
1215                 Some([c.x, c.y, c.x + c.width, c.y + c.height]),
1216             );
1217         }
1218     }
1219 
1220     fn paint_status(&self, pc: &mut PaintCtx) {
1221         let (w, h) = (self.width as f32, self.height as f32);
1222         let y = h - STATUS_H;
1223         pc.recess_edges(
1224             Rect { x: 0.0, y, width: w, height: STATUS_H },
1225             (0.0, 0.0, 0.0, 0.0),
1226             cce_ui::layout::bar_wall_width(),
1227             (true, false, false, false),
1228         );
1229         let (family, size) = cce_ui::layout::statusbar_font_parsed();
1230         let inset = cce_ui::layout::root_plate_inset();
1231         let ty = cce_ui::layout::align_text_y(y, STATUS_H, size, 0.0);
1232         let (msg, color) = if self.conflict {
1233             ("Changed on disk · Ctrl+S keeps yours · Ctrl+R reloads".to_string(), CONFLICT)
1234         } else if let Some((msg, err)) = &self.status {
1235             (msg.clone(), if *err { CONFLICT } else { DIM })
1236         } else if let (Some(cur), Some(ix)) = (&self.current, &self.index) {
1237             let words = if self.mode == Mode::Source {
1238                 self.editor.buf.lines().iter().map(|l| l.split_whitespace().count()).sum()
1239             } else {
1240                 self.saved.split_whitespace().count()
1241             };
1242             // Notes linking here, as the backlinks pane counts them.
1243             let back = ix.backlinks(cur).iter().filter(|b| b.source != cur).map(|b| b.source).collect::<std::collections::HashSet<_>>().len();
1244             (format!("{} · {}", plural(words, "word"), plural(back, "backlink")), DIM)
1245         } else {
1246             (String::new(), DIM)
1247         };
1248         pc.text_with(msg, inset, ty, size, srgb_u8(color), Some(family.clone()), Some([inset, y, w * 0.75, h]));
1249         if let Some(ix) = &self.index {
1250             let name = vault_name(ix.root());
1251             let nw = name.chars().count() as f32 * size * 0.6;
1252             pc.text_with(name, w - inset - nw, ty, size, srgb_u8(DIM), Some(family), None);
1253         }
1254     }
1255 
1256     fn paint_tree(&self, pc: &mut PaintCtx, m: &Metrics) {
1257         if m.tree.width <= 0.0 {
1258             return;
1259         }
1260         pc.recess_edges(m.tree, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, true, false, false));
1261         let active = if self.left_tab == LeftTab::Files { 0 } else { 1 };
1262         paint_tabs(pc, m.left_tabs, &["Files", "Search"], active);
1263         if self.left_tab == LeftTab::Search {
1264             cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.search_input], pc);
1265             if self.search_panel.items.is_empty() {
1266                 let (family, size) = cce_ui::layout::tree_font_parsed();
1267                 let b = m.tree_body;
1268                 pc.text_with(
1269                     "Words, or #tag",
1270                     b.x + 12.0,
1271                     b.y + 10.0,
1272                     (size * 0.9).round(),
1273                     srgb_u8(DIM),
1274                     Some(family),
1275                     Some([b.x, b.y, b.x + b.width, b.y + b.height]),
1276                 );
1277             }
1278             self.search_panel.paint(pc, m.tree_body);
1279             return;
1280         }
1281         let t = m.tree_body;
1282         let (family, size) = cce_ui::layout::tree_font_parsed();
1283         pc.clip(t, |pc| {
1284             for (i, row) in self.rows.iter().enumerate() {
1285                 let y = t.y + 4.0 + i as f32 * TREE_ROW_H - self.tree_scroll;
1286                 if y + TREE_ROW_H < t.y || y > t.y + t.height {
1287                     continue;
1288                 }
1289                 let r = Rect { x: t.x + 4.0, y, width: t.width - 10.0, height: TREE_ROW_H };
1290                 if !row.folder && self.current.as_deref() == Some(row.path.as_str()) {
1291                     pc.rounded_rect(r, 4.0, (true, true, true, true), ROW_CURRENT);
1292                 } else if self.tree_hover == Some(i) {
1293                     pc.rounded_rect(r, 4.0, (true, true, true, true), ROW_HOVER);
1294                 }
1295                 let x = r.x + 8.0 + row.depth as f32 * 14.0;
1296                 let ty = cce_ui::layout::align_text_y(r.y, r.height, size, 0.0);
1297                 // A folder's disclosure chevron sits in the gutter every
1298                 // row's name is indented past, so files line up with it.
1299                 let gutter = (size * 1.2).round();
1300                 let color = if row.folder {
1301                     let side = (size * 0.9).round();
1302                     let chevron = Rect { x, y: r.y + (r.height - side) / 2.0, width: side, height: side };
1303                     pc.icon(if row.open { "chevron-down" } else { "chevron-right" }, chevron, cce_ui::colors::to_srgb(DIM));
1304                     DIM
1305                 } else {
1306                     FG
1307                 };
1308                 pc.text_with(
1309                     row.name.clone(),
1310                     x + gutter,
1311                     ty,
1312                     size,
1313                     srgb_u8(color),
1314                     Some(family.clone()),
1315                     Some([r.x, r.y.max(t.y), r.x + r.width, (r.y + r.height).min(t.y + t.height)]),
1316                 );
1317             }
1318         });
1319     }
1320 
1321     fn paint_note(&mut self, pc: &mut PaintCtx, m: &Metrics) {
1322         if self.switcher.is_some() {
1323             // The switcher covers the note; text cannot be hidden under a
1324             // plate, so the note is not painted at all while it is open.
1325             return;
1326         }
1327         if self.index.is_none() {
1328             let msg = self.vault_error.clone().unwrap_or_default();
1329             let (family, size) = cce_ui::layout::list_font_parsed();
1330             let r = m.note;
1331             for (i, line) in msg.lines().enumerate() {
1332                 pc.text_with(
1333                     line.to_string(),
1334                     r.x + READ_PAD,
1335                     r.y + READ_PAD + i as f32 * size * 1.6,
1336                     size,
1337                     srgb_u8(if i == 0 { FG } else { DIM }),
1338                     Some(family.clone()),
1339                     None,
1340                 );
1341             }
1342             return;
1343         }
1344         if self.current.is_none() {
1345             let (family, size) = cce_ui::layout::list_font_parsed();
1346             let hint = "Ctrl+O to open a note · Alt+D for today's note";
1347             let hw = hint.chars().count() as f32 * size * 0.6;
1348             pc.text_with(
1349                 hint,
1350                 m.note.x + (m.note.width - hw) / 2.0,
1351                 m.note.y + m.note.height / 2.0,
1352                 size,
1353                 srgb_u8(DIM),
1354                 Some(family),
1355                 None,
1356             );
1357             return;
1358         }
1359         match self.mode {
1360             Mode::Source => {
1361                 let e = self.editor_rect(m);
1362                 let focused = self.editor_focused();
1363                 self.editor.prepare(e);
1364                 let hole = self.completion_rect(m);
1365                 let (index, cur) = (&self.index, self.current.as_deref());
1366                 let resolved = |t: &doc_editor::Target| match t {
1367                     doc_editor::Target::Note { target, .. } => {
1368                         target.is_empty() || index.as_ref().is_some_and(|ix| ix.resolve_text(cur, target).is_some())
1369                     }
1370                     doc_editor::Target::Url(_) => true,
1371                 };
1372                 match hole {
1373                     // Text draws over every plate, so the popup cannot simply
1374                     // cover the editor: paint the editor four times, clipped
1375                     // to the bands around the popup, and the popup into the
1376                     // hole.
1377                     Some(hole) => {
1378                         let (l, t, r, b) = (e.x, e.y, e.x + e.width, e.y + e.height);
1379                         let bands = [
1380                             Rect { x: l, y: t, width: r - l, height: hole.y - t },
1381                             Rect { x: l, y: hole.y + hole.height, width: r - l, height: b - hole.y - hole.height },
1382                             Rect { x: l, y: hole.y, width: hole.x - l, height: hole.height },
1383                             Rect { x: hole.x + hole.width, y: hole.y, width: r - hole.x - hole.width, height: hole.height },
1384                         ];
1385                         let ed = &mut self.editor;
1386                         for band in bands.into_iter().filter(|b| b.width > 0.0 && b.height > 0.0) {
1387                             pc.clip(band, |pc| ed.paint_prepared_with(pc, focused, &resolved));
1388                         }
1389                         self.paint_completion(pc, hole);
1390                     }
1391                     None => self.editor.paint_prepared_with(pc, focused, &resolved),
1392                 }
1393             }
1394             Mode::Reading => {
1395                 let (ox, oy, width) = self.reading_frame(m);
1396                 self.ensure_layout(width);
1397                 if let Some(line) = self.pending_line.take() {
1398                     let max = self.read_max_scroll(m);
1399                     let y = self.layout.as_ref().map_or(0.0, |(_, l)| l.y_of_line(line));
1400                     self.read_scroll = y.clamp(0.0, max);
1401                     self.read_motion.y.jump_to(self.read_scroll);
1402                 }
1403                 let note = m.note;
1404                 if let Some((_, l)) = &self.layout {
1405                     let images = &self.images;
1406                     pc.clip(note, |pc| l.paint_with(pc, (ox, oy), self.read_scroll, note, &|t| images.lookup(t)));
1407                 }
1408             }
1409         }
1410     }
1411 
1412     fn paint_switcher(&self, pc: &mut PaintCtx) {
1413         let Some(sw) = &self.switcher else { return };
1414         let r = self.switcher_rect();
1415         pc.rounded_rect(r, 10.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_BG);
1416         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.ui_context[self.switcher_input], pc);
1417         let (family, size) = cce_ui::layout::list_font_parsed();
1418         let top = r.y + switcher_rows_top();
1419         for (i, c) in sw.choices.iter().enumerate() {
1420             let row = Rect { x: r.x + 6.0, y: top + i as f32 * SWITCHER_ROW_H, width: r.width - 12.0, height: SWITCHER_ROW_H };
1421             if i == sw.selected {
1422                 pc.rounded_rect(row, 5.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
1423             }
1424             let (label, color, alias) = match c {
1425                 Choice::Note { path, alias } => (path.trim_end_matches(".md").to_string(), FG, alias.as_deref()),
1426                 Choice::Create(name) => (format!("Create “{name}”"), DIM, None),
1427             };
1428             let ty = cce_ui::layout::align_text_y(row.y, row.height, size, 0.0);
1429             let bounds = Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]);
1430             let lw = label.chars().count() as f32 * size * 0.6;
1431             pc.text_with(label, row.x + 10.0, ty, size, srgb_u8(color), Some(family.clone()), bounds);
1432             if let Some(alias) = alias {
1433                 // "path ← alias": the alias the query matched, behind an
1434                 // arrow glyph pointing back at the note it names.
1435                 let side = (size * 0.9).round();
1436                 let ax = row.x + 10.0 + lw + size * 0.6;
1437                 let arrow = Rect { x: ax, y: row.y + (row.height - side) / 2.0, width: side, height: side };
1438                 if arrow.x + arrow.width < row.x + row.width - 6.0 {
1439                     pc.icon("arrow-left", arrow, cce_ui::colors::to_srgb(DIM));
1440                 }
1441                 let tx = ax + side + size * 0.4;
1442                 pc.text_with(alias.to_string(), tx, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
1443             }
1444         }
1445         if let Some((hint, err)) = &sw.hint {
1446             let y = top + sw.choices.len() as f32 * SWITCHER_ROW_H;
1447             let row = Rect { x: r.x + 6.0, y, width: r.width - 12.0, height: SWITCHER_ROW_H };
1448             pc.text_with(
1449                 hint.clone(),
1450                 row.x + 10.0,
1451                 cce_ui::layout::align_text_y(row.y, row.height, size, 0.0),
1452                 size,
1453                 srgb_u8(if *err { CONFLICT } else { DIM }),
1454                 Some(family.clone()),
1455                 Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]),
1456             );
1457         }
1458     }
1459 
1460     fn paint_side(&self, pc: &mut PaintCtx, m: &Metrics) {
1461         if m.side.width <= 0.0 {
1462             return;
1463         }
1464         pc.recess_edges(m.side, (0.0, 0.0, 0.0, 0.0), cce_ui::layout::bar_wall_width(), (false, false, false, true));
1465         let active = if self.side_tab == SideTab::Backlinks { 0 } else { 1 };
1466         paint_tabs(pc, m.side_tabs, &["Backlinks", "Outline"], active);
1467         self.side_panel.paint(pc, m.side_body);
1468     }
1469 
1470     fn paint_completion(&self, pc: &mut PaintCtx, r: Rect) {
1471         let Some(c) = &self.completion else { return };
1472         pc.rounded_rect(r, 8.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_BG);
1473         let (family, size) = cce_ui::layout::list_font_parsed();
1474         for (i, path) in c.choices.iter().enumerate() {
1475             let row = Rect { x: r.x + 4.0, y: r.y + 4.0 + i as f32 * COMPLETE_ROW_H, width: r.width - 8.0, height: COMPLETE_ROW_H };
1476             if i == c.selected {
1477                 pc.rounded_rect(row, 5.0, (true, true, true, true), cce_ui::colors::PANEL_MENU_HOVER);
1478             }
1479             let (dir, file) = match path.rsplit_once('/') {
1480                 Some((d, f)) => (Some(d), f),
1481                 None => (None, path.as_str()),
1482             };
1483             let name = file.strip_suffix(".md").unwrap_or(file);
1484             let ty = cce_ui::layout::align_text_y(row.y, row.height, size, 0.0);
1485             let bounds = Some([row.x, row.y, row.x + row.width - 6.0, row.y + row.height]);
1486             pc.text_with(name.to_string(), row.x + 8.0, ty, size, srgb_u8(FG), Some(family.clone()), bounds);
1487             if let Some(d) = dir {
1488                 let nx = row.x + 8.0 + (name.chars().count() as f32 + 2.0) * size * 0.6;
1489                 pc.text_with(d.to_string(), nx, ty, size, srgb_u8(DIM), Some(family.clone()), bounds);
1490             }
1491         }
1492     }
1493 }
1494 
1495 /// Equal-width text tabs across `r`, the active one underlined.
1496 fn paint_tabs(pc: &mut PaintCtx, r: Rect, labels: &[&str], active: usize) {
1497     if r.width <= 0.0 {
1498         return;
1499     }
1500     let (family, size) = cce_ui::layout::tree_font_parsed();
1501     let w = r.width / labels.len() as f32;
1502     for (i, label) in labels.iter().enumerate() {
1503         let cell = Rect { x: r.x + i as f32 * w, y: r.y, width: w, height: r.height };
1504         let lw = label.chars().count() as f32 * size * 0.6;
1505         let color = if i == active { FG } else { DIM };
1506         pc.text_with(
1507             label.to_string(),
1508             cell.x + ((cell.width - lw) / 2.0).max(4.0),
1509             cce_ui::layout::align_text_y(cell.y, cell.height, size, 0.0),
1510             size,
1511             srgb_u8(color),
1512             Some(family.clone()),
1513             Some([cell.x, cell.y, cell.x + cell.width, cell.y + cell.height]),
1514         );
1515         if i == active {
1516             let u = Rect { x: cell.x + 14.0, y: cell.y + cell.height - 3.0, width: (cell.width - 28.0).max(4.0), height: 2.0 };
1517             pc.rounded_rect(u, 1.0, (true, true, true, true), cce_ui::colors::to_linear([0.66, 0.55, 0.98, 1.0]));
1518         }
1519     }
1520 }
1521 
1522 /// The switcher's choice rows start under its input: a 12px inset, the
1523 /// textbox, an 8px gap.
1524 fn switcher_rows_top() -> f32 {
1525     12.0 + cce_ui::layout::textbox_height() + 8.0
1526 }
1527 
1528 fn tab_at(r: Rect, n: usize, x: f32, y: f32) -> Option<usize> {
1529     if r.width <= 0.0 || !r.contains(x, y) {
1530         return None;
1531     }
1532     Some((((x - r.x) / (r.width / n as f32)) as usize).min(n - 1))
1533 }
1534 
1535 /// The vault path a rename prompt's text names: `.md` added unless it
1536 /// names another extension the note already had. `None` while empty.
1537 fn rename_target(from: &str, query: &str) -> Option<String> {
1538     let q = query.trim().trim_start_matches('/').trim_end_matches('/');
1539     if q.is_empty() {
1540         return None;
1541     }
1542     let keeps_ext = !from.to_lowercase().ends_with(".md") && from.rsplit('.').next() == q.rsplit('.').next();
1543     if q.to_lowercase().ends_with(".md") || keeps_ext {
1544         Some(q.to_string())
1545     } else {
1546         Some(format!("{q}.md"))
1547     }
1548 }
1549 
1550 
1551 use side::plural;
1552 
1553 fn vault_name(root: &Path) -> String {
1554     root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| root.display().to_string())
1555 }
1556 
1557 impl Application for NotesApp {
1558     type Message = Message;
1559 
1560     fn ui_context(&self) -> Option<&cce_ui::context::UiContext> {
1561         Some(&self.ui_context)
1562     }
1563 
1564     /// Tab accepts the editor's link completion, as Enter does. The toolkit's Tab walk (on by
1565     /// default since 2026-10-08) would take it first.
1566     fn plate_navigation(&self) -> bool {
1567         false
1568     }
1569 
1570     fn ui_context_mut(&mut self) -> Option<&mut cce_ui::context::UiContext> {
1571         Some(&mut self.ui_context)
1572     }
1573 
1574     fn create(sender: cce_ui::engine::AppSender<Self::Message>) -> Self {
1575         // The app keeps calloop's sender; `AppSender` converts into it.
1576         let sender: calloop::channel::Sender<Self::Message> = sender.into();
1577         let startup = STARTUP.get().expect("startup set in main");
1578         let (index, vault_error, watcher) = match &startup.vault {
1579             Ok(root) => match Index::open(root, true) {
1580                 Ok(mut ix) => {
1581                     if let Err(e) = ix.save_cache() {
1582                         log::warn!("vault cache: {e}");
1583                     }
1584                     let tx = sender.clone();
1585                     let watcher = VaultWatcher::spawn(root, move |paths| {
1586                         let _ = tx.send(Message::VaultChanged(paths));
1587                     })
1588                     .map_err(|e| log::warn!("vault watcher: {e}"))
1589                     .ok();
1590                     (Some(ix), None, watcher)
1591                 }
1592                 Err(e) => (None, Some(format!("Could not read the vault at {}\n{e}", root.display())), None),
1593             },
1594             Err(e) => (None, Some(e.clone()), None),
1595         };
1596         // Only the instance serves MCP: a second copy running standalone
1597         // would fight it for the port.
1598         if instance::spawn_listener(sender.clone()) && index.is_some() {
1599             mcp::start(sender.clone());
1600         }
1601         let _ = sender.send(Message::Command(startup.command.clone()));
1602 
1603         let images = images::Images::default();
1604         let mut editor = DocEditor::new("", EditorTheme::new(READ_SIZE), true);
1605         let shared = images.clone();
1606         editor.set_images(Box::new(move |t| shared.lookup(t)));
1607         editor.max_width = READ_MAX_W;
1608         editor.pad = READ_PAD;
1609         let switcher_input = TextBox::new(String::new()).with_placeholder("Find or create a note…");
1610         let search_input = TextBox::new(String::new()).with_placeholder("Search");
1611 
1612         // The context owns the widgets; the app keeps their handles.
1613         let mut ui_context = cce_ui::context::UiContext::new();
1614         let mut app = NotesApp {
1615             keys: Keys::load(),
1616             index,
1617             vault_error,
1618             _watcher: watcher,
1619             tree: tree::Tree::default(),
1620             rows: Vec::new(),
1621             show_tree: true,
1622             tree_scroll: 0.0,
1623             tree_motion: ScrollMotion::new(),
1624             tree_hover: None,
1625             current: None,
1626             saved: String::new(),
1627             blocks: Vec::new(),
1628             theme: Theme { body_font: "sans-serif".into(), mono_font: "monospace".into(), size: READ_SIZE },
1629             layout: None,
1630             measure: None,
1631             mode: Mode::Reading,
1632             read_scroll: 0.0,
1633             read_motion: ScrollMotion::new(),
1634             pending_line: None,
1635             hover_hit: false,
1636             saved_rev: editor.buf.revision,
1637             edit_seen: editor.buf.revision,
1638             editor,
1639             images,
1640             sender: sender.clone(),
1641             seen_renderer: false,
1642             edit_changed_at: Instant::now(),
1643             history: Vec::new(),
1644             hist_pos: 0,
1645             switcher: None,
1646             switcher_input: ui_context.insert(switcher_input),
1647             left_tab: LeftTab::Files,
1648             search_input: ui_context.insert(search_input),
1649             search_panel: Panel::default(),
1650             search_seen: String::new(),
1651             show_side: true,
1652             side_tab: SideTab::Backlinks,
1653             side_panel: Panel::default(),
1654             side_key: None,
1655             side_dirty: true,
1656             completion: None,
1657             completion_dismissed: None,
1658             conflict: false,
1659             status: None,
1660             width: 1000,
1661             height: 700,
1662             scale: 1.0,
1663             needs_rebuild: true,
1664             ui_context,
1665             pointer: (0.0, 0.0),
1666         };
1667         app.rebuild_rows();
1668         app
1669     }
1670 
1671     fn settings(&self) -> WindowSettings {
1672         WindowSettings {
1673             title: "Notes".to_string(),
1674             app_id: "cce-notes".to_string(),
1675             width: 1000,
1676             height: 700,
1677             fullscreen: false,
1678             min_size: Some((480, 320)),
1679         }
1680     }
1681 
1682     fn update(&mut self, msg: Message, needs_rebuild: &mut bool, _exit: &mut bool) {
1683         match msg {
1684             Message::Command(cmd) => self.run_command(cmd),
1685             Message::VaultChanged(paths) => self.vault_changed(paths),
1686             Message::Mcp(call) => self.mcp_call(call),
1687             Message::ImageDecoded(path, decoded) => {
1688                 if self.images.decoded(path, decoded) {
1689                     self.layout = None;
1690                     self.editor.invalidate();
1691                 }
1692             }
1693             Message::ImagesReady => {
1694                 self.layout = None;
1695                 self.editor.invalidate();
1696             }
1697             Message::Exit => *_exit = true,
1698         }
1699         *needs_rebuild = true;
1700     }
1701 
1702     fn idle_poll_interval(&self) -> Option<Duration> {
1703         // Wakes the autosave check while the editor holds unsaved text.
1704         self.dirty().then_some(Duration::from_millis(500))
1705     }
1706 
1707     fn tick(&mut self, dt: f32, needs_rebuild: &mut bool) {
1708         if self.ui_context.tick(dt) {
1709             *needs_rebuild = true;
1710         }
1711         let m = self.metrics();
1712         if self.read_motion.is_animating() {
1713             let max = self.read_max_scroll(&m);
1714             self.read_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
1715             self.read_scroll = self.read_motion.y.pos();
1716             *needs_rebuild = true;
1717         }
1718         if self.tree_motion.is_animating() {
1719             let max = self.tree_max_scroll(&m);
1720             self.tree_motion.tick(dt, Bounds::max(0.0), Bounds::max(max));
1721             self.tree_scroll = self.tree_motion.y.pos();
1722             *needs_rebuild = true;
1723         }
1724         if self.search_panel.tick(dt, m.tree_body) | self.side_panel.tick(dt, m.side_body) {
1725             *needs_rebuild = true;
1726         }
1727         if self.mode == Mode::Source && self.editor.tick(dt) {
1728             *needs_rebuild = true;
1729         }
1730         if self.dirty() && !self.conflict {
1731             let now = Instant::now();
1732             if self.editor.buf.revision != self.edit_seen {
1733                 self.edit_seen = self.editor.buf.revision;
1734                 self.edit_changed_at = now;
1735             } else if now.duration_since(self.edit_changed_at) >= AUTOSAVE_AFTER {
1736                 self.save();
1737                 *needs_rebuild = true;
1738             }
1739         }
1740     }
1741 
1742     fn display_list(&mut self, size: LogicalSize, scale: f64) -> Option<DisplayList> {
1743         let size_changed =
1744             self.width != size.width as u32 || self.height != size.height as u32 || self.scale != scale;
1745         if size_changed {
1746             if self.scale != scale {
1747                 self.layout = None;
1748                 self.editor.invalidate();
1749             }
1750             self.width = size.width as u32;
1751             self.height = size.height as u32;
1752             self.scale = scale;
1753             cce_ui::scale::set_scale_factor(scale as f32);
1754         }
1755         let m = self.metrics();
1756         if self.needs_rebuild || size_changed {
1757             let r = self.switcher_rect();
1758             self.ui_context[self.switcher_input].set_rect(r.x + 12.0, r.y + 12.0, r.width - 24.0, cce_ui::layout::textbox_height());
1759             let sb = m.search_box;
1760             self.ui_context[self.search_input].set_rect(sb.x, sb.y, sb.width, sb.height);
1761             self.needs_rebuild = false;
1762             self.ui_context.rebuild_spatial_grid();
1763             // Keep scroll offsets in range after a resize or a new layout.
1764             self.tree_scroll = self.tree_scroll.min(self.tree_max_scroll(&m));
1765             self.search_panel.clamp(m.tree_body);
1766             self.side_panel.clamp(m.side_body);
1767         }
1768         if self.side_dirty && m.side.width > 0.0 {
1769             self.refresh_side();
1770         }
1771 
1772         let (w, h) = (self.width as f32, self.height as f32);
1773         let mut pc = PaintCtx::new();
1774         pc.root_plate(w, h);
1775         self.paint_band(&mut pc, &m);
1776         if self.switcher.is_none() {
1777             self.paint_tree(&mut pc, &m);
1778             self.paint_side(&mut pc, &m);
1779         }
1780         self.paint_note(&mut pc, &m);
1781         let max = self.read_max_scroll(&m);
1782         if self.read_scroll > max {
1783             self.read_scroll = max;
1784             self.read_motion.y.jump_to(max);
1785         }
1786         self.paint_status(&mut pc);
1787         self.paint_switcher(&mut pc);
1788         // What this frame asked for and nobody has: resolve and decode it.
1789         if let Some(ix) = &self.index {
1790             let tx = self.sender.clone();
1791             let ready = self.images.pump(ix, self.current.as_deref(), move |path, d| {
1792                 let _ = tx.send(Message::ImageDecoded(path, d));
1793             });
1794             if ready {
1795                 // Through the loop, so a frame follows.
1796                 let _ = self.sender.send(Message::ImagesReady);
1797             }
1798         }
1799         Some(pc.finish())
1800     }
1801 
1802     fn display_list_text(&self) -> bool {
1803         true
1804     }
1805 
1806     /// Pixels first (a browser's dragged picture); a file manager's drag
1807     /// offers only the file list.
1808     fn drop_mimes(&self) -> &'static [&'static str] {
1809         paste::DROP_MIMES
1810     }
1811 
1812     fn handle_drop(&mut self, mime: &str, data: &[u8], pos: LogicalPosition, needs_rebuild: &mut bool) {
1813         self.dropped(mime, data, pos.x as f32, pos.y as f32);
1814         *needs_rebuild = true;
1815     }
1816 
1817     /// A reconnect builds a new renderer and every uploaded image id dies
1818     /// with the old one: forget them, and the next frame uploads afresh.
1819     /// The first renderer needs nothing (uploads queued before it drain
1820     /// into it).
1821     fn renderer_init(&mut self, _renderer: &mut cce_ui::vk::VkRenderer) {
1822         if std::mem::replace(&mut self.seen_renderer, true) {
1823             self.images.renderer_reset();
1824             self.layout = None;
1825             self.editor.invalidate();
1826         }
1827     }
1828 
1829     /// Ctrl+Z reaches the editor through here: the runner routes the undo
1830     /// chord to the focused widget first, and the editor is not one.
1831     fn undo(&mut self, needs_rebuild: &mut bool) -> bool {
1832         if !self.editor_focused() {
1833             return false;
1834         }
1835         self.editor.undo();
1836         self.update_completion();
1837         *needs_rebuild = true;
1838         true
1839     }
1840 
1841     fn redo(&mut self, needs_rebuild: &mut bool) -> bool {
1842         if !self.editor_focused() {
1843             return false;
1844         }
1845         self.editor.redo();
1846         self.update_completion();
1847         *needs_rebuild = true;
1848         true
1849     }
1850 
1851     /// The reading view needs bold and italic faces of the DE's sans
1852     /// (Noto Sans by default), which the toolkit's bundle does not carry:
1853     /// without system fonts, `**bold**` fell back to a serif face. The
1854     /// measuring FontSystem loads the same set ([`NotesApp::measure_fs`]).
1855     fn load_system_fonts(&self) -> bool {
1856         true
1857     }
1858 
1859     fn cursor_icon(&self, x: f32, y: f32) -> Option<CursorIcon> {
1860         let m = self.metrics();
1861         let over_title = self.current.is_some() && self.switcher.is_none() && m.title.contains(x, y);
1862         if self.hover_hit
1863             || self.tree_hover.is_some()
1864             || self.search_panel.hover.is_some()
1865             || self.side_panel.hover.is_some()
1866             || over_title
1867         {
1868             return Some(CursorIcon::Pointer);
1869         }
1870         if self.left_tab == LeftTab::Search && m.search_box.contains(x, y) {
1871             return Some(CursorIcon::Text);
1872         }
1873         (self.mode == Mode::Source && self.switcher.is_none() && self.editor_rect(&m).contains(x, y))
1874             .then_some(CursorIcon::Text)
1875     }
1876 
1877     fn on_exit(&mut self) {
1878         if !self.conflict {
1879             self.save();
1880         }
1881         if let Some(ix) = self.index.as_mut() {
1882             let _ = ix.save_cache();
1883         }
1884     }
1885 
1886     fn handle_pointer_move(&mut self, pos: LogicalPosition, needs_rebuild: &mut bool) {
1887         let (x, y) = (pos.x as f32, pos.y as f32);
1888         self.pointer = (x, y);
1889         // The shared context menu (the switcher's or the search box's) gets the
1890         // pointer to itself while open, over the switcher too: its row highlight.
1891         if cce_ui::widget::context_menu::is_visible() {
1892             if cce_ui::widget::context_menu::cursor_moved(x, y) {
1893                 *needs_rebuild = true;
1894             }
1895             return;
1896         }
1897         let ev = Event::PointerMove { x, y, local_x: x, local_y: y };
1898         if self.switcher.is_some() {
1899             let row = self.switcher_row_at(x, y);
1900             if let (Some(sw), Some(i)) = (self.switcher.as_mut(), row) {
1901                 if sw.selected != i {
1902                     sw.selected = i;
1903                     *needs_rebuild = true;
1904                 }
1905             }
1906             if self.ui_context.propagate_event(&ev, self.switcher_input.id()) {
1907                 *needs_rebuild = true;
1908             }
1909             return;
1910         }
1911         let m = self.metrics();
1912         let hover = self.tree_row_at(&m, x, y);
1913         if hover != self.tree_hover {
1914             self.tree_hover = hover;
1915             *needs_rebuild = true;
1916         }
1917         let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
1918         if self.search_panel.hover_at(search_area, x, y) | self.side_panel.hover_at(m.side_body, x, y) {
1919             *needs_rebuild = true;
1920         }
1921         if self.left_tab == LeftTab::Search && self.ui_context.propagate_event(&ev, self.search_input.id()) {
1922             *needs_rebuild = true;
1923         }
1924         let mut hit = self.reading_hit(x, y).is_some();
1925         if self.mode == Mode::Source {
1926             if self.editor.dragging() {
1927                 if self.editor.drag(x, y) != doc_editor::Response::None {
1928                     self.update_completion();
1929                     *needs_rebuild = true;
1930                 }
1931             } else if m.note.contains(x, y) {
1932                 hit = self.editor.link_at(x, y);
1933             }
1934         }
1935         if hit != self.hover_hit {
1936             self.hover_hit = hit;
1937             *needs_rebuild = true;
1938         }
1939     }
1940 
1941     fn handle_mouse_input(
1942         &mut self,
1943         button: MouseButton,
1944         state: ElementState,
1945         pos: LogicalPosition,
1946         needs_rebuild: &mut bool,
1947     ) -> Option<Message> {
1948         let (x, y) = (pos.x as f32, pos.y as f32);
1949         *needs_rebuild = true;
1950 
1951         // The shared context menu a right-click on the switcher or search box
1952         // opens takes every click while open: a row runs, a press anywhere else
1953         // dismisses it -- and only it, so the press that closes the menu does
1954         // not also close the switcher beneath. The toolkit leaves this routing to
1955         // the app; without it the menu could not be closed by clicking outside
1956         // it, and its rows did nothing. A Paste or Cut is a new query.
1957         if cce_ui::widget::context_menu::is_visible() {
1958             cce_ui::widget::context_menu::mouse_input(button, state, x, y, Some(&mut self.ui_context));
1959             if self.switcher.is_some() {
1960                 self.refresh_switcher();
1961             } else {
1962                 self.refresh_search(false);
1963             }
1964             return None;
1965         }
1966 
1967         let ev = Event::MouseButton { button, state, x, y, local_x: x, local_y: y };
1968         let pressed = state == ElementState::Pressed && button == MouseButton::Left;
1969 
1970         if self.switcher.is_some() {
1971             if pressed {
1972                 if let Some(i) = self.switcher_row_at(x, y) {
1973                     if let Some(sw) = self.switcher.as_mut() {
1974                         sw.selected = i;
1975                     }
1976                     self.switcher_pick(false);
1977                     return None;
1978                 }
1979                 if !self.switcher_rect().contains(x, y) {
1980                     self.close_switcher();
1981                     return None;
1982                 }
1983             }
1984             self.ui_context.propagate_event(&ev, self.switcher_input.id());
1985             return None;
1986         }
1987 
1988         let m = self.metrics();
1989         // The search box keeps the keyboard only while it is clicked into.
1990         let in_search = self.left_tab == LeftTab::Search && m.search_box.contains(x, y);
1991         if pressed && !in_search && self.ui_context[self.search_input].editing {
1992             self.ui_context.unfocus_id(self.search_input.id());
1993         }
1994         if in_search {
1995             if pressed && !self.ui_context[self.search_input].editing {
1996                 self.ui_context.set_focused_id(self.search_input.id());
1997                 WidgetHost::focus(&mut self.ui_context[self.search_input]);
1998             }
1999             self.ui_context.propagate_event(&ev, self.search_input.id());
2000             return None;
2001         }
2002         if pressed {
2003             if let Some(i) = self.completion_row_at(&m, x, y) {
2004                 if let Some(c) = self.completion.as_mut() {
2005                     c.selected = i;
2006                 }
2007                 self.accept_completion();
2008                 return None;
2009             }
2010             if let Some(t) = tab_at(m.left_tabs, 2, x, y) {
2011                 self.left_tab = if t == 0 { LeftTab::Files } else { LeftTab::Search };
2012                 if self.left_tab == LeftTab::Search {
2013                     let q = self.search_query();
2014                     self.open_search(&q);
2015                 }
2016                 self.needs_rebuild = true;
2017                 return None;
2018             }
2019             if let Some(t) = tab_at(m.side_tabs, 2, x, y) {
2020                 self.side_tab = if t == 0 { SideTab::Backlinks } else { SideTab::Outline };
2021                 self.side_dirty = true;
2022                 return None;
2023             }
2024             let search_area = if self.left_tab == LeftTab::Search { m.tree_body } else { Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 } };
2025             if let Some(a) = self.search_panel.click(search_area, x, y).or_else(|| self.side_panel.click(m.side_body, x, y)) {
2026                 self.run_action(a);
2027                 return None;
2028             }
2029             if self.current.is_some() && m.title.contains(x, y) {
2030                 self.open_rename();
2031                 return None;
2032             }
2033         }
2034         if pressed && m.mode_chip.contains(x, y) && self.current.is_some() {
2035             let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
2036             self.set_mode(next);
2037             return None;
2038         }
2039         if pressed {
2040             if let Some(i) = self.tree_row_at(&m, x, y) {
2041                 let row = self.rows[i].clone();
2042                 if row.folder {
2043                     self.tree.toggle(&row.path);
2044                     self.rebuild_rows();
2045                 } else {
2046                     self.open_path(&row.path, None, true);
2047                 }
2048                 return None;
2049             }
2050             if let Some(hit) = self.reading_hit(x, y) {
2051                 match hit {
2052                     Hit::Link(link) => self.follow(link),
2053                     Hit::Task { line, status } => self.toggle_task(line, status),
2054                 }
2055                 self.hover_hit = false;
2056                 return None;
2057             }
2058         }
2059         // Back/forward mouse buttons walk the history, as in a browser.
2060         if state == ElementState::Pressed {
2061             match button {
2062                 MouseButton::Back => {
2063                     self.go_history(-1);
2064                     return None;
2065                 }
2066                 MouseButton::Forward => {
2067                     self.go_history(1);
2068                     return None;
2069                 }
2070                 _ => {}
2071             }
2072         }
2073         if self.mode == Mode::Source && button == MouseButton::Left {
2074             if state == ElementState::Released {
2075                 self.editor.release();
2076             } else if self.editor_rect(&m).contains(x, y) {
2077                 let (shift, ctrl) = (self.ui_context.shift_pressed, self.ui_context.ctrl_pressed);
2078                 match self.editor.press(x, y, shift, ctrl) {
2079                     doc_editor::Response::Follow(target) => {
2080                         self.editor.release();
2081                         self.hover_hit = false;
2082                         self.follow(match target {
2083                             doc_editor::Target::Note { target, subpath } => SpanLink::Note { target, subpath },
2084                             doc_editor::Target::Url(url) => SpanLink::Url(url),
2085                         });
2086                         return None;
2087                     }
2088                     _ => self.update_completion(),
2089                 }
2090             }
2091         }
2092         None
2093     }
2094 
2095     fn handle_mouse_wheel(&mut self, delta: &MouseScrollDelta, pos: LogicalPosition, needs_rebuild: &mut bool) {
2096         let (x, y) = (pos.x as f32, pos.y as f32);
2097         if self.switcher.is_some() {
2098             return;
2099         }
2100         let m = self.metrics();
2101         if m.side_body.contains(x, y) {
2102             if self.side_panel.wheel(delta, m.side_body) {
2103                 *needs_rebuild = true;
2104             }
2105             return;
2106         }
2107         if self.left_tab == LeftTab::Search && m.tree_body.contains(x, y) {
2108             if self.search_panel.wheel(delta, m.tree_body) {
2109                 *needs_rebuild = true;
2110             }
2111             return;
2112         }
2113         if m.tree_body.contains(x, y) && self.left_tab == LeftTab::Files {
2114             let max = self.tree_max_scroll(&m);
2115             self.tree_motion.reconcile(0.0, self.tree_scroll);
2116             if self.tree_motion.apply(delta, (TREE_ROW_H, TREE_ROW_H), Bounds::max(0.0), Bounds::max(max)) {
2117                 self.tree_scroll = self.tree_motion.y.pos();
2118                 *needs_rebuild = true;
2119             }
2120             return;
2121         }
2122         if !m.note.contains(x, y) {
2123             return;
2124         }
2125         match self.mode {
2126             Mode::Reading => {
2127                 let max = self.read_max_scroll(&m);
2128                 self.read_motion.reconcile(0.0, self.read_scroll);
2129                 let line = READ_SIZE * 1.5;
2130                 if self.read_motion.apply(delta, (line, line * 2.0), Bounds::max(0.0), Bounds::max(max)) {
2131                     self.read_scroll = self.read_motion.y.pos();
2132                     *needs_rebuild = true;
2133                 }
2134             }
2135             Mode::Source => {
2136                 if self.editor.wheel(delta) {
2137                     *needs_rebuild = true;
2138                 }
2139             }
2140         }
2141     }
2142 
2143     fn handle_key_input(&mut self, event: &KeyEvent, needs_rebuild: &mut bool) -> Option<Message> {
2144         *needs_rebuild = true;
2145         let pressed = event.state == ElementState::Pressed;
2146         let ev = Event::KeyInput(event.clone());
2147 
2148         if self.switcher.is_some() {
2149             if pressed {
2150                 match &event.logical_key {
2151                     Key::Named(NamedKey::Escape) => {
2152                         self.close_switcher();
2153                         return None;
2154                     }
2155                     Key::Named(NamedKey::Enter) => {
2156                         self.switcher_pick(event.shift);
2157                         return None;
2158                     }
2159                     Key::Named(NamedKey::ArrowDown) | Key::Named(NamedKey::ArrowUp) => {
2160                         if let Some(sw) = self.switcher.as_mut() {
2161                             let n = sw.choices.len();
2162                             if n > 0 {
2163                                 let down = matches!(event.logical_key, Key::Named(NamedKey::ArrowDown));
2164                                 sw.selected = if down { (sw.selected + 1) % n } else { (sw.selected + n - 1) % n };
2165                             }
2166                         }
2167                         return None;
2168                     }
2169                     _ => {}
2170                 }
2171             }
2172             self.ui_context.propagate_event(&ev, self.switcher_input.id());
2173             self.refresh_switcher();
2174             return None;
2175         }
2176 
2177         if pressed {
2178             let k = |chord: &str| cce_ui::widget::match_key_shortcut(event, chord);
2179             if k(&self.keys.quit) {
2180                 return Some(Message::Exit);
2181             }
2182             if k(&self.keys.switcher) && self.index.is_some() {
2183                 self.open_switcher("");
2184                 return None;
2185             }
2186             if k(&self.keys.toggle_mode) {
2187                 let next = if self.mode == Mode::Reading { Mode::Source } else { Mode::Reading };
2188                 self.set_mode(next);
2189                 return None;
2190             }
2191             if k(&self.keys.toggle_source) && self.current.is_some() {
2192                 // Live preview <-> source, both editing; from reading it
2193                 // opens the editor in the mode it flips to.
2194                 let on = !self.editor.preview;
2195                 self.editor.set_preview(on);
2196                 self.set_mode(Mode::Source);
2197                 return None;
2198             }
2199             if k(&self.keys.save) {
2200                 // In a conflict this is "keep mine": it writes over the disk copy.
2201                 self.save_now(self.conflict);
2202                 return None;
2203             }
2204             if k(&self.keys.reload) {
2205                 if let Some(cur) = self.current.clone() {
2206                     let mode = self.mode;
2207                     self.mode = Mode::Reading;
2208                     self.open_path(&cur, None, false);
2209                     self.mode = mode;
2210                 }
2211                 return None;
2212             }
2213             if k(&self.keys.back) {
2214                 self.go_history(-1);
2215                 return None;
2216             }
2217             if k(&self.keys.forward) {
2218                 self.go_history(1);
2219                 return None;
2220             }
2221             if k(&self.keys.toggle_tree) {
2222                 self.show_tree = !self.show_tree;
2223                 self.invalidate();
2224                 return None;
2225             }
2226             if k(&self.keys.daily) && self.index.is_some() {
2227                 self.open_daily(None);
2228                 return None;
2229             }
2230             if k(&self.keys.toggle_side) {
2231                 self.show_side = !self.show_side;
2232                 self.side_dirty = true;
2233                 self.invalidate();
2234                 return None;
2235             }
2236             if k(&self.keys.search) && self.index.is_some() {
2237                 let q = self.search_query();
2238                 self.open_search(&q);
2239                 self.ui_context[self.search_input].select_all();
2240                 return None;
2241             }
2242             if k(&self.keys.rename) && self.current.is_some() {
2243                 self.open_rename();
2244                 return None;
2245             }
2246             if k(&self.keys.graph) && self.index.is_some() {
2247                 // The local graph follows this window's note (it polls
2248                 // `current` on the instance socket). cce-graph's vault mode
2249                 // is single-instance, so a second press only raises nothing
2250                 // new.
2251                 let vault = self.index.as_ref().map(|ix| ix.root().to_path_buf());
2252                 let mut cmd = std::process::Command::new("cce-graph");
2253                 cmd.arg("--vault");
2254                 if let Some(v) = vault {
2255                     cmd.arg(v);
2256                 }
2257                 cmd.arg("--local");
2258                 if let Err(e) = cmd.spawn() {
2259                     self.set_status(format!("Could not start cce-graph: {e}"), true);
2260                 }
2261                 return None;
2262             }
2263         }
2264 
2265         // The search box, while it holds the keyboard.
2266         if self.ui_context[self.search_input].editing {
2267             if pressed {
2268                 match &event.logical_key {
2269                     Key::Named(NamedKey::Escape) => {
2270                         self.ui_context.unfocus_id(self.search_input.id());
2271                         return None;
2272                     }
2273                     Key::Named(NamedKey::Enter) => {
2274                         // Enter opens the first result.
2275                         let first = self.search_panel.items.iter().find_map(|i| i.action.clone());
2276                         if let Some(a) = first {
2277                             self.ui_context.unfocus_id(self.search_input.id());
2278                             self.run_action(a);
2279                         }
2280                         return None;
2281                     }
2282                     _ => {}
2283                 }
2284             }
2285             self.ui_context.propagate_event(&ev, self.search_input.id());
2286             self.refresh_search(false);
2287             return None;
2288         }
2289 
2290         match self.mode {
2291             Mode::Source => {
2292                 if !pressed {
2293                     return None;
2294                 }
2295                 if self.completion_key(&event.logical_key) {
2296                     return None;
2297                 }
2298                 // An image on the clipboard pastes as an attachment;
2299                 // anything else falls through to the editor's text paste.
2300                 if cce_ui::widget::match_key_shortcut(event, "ctrl+v") && self.paste_images() {
2301                     self.update_completion();
2302                     return None;
2303                 }
2304                 self.editor.key(event);
2305                 self.update_completion();
2306             }
2307             Mode::Reading if pressed => {
2308                 let m = self.metrics();
2309                 let max = self.read_max_scroll(&m);
2310                 let page = m.note.height * 0.9;
2311                 let target = match &event.logical_key {
2312                     Key::Named(NamedKey::ArrowDown) => Some(self.read_scroll + READ_SIZE * 3.0),
2313                     Key::Named(NamedKey::ArrowUp) => Some(self.read_scroll - READ_SIZE * 3.0),
2314                     Key::Named(NamedKey::PageDown) | Key::Named(NamedKey::Space) => Some(self.read_scroll + page),
2315                     Key::Named(NamedKey::PageUp) => Some(self.read_scroll - page),
2316                     Key::Named(NamedKey::Home) => Some(0.0),
2317                     Key::Named(NamedKey::End) => Some(max),
2318                     _ => None,
2319                 };
2320                 if let Some(t) = target {
2321                     let s = cce_ui::widget::scroll_motion::scroll_settings();
2322                     self.read_motion.reconcile(0.0, self.read_scroll);
2323                     self.read_motion.y.scroll_to(t, Bounds::max(max), &s);
2324                 }
2325             }
2326             Mode::Reading => {}
2327         }
2328         None
2329     }
2330 }
2331 
2332 fn main() {
2333     env_logger::init();
2334     let mut args: Vec<String> = std::env::args().skip(1).collect();
2335     let mut explicit: Option<PathBuf> = None;
2336     if let Some(i) = args.iter().position(|a| a == "--vault") {
2337         if i + 1 >= args.len() {
2338             eprintln!("cce-notes: --vault needs a directory");
2339             std::process::exit(2);
2340         }
2341         explicit = Some(PathBuf::from(args.remove(i + 1)));
2342         args.remove(i);
2343     }
2344     let command = match Command::from_args(&args) {
2345         Ok(c) => c,
2346         Err(e) => {
2347             eprintln!("cce-notes: {e}");
2348             std::process::exit(2);
2349         }
2350     };
2351     if instance::forward_or_claim(&command) {
2352         return;
2353     }
2354     let vault = cce_vault::config::vault_root(explicit.as_deref()).map_err(|e| {
2355         format!(
2356             "No vault: {e}\nSet `vault {{ path \"~/Notes\" }}` in ~/.config/cce/config.kdl, \
2357              or run with --vault <dir> or CCE_VAULT=<dir>."
2358         )
2359     });
2360     let _ = STARTUP.set(Startup { vault, command });
2361     cce_ui::engine::run::<NotesApp>();
2362     instance::cleanup();
2363 }