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 }