From 5e9b5d3cfb3e7a4efcf58148a7489b41db6dbb37 Mon Sep 17 00:00:00 2001 From: Marcos Paulo Date: Wed, 21 Oct 2026 02:31:52 -0300 Subject: [PATCH] feat: audio player, code files with highlighting, ico, draw tools fixed, activity panel, calendar-title link, lightbox and shell fixes Audio tab and note embeds with waveform, live spectrum (own FFT), seek, speed, loop and volume through ffplay/ffmpeg; code and text files open in an editable source editor with syntax highlighting, a Language chip and a banner to install, pick or ignore a grammar; ico decoding through the largest entry; info card as a clean grid; draw tools: arrows and lines clamped and clipped to the picture with a head, inline text tool, hotkeys; Activity tab with unread count and hotkey; calendar title shows a link icon and a pointing hand; explorer trash button works on press; right panel tab pills shrink so they never cover the collapse button; math: unknown command keeps a parser tooltip; test files grouped into tests/ folders (12-file rule); flaky search-order test waits for the rows. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, 4422 tests, layers, tree, i18n) on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 --- ROADMAP.md | 2 +- apps/notescraft-sync-server/tests/publish.rs | 24 ++ assets/icons/bell.svg | 14 + crates/i18n/locales/en.toml | 48 ++- crates/ui-egui/Cargo.toml | 4 +- crates/ui-egui/src/design/fonts/mod.rs | 6 +- crates/ui-egui/src/design/icons.rs | 1 + crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/mod.rs | 2 +- .../src/panels/right/activity_panel.rs | 350 ++++++++++++++++ crates/ui-egui/src/panels/right/mod.rs | 1 + crates/ui-egui/src/panels/right_view.rs | 70 +++- crates/ui-egui/src/panels/tree.rs | 8 +- crates/ui-egui/src/panels/tree/hscroll.rs | 2 +- .../ui-egui/src/panels/tree/scroll_tests.rs | 5 +- crates/ui-egui/src/panels/tree_tests.rs | 4 +- crates/ui-egui/src/shell/app.rs | 3 + .../shell/app/editor/editor_tools_tests.rs | 3 + .../ui-egui/src/shell/app/vault/explorer.rs | 3 + .../ui-egui/src/shell/app/vault/indexing.rs | 27 +- .../ui-egui/src/shell/app/vault/lifecycle.rs | 3 +- .../src/shell/app/vault/preferences.rs | 13 + .../src/shell/app/window/activity/mod.rs | 119 ++++++ crates/ui-egui/src/shell/app/window/cards.rs | 32 ++ .../src/shell/app/window/comments/mod.rs | 4 +- crates/ui-egui/src/shell/app/window/frame.rs | 11 +- crates/ui-egui/src/shell/app/window/mod.rs | 3 + .../app/window/{ => tests}/menu_tests.rs | 0 .../app/window/{ => tests}/origin_tests.rs | 0 crates/ui-egui/src/shell/app/window/views.rs | 34 +- .../src/shell/attachment_drop/images.rs | 2 +- .../ui-egui/src/shell/attachment_drop/mod.rs | 5 +- crates/ui-egui/src/shell/compact/mod.rs | 15 +- crates/ui-egui/src/shell/compact/review.rs | 66 +++ crates/ui-egui/src/shell/hotkeys/commands.rs | 7 +- crates/ui-egui/src/shell/hotkeys/defaults.rs | 1 + crates/ui-egui/src/shell/hotkeys/tests.rs | 1 + .../src/shell/panel_resize/controls.rs | 3 +- crates/ui-egui/src/shell/state.rs | 6 + crates/ui-egui/src/text/features/hotkeys.rs | 1 + .../src/text/panels/review/activity_panel.rs | 23 ++ crates/ui-egui/src/text/panels/review/mod.rs | 3 +- .../ui-egui/src/text/views/code_view/mod.rs | 12 + .../ui-egui/src/text/views/files/file_card.rs | 6 + .../ui-egui/src/text/views/image_annotate.rs | 3 +- .../src/text/views/media_view/audio.rs | 11 + .../ui-egui/src/text/views/media_view/mod.rs | 2 + crates/ui-egui/src/views/center/breadcrumb.rs | 8 +- crates/ui-egui/src/views/code_view/editor.rs | 195 +++++++++ .../ui-egui/src/views/code_view/language.rs | 250 ++++++++++++ .../src/views/code_view/language_ui.rs | 110 +++++ crates/ui-egui/src/views/code_view/mod.rs | 40 +- crates/ui-egui/src/views/file_card.rs | 212 ++++++++-- crates/ui-egui/src/views/folder_view.rs | 4 - .../src/views/image_view/annotate/draw.rs | 114 ++++-- .../src/views/image_view/annotate/keys.rs | 43 ++ .../src/views/image_view/annotate/mod.rs | 119 ++---- .../src/views/image_view/annotate/palette.rs | 79 ++-- .../src/views/image_view/annotate/place.rs | 122 ++++++ .../src/views/image_view/annotate/text.rs | 308 ++++++++++++++ .../src/views/image_view/annotate/tool.rs | 108 +++++ .../src/views/image_view/annotate/tools.rs | 149 +++++-- crates/ui-egui/src/views/image_view/canvas.rs | 5 +- crates/ui-egui/src/views/image_view/header.rs | 23 ++ crates/ui-egui/src/views/image_view/ico.rs | 75 ++++ crates/ui-egui/src/views/image_view/info.rs | 1 + crates/ui-egui/src/views/image_view/load.rs | 2 +- crates/ui-egui/src/views/image_view/mod.rs | 3 + crates/ui-egui/src/views/math_view.rs | 7 +- .../ui-egui/src/views/media_view/audio/bus.rs | 178 +++++++++ .../src/views/media_view/audio/embed.rs | 107 +++++ .../ui-egui/src/views/media_view/audio/fft.rs | 159 ++++++++ .../ui-egui/src/views/media_view/audio/mod.rs | 331 ++++++++++++++++ .../src/views/media_view/audio/peaks.rs | 230 +++++++++++ .../src/views/media_view/audio/player.rs | 301 ++++++++++++++ .../src/views/media_view/audio/process.rs | 137 +++++++ .../src/views/media_view/audio/ring.rs | 107 +++++ .../src/views/media_view/audio/spectrum.rs | 323 +++++++++++++++ .../src/views/media_view/audio/waveform.rs | 333 ++++++++++++++++ crates/ui-egui/src/views/media_view/mod.rs | 1 + crates/ui-egui/src/views/media_view/system.rs | 2 +- crates/ui-egui/src/views/note_view.rs | 2 +- crates/ui-egui/src/views/note_view/blocks.rs | 5 +- .../views/note_view/blocks/code_actions.rs | 2 +- .../src/views/note_view/blocks/embed.rs | 16 +- .../src/views/note_view/blocks/rich/mod.rs | 2 +- .../blocks/{ => tests}/code_card_tests.rs | 0 .../blocks/{ => tests}/fold_tests.rs | 0 .../blocks/{ => tests}/render_tests.rs | 0 crates/ui-egui/src/widgets/tabs/kind.rs | 27 +- crates/ui-egui/tests/activity_panel.rs | 135 +++++++ crates/ui-egui/tests/audio_view.rs | 375 ++++++++++++++++++ crates/ui-egui/tests/driven_features.rs | 18 +- crates/ui-egui/tests/image_annotate.rs | 189 ++++++++- crates/ui-egui/tests/math_view.rs | 4 +- crates/ui-egui/tests/menu_keys.rs | 4 +- crates/ui-egui/tests/source_language.rs | 104 +++++ docs/ARCHITECTURE.md | 2 +- docs/CODE-MAP.md | 4 +- 99 files changed, 5716 insertions(+), 323 deletions(-) create mode 100644 assets/icons/bell.svg create mode 100644 crates/ui-egui/src/panels/right/activity_panel.rs create mode 100644 crates/ui-egui/src/shell/app/window/activity/mod.rs rename crates/ui-egui/src/shell/app/window/{ => tests}/menu_tests.rs (100%) rename crates/ui-egui/src/shell/app/window/{ => tests}/origin_tests.rs (100%) create mode 100644 crates/ui-egui/src/shell/compact/review.rs create mode 100644 crates/ui-egui/src/text/panels/review/activity_panel.rs create mode 100644 crates/ui-egui/src/text/views/media_view/audio.rs create mode 100644 crates/ui-egui/src/views/code_view/editor.rs create mode 100644 crates/ui-egui/src/views/code_view/language.rs create mode 100644 crates/ui-egui/src/views/code_view/language_ui.rs create mode 100644 crates/ui-egui/src/views/image_view/annotate/keys.rs create mode 100644 crates/ui-egui/src/views/image_view/annotate/text.rs create mode 100644 crates/ui-egui/src/views/image_view/annotate/tool.rs create mode 100644 crates/ui-egui/src/views/image_view/ico.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/bus.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/embed.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/fft.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/mod.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/peaks.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/player.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/process.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/ring.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/spectrum.rs create mode 100644 crates/ui-egui/src/views/media_view/audio/waveform.rs rename crates/ui-egui/src/views/note_view/blocks/{ => tests}/code_card_tests.rs (100%) rename crates/ui-egui/src/views/note_view/blocks/{ => tests}/fold_tests.rs (100%) rename crates/ui-egui/src/views/note_view/blocks/{ => tests}/render_tests.rs (100%) create mode 100644 crates/ui-egui/tests/activity_panel.rs create mode 100644 crates/ui-egui/tests/audio_view.rs create mode 100644 crates/ui-egui/tests/source_language.rs diff --git a/ROADMAP.md b/ROADMAP.md index 844f6b1..397bc68 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -60,7 +60,7 @@ Done (crossed off): Pending, by area: - **Code blocks, grammars and formatting (done, 15 of 15 tasks, specs/code-blocks-and-formatting):** `toml` and `ts` fences now highlight through two original grammars embedded in `notescraft-syntax` (`crates/syntax/grammars/*.sublime-syntax`), and the batch review has a third check, "Add missing language labels": `blocks::detect_language` names the language of an unlabeled fence conservatively (shebang, PHP, HTML, XML, JSON, YAML, diff, shell prompts, Rust, Python, SQL, TOML, on the first 4 KiB and 40 non-blank lines) and each `ChangeKind::Label` is a zero-length insert right after the fence run that always needs review (`batch.start` takes `labels`, on by default). Not built — the language picker of the design (a mistyped label is fixed by the suggestion chip, and nothing offers to add a label while typing in source mode), the table-alignment checks of the design, and the "more info" card of the grammar dialog. A note with 50 code blocks draws its first frame in 15 ms median (release), 87 ms in a cold process. - **Editor previews (done, 11 of 11 tasks, specs/editor-previews):** not built or limited — no swatches or arrows in Source mode or in rows carrying inline math, and a hex that Obsidian reads as a tag (`#cba6f7` in prose) stays a tag, so only inline code and non-tag text get swatches; `Unsupported` is reachable only if the diagram parser returns an unrenderable kind (today it answers `NoHeader` for one it does not know, so those show the Error chip). Scanning 100 rows of 10 colours (1,000 hits) takes 0.05 ms median, 0.15 ms worst, in release. -- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), audio embeds with Play/Stop through the system player, video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events. +- **Obsidian basics (specs/obsidian-basics, audit in audit.md): all 18 tasks built.** Find and replace (with regex), back/forward, auto-pair, footnotes, explorer sort and drag-and-drop move, heading and list folding, search operators and `/regex/` search, vault-wide replace, quick switcher headings and blocks, random and unique notes, Open in default app, Copy Obsidian URL, pinned and reorderable tabs, link hover preview, outgoing links, split view (two panes with their own tabs; a click in a pane makes it the editing pane), spelling lint rule, settings (default view, font size, readable width, line numbers, new-note folder, link format and path, deleted files), PDF embeds drawn as an image of the page (`#page=N`; macOS PDFKit, elsewhere `pdftoppm` when installed, else a file card), an audio tab and audio embeds that play in the app (ffplay sound, a waveform that seeks, a live spectrum from an own FFT), video embeds with a still, and in-note playback (ffmpeg decodes frames, ffplay the sound) when ffmpeg is installed, else Open in the system player, and a graph view with force sliders and colour groups that scales to 6.5k notes. Strict line breaks and show-hidden-files are settings too. Limits kept on purpose: video playback needs `ffmpeg` on the machine (no decoder is linked), no bundled spelling dictionary (the vault is the vocabulary). `crates/ui-egui/tests/driven_features.rs` drives tab drag, pinning, the link hover card, list folding, split view, hidden files, the regex find bar and Explorer drag-and-drop through real input events. - **Obsidian basics round 2 (specs/obsidian-basics/tasks.md 19-24): query embeds (19) and stacked tabs (21) built; editor follow-ups (23) built; pop-out windows (22) built but not seen in a native window; RTL (20) built for the reading view with `unicode-bidi` (owner approved; Arabic unjoined, Source mode logical order); round 3 (tasks 25-30, from the audit rows still missing or partial) started: note composer (`note.extract`, `note.merge`, the toolbar button and the Explorer "Merge into…") and trash `trash.list/restore/empty` and the Trash tab built, folder notes (27), reading mode and the indent settings (29), table helpers, user themes, the graph timelapse and Source-mode heading folding (30), workspaces (32), heading/block links (33) and note versions (31, local snapshots plus the git history) and Cmd/Ctrl+Enter on tasks in Source and Visual (36, 37) built; unbuilt: hand check of the packaged app and a Windows playback run. - **Linux and Windows feedback (2026-10-19):** built: the Explorer calendar folds (chevron, remembered, folded on a first run in a window under 720 pt), the Explorer toolbar is laid out by hand (filter box plus two pill buttons, fixed gaps, flush right), the empty Bookmarks section is gone, the menu bar words are flat text with a soft hover/open background, the Related tab scrolls, a new Outline pane (count, filter, folding, active heading, scroll), a performance overlay (`view.performance`: fps, frame ms, memory, CPU; `/proc`, `ps` or PowerShell, no new dependency), and a first-run UI scale taken from the desktop on Linux when the window was not told its scale (`QT_SCALE_FACTOR`, `GDK_SCALE`, `Xft.dpi`, GNOME text scaling). Not confirmed on those systems: the title bar blink (the bar is now drawn whole on every frame and sits above the cards) and Explorer icons drawn out of bounds on a fast scroll (not reproduced offscreen; rows are now clipped to the viewport). Windows paths (PowerShell sampler, menu bar) were not run. - **Editor (built, see phase 14):** spell-check needs an owner decision (new dependency or in-house checker); open: mobile bottom toolbar, inline fix popover, block menu by keyboard, embed with other text on one line, checkboxes inside embeds, embed refresh after editing another tab. diff --git a/apps/notescraft-sync-server/tests/publish.rs b/apps/notescraft-sync-server/tests/publish.rs index d455d8c..2609ca0 100644 --- a/apps/notescraft-sync-server/tests/publish.rs +++ b/apps/notescraft-sync-server/tests/publish.rs @@ -102,6 +102,30 @@ fn a_published_folder_serves_only_its_markdown_notes() { assert!(!index.contains("Hidden note"), "{index}"); } +#[test] +fn a_comment_sidecar_is_never_published() { + let server = Server::start_temp("publish-sidecar"); + let (vault, token, _) = server.owned_vault("sidecar"); + note(&server, &vault, &token, "docs/a.md", "# Note A\n"); + note(&server, &vault, &token, "docs/a.md.comments.json", "{\"version\":1,\"note\":\"docs/a.md\",\"comments\":[]}\n"); + let (status, reply) = + server.json("POST", &format!("/v1/vaults/{vault}/publications"), Some(&token), &[], Some(&json!({"target": "docs", "kind": "folder"}))); + assert_eq!(status, 200, "{reply}"); + let slug = reply["slug"].as_str().expect("slug").to_owned(); + + // The index of the folder lists the note, never the sidecar. + let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs"), None, &[], None); + assert_eq!(status, 200); + let index = text(&body); + assert!(index.contains("Note A"), "{index}"); + assert!(!index.contains("comments.json"), "{index}"); + + // And the sidecar itself answers 404 with an empty body. + let (status, _, body) = server.send_full("GET", &format!("/p/{slug}/docs/a.md.comments.json"), None, &[], None); + assert_eq!(status, 404); + assert!(body.is_empty(), "the sidecar must answer with an empty body"); +} + #[test] fn unpublishing_answers_404_at_once() { let server = Server::start_temp("publish-unpublish"); diff --git a/assets/icons/bell.svg b/assets/icons/bell.svg new file mode 100644 index 0000000..0345ca3 --- /dev/null +++ b/assets/icons/bell.svg @@ -0,0 +1,14 @@ + + + + diff --git a/crates/i18n/locales/en.toml b/crates/i18n/locales/en.toml index c2b1399..44b8c7d 100644 --- a/crates/i18n/locales/en.toml +++ b/crates/i18n/locales/en.toml @@ -1576,8 +1576,7 @@ redo = "Redo" clear = "Clear" clear-again = "Click again to clear" colour = "Colour" -label = "Label" -add-label = "Add" +text-size = "Text size" saved = "Saved to {{name}}" saved-plain = "Saved · the original picture is unchanged" saving = "Saving…" @@ -1655,6 +1654,7 @@ show-backlinks = "Show backlinks" show-assistant = "Show the assistant" show-lint = "Show lint" show-comments = "Show comments" +show-activity = "Show the activity" region-sidebar = "Sidebar" region-editor = "Editor" region-right = "Right panel" @@ -1689,3 +1689,47 @@ now = "just now" minutes = "{n}m ago" hours = "{n}h ago" days = "{n}d ago" + +[ui.activity-panel] +title = "Activity" +loading = "Loading the activity…" +empty = "Nothing happened yet" +unbound = "This vault has no server: there is no activity to show." +unread = "{n} unread" +mark-read = "Mark all as read" +now = "just now" +minutes = "{n}m ago" +hours = "{n}h ago" +days = "{n}d ago" +mention = "mentioned you" +comment = "commented on" +suggestion = "proposed a change to" +note-changed = "changed" +invite-accepted = "accepted your invite" +member-joined = "joined the vault" +role-changed = "changed a role" +thread-resolved = "resolved a thread" +other = "was active" + +[ui.audio-view] +spectrum = "Spectrum" +spectrum-tip = "Show or hide the frequency bars" +spectrum-label = "Frequency spectrum" +waveform = "Waveform" +waveform-failed = "The waveform of this file cannot be shown" +no-player = "ffplay is not installed, so this file cannot play here." +volume = "Volume {{percent}}%" + +[ui.source-file] +too-large = "This file is over 2 MiB: shown read-only." +plain = "Plain text" +chip = "Language: {{language}}" +banner = "No syntax highlighting found for {{type}}" +install = "Install a grammar" +other = "Use another language" +ignore = "Ignore" +search = "Search languages" +no-match = "No language matches" +avif-reason = "AVIF pictures need a native decoder this app does not ship." +copy-hint = "Click to copy" +kind-image = "Image" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 108e4e6..7bab104 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,8 +18,8 @@ craft-ui = { workspace = true } egui = { workspace = true } egui_extras = { workspace = true, features = ["image"] } arboard = { workspace = true } -# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp; owner asked for jpeg and the other formats on 2026-10-21). -image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } +# Encodes a pasted clipboard image to PNG and decodes the picture formats the lightbox opens (png, jpeg, gif, webp, bmp, ico; owner asked for jpeg and the other formats on 2026-10-21). +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp", "ico"] } log = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/ui-egui/src/design/fonts/mod.rs b/crates/ui-egui/src/design/fonts/mod.rs index 9ed1ed2..efd0964 100644 --- a/crates/ui-egui/src/design/fonts/mod.rs +++ b/crates/ui-egui/src/design/fonts/mod.rs @@ -78,12 +78,14 @@ pub fn install(ctx: &egui::Context) { } let proportional = fonts.families.entry(FontFamily::Proportional).or_default(); proportional.insert(0, "inter".to_owned()); + let mut fallback_names = Vec::new(); for (index, bytes) in system_fallbacks().into_iter().enumerate() { let name = format!("system-fallback-{index}"); fonts.font_data.insert(name.clone(), Arc::new(FontData::from_owned(bytes))); if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { - chain.push(name); + chain.push(name.clone()); } + fallback_names.push(name); } // STIX Two Math is the last resort of every text family, so symbols, arrows and math operators never show boxes. if let Some(chain) = fonts.families.get_mut(&FontFamily::Proportional) { @@ -91,6 +93,8 @@ pub fn install(ctx: &egui::Context) { } let monospace = fonts.families.entry(FontFamily::Monospace).or_default(); monospace.insert(0, "jetbrains-mono".to_owned()); + // Hebrew and Arabic inside code (strings, comments) must not show boxes either. + monospace.extend(fallback_names); monospace.push(MATH.to_owned()); for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] { let mut chain = vec![face.to_owned(), "inter".to_owned()]; diff --git a/crates/ui-egui/src/design/icons.rs b/crates/ui-egui/src/design/icons.rs index 0264ec4..2ad7b3b 100644 --- a/crates/ui-egui/src/design/icons.rs +++ b/crates/ui-egui/src/design/icons.rs @@ -36,6 +36,7 @@ static ICONS: &[(&str, &[u8])] = &[ icon!("agents/custom"), icon!("agents/plug"), icon!("arrow-up"), + icon!("bell"), ("flip-horizontal", br#""#), ("flip-vertical", br#""#), ("file-down", br#""#), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index ccbb697..96a6b42 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -18,6 +18,7 @@ pub use design::motion; pub use design::sounds; pub use design::style; pub use design::theme; +pub use panels::activity_panel; pub use panels::assistant_panel; pub use panels::backlinks_panel; pub use panels::bookmarks_view; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b5b1164..d3bfd4f 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -4,7 +4,7 @@ pub mod assistant_panel; pub mod bookmarks_view; pub mod buffers_panel; pub mod right; -pub use right::{backlinks_panel, comment_panel, outline}; +pub use right::{activity_panel, backlinks_panel, comment_panel, outline}; pub mod lint_panel; pub mod related_panel; pub mod right_view; diff --git a/crates/ui-egui/src/panels/right/activity_panel.rs b/crates/ui-egui/src/panels/right/activity_panel.rs new file mode 100644 index 0000000..2e80fb7 --- /dev/null +++ b/crates/ui-egui/src/panels/right/activity_panel.rs @@ -0,0 +1,350 @@ +//! The Activity tab of the right card: the feed of the vault. +//! +//! Pure widget: the shell reads the feed with the `activity.list` command and +//! hands it over as [`ActivityData`]; what the user pressed comes back as +//! [`ActivityResponse`]. No command runs while the panel paints. + +use egui::{Layout, RichText, Sense, Ui}; +use serde_json::Value; + +use crate::text::review::activity_panel as strings; +use crate::theme::{Tokens, color}; + +/// Text size of the small parts, in points. +const SMALL: f32 = 12.0; +/// Text size of the body of an event, in points. +const BODY: f32 = 13.0; +/// Corner radius of the cards, in points. +const RADIUS: f32 = 12.0; +/// Inner margin of a card, in points. +const CARD_MARGIN: i8 = 12; +/// Most characters of a note path shown in a row. +const PATH_CHARS: usize = 80; +/// Side of the dot that marks an unread event, in points. +const DOT: f32 = 8.0; +/// Height of the row that holds the buttons, in points. +const ROW_HEIGHT: f32 = 28.0; + +/// One event of the feed, as the server lists it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActivityEvent { + /// The id of the event; the cursor of the feed. + pub id: u64, + /// When the server stored the event, in milliseconds. + pub ts_ms: u64, + /// The kind of the event. + pub kind: String, + /// The user id of the actor. + pub actor: String, + /// The display name of the actor. + pub actor_name: String, + /// The note the event is about. + pub note: Option, + /// The comment thread the event is about. + pub thread_id: Option, + /// The line of the note the event is about, one-based. + pub line: Option, + /// Whether the member has read the event. + pub read: bool, +} + +/// The feed of the answer of `activity.list`. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActivityFeed { + /// The events, newest first. + pub events: Vec, + /// How many of them the member has not read. + pub unread: u64, +} + +/// What the tab shows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActivityData { + /// The events of the feed, newest first. + pub events: Vec, + /// How many events the member has not read. + pub unread: u64, + /// The feed has been read at least once. + pub loaded: bool, + /// The vault has no server: there is no feed to show. + pub unbound: bool, +} + +/// The note an event points at. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ActivityTarget { + /// The path of the note, vault-relative. + pub path: String, + /// The line of the note, one-based, when the event carries one. + pub line: Option, +} + +/// What the user did in the tab. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ActivityResponse { + /// An event was clicked: open its note, at its line when it has one. + pub open: Option, + /// The events to mark read; an empty list means every event of the member. + pub mark_read: Option>, +} + +/// The feed of the answer of `activity.list`: the events newest first and the +/// unread count. +/// +/// The server lists the events oldest first; the feed shows them the other way +/// round, so the newest event is the first row. +#[must_use] +pub fn parse_feed(result: &Value) -> ActivityFeed { + let mut events: Vec = + result.get("events").and_then(Value::as_array).map_or_else(Vec::new, |items| items.iter().filter_map(parse_event).collect()); + events.sort_by_key(|event| std::cmp::Reverse(event.id)); + let unread = result.get("unread").and_then(Value::as_u64).unwrap_or(0); + ActivityFeed { events, unread } +} + +/// One event of the answer of `activity.list`. +fn parse_event(value: &Value) -> Option { + let id = value.get("id").and_then(Value::as_u64)?; + Some(ActivityEvent { + id, + ts_ms: value.get("ts_ms").and_then(Value::as_u64).unwrap_or(0), + kind: value.get("kind").and_then(Value::as_str).unwrap_or_default().to_owned(), + actor: value.get("actor").and_then(Value::as_str).unwrap_or_default().to_owned(), + actor_name: value.get("actor_name").and_then(Value::as_str).unwrap_or_default().to_owned(), + note: value.get("note").and_then(Value::as_str).map(str::to_owned), + thread_id: value.get("thread_id").and_then(Value::as_str).map(str::to_owned), + line: value.get("line").and_then(Value::as_u64), + read: value.get("read").and_then(Value::as_bool).unwrap_or(false), + }) +} + +/// The copy of a kind of event; an unknown kind stays plain. +#[must_use] +pub fn kind_action(kind: &str) -> &'static str { + match kind { + "mention" => strings::mention(), + "comment" => strings::comment(), + "suggestion" => strings::suggestion(), + "note_changed" => strings::note_changed(), + "invite_accepted" => strings::invite_accepted(), + "member_joined" => strings::member_joined(), + "role_changed" => strings::role_changed(), + "thread_resolved" => strings::thread_resolved(), + _ => strings::other(), + } +} + +/// The name of the actor, or its id when the server knows no name. +#[must_use] +pub fn actor_name(event: &ActivityEvent) -> &str { + if event.actor_name.is_empty() { &event.actor } else { &event.actor_name } +} + +/// The note of an event, with its line when it has one. +#[must_use] +pub fn note_label(path: &str, line: Option) -> String { + let mut out = excerpt(path, PATH_CHARS); + if let Some(line) = line { + out.push(':'); + out.push_str(&line.to_string()); + } + out +} + +/// The first `max` characters of a text, with an ellipsis when it was cut. +#[must_use] +pub fn excerpt(text: &str, max: usize) -> String { + let mut out: String = text.chars().take(max).collect(); + if text.chars().nth(max).is_some() { + out.push('…'); + } + out +} + +/// The time of an event, as a short relative text. +#[must_use] +pub fn relative_time(ts_ms: u64, now_ms: u64) -> String { + let minutes = now_ms.saturating_sub(ts_ms) / 60_000; + if minutes == 0 { + return strings::now().to_owned(); + } + if minutes < 60 { + return strings::minutes().replace("{n}", &minutes.to_string()); + } + let hours = minutes / 60; + if hours < 24 { + return strings::hours().replace("{n}", &hours.to_string()); + } + strings::days().replace("{n}", &(hours / 24).to_string()) +} + +/// The clock now, in milliseconds since the epoch. +fn now_ms() -> u64 { + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |since| u64::try_from(since.as_millis()).unwrap_or(u64::MAX)) +} + +/// Shows the feed of the vault, newest first. What the user pressed comes back +/// in the answer. +pub fn show(ui: &mut Ui, tokens: &Tokens, data: &ActivityData) -> ActivityResponse { + let mut response = ActivityResponse::default(); + if !data.loaded { + muted_line(ui, tokens, strings::loading()); + return response; + } + if data.unbound { + muted_line(ui, tokens, strings::unbound()); + return response; + } + summary(ui, tokens, data, &mut response); + if data.events.is_empty() { + muted_line(ui, tokens, strings::empty()); + return response; + } + egui::ScrollArea::vertical().id_salt("notescraft-activity-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 8.0; + for event in &data.events { + row(ui, tokens, event, &mut response); + } + }); + response +} + +/// The row above the feed: the unread count and the button that marks it read. +fn summary(ui: &mut Ui, tokens: &Tokens, data: &ActivityData, response: &mut ActivityResponse) { + let colors = &tokens.theme.colors; + ui.horizontal(|ui| { + if data.unread > 0 { + ui.label( + RichText::new(strings::unread().replace("{n}", &data.unread.to_string())) + .size(SMALL) + .family(crate::fonts::semibold()) + .color(color(colors.accent)), + ); + } + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + if data.unread > 0 { + let height = crate::style::compact::target(ui.ctx(), ROW_HEIGHT); + let button = ui.add(egui::Button::new(RichText::new(strings::mark_read()).size(SMALL)).min_size(egui::vec2(0.0, height))); + if button.clicked() { + response.mark_read = Some(data.events.iter().filter(|event| !event.read).map(|event| event.id).collect()); + } + } + }); + }); +} + +/// One event: the actor, the time, the action and the note it is about. +fn row(ui: &mut Ui, tokens: &Tokens, event: &ActivityEvent, response: &mut ActivityResponse) { + let colors = &tokens.theme.colors; + egui::Frame::new().fill(color(colors.surface_2)).corner_radius(RADIUS).inner_margin(egui::Margin::symmetric(CARD_MARGIN, 10)).show(ui, |ui| { + ui.set_min_width(ui.available_width()); + row_header(ui, tokens, event); + ui.add(egui::Label::new(RichText::new(kind_action(&event.kind)).size(BODY).color(color(colors.text))).wrap()); + if let Some(path) = &event.note { + let label = note_label(path, event.line); + let height = crate::style::compact::target(ui.ctx(), ROW_HEIGHT); + let button = ui.add(egui::Button::new(RichText::new(label).size(SMALL).color(color(colors.link))).frame(false).min_size(egui::vec2(0.0, height))); + if button.clicked() { + response.open = Some(ActivityTarget { path: path.clone(), line: event.line }); + } + } + }); +} + +/// The actor, the time and the dot of an unread event. +fn row_header(ui: &mut Ui, tokens: &Tokens, event: &ActivityEvent) { + let colors = &tokens.theme.colors; + ui.horizontal(|ui| { + ui.label(RichText::new(actor_name(event)).size(SMALL).family(crate::fonts::semibold()).color(color(colors.text))); + ui.label(RichText::new(relative_time(event.ts_ms, now_ms())).size(SMALL).color(color(colors.text_muted))); + ui.with_layout(Layout::right_to_left(egui::Align::Center), |ui| { + if !event.read { + let (rect, _) = ui.allocate_exact_size(egui::vec2(DOT, DOT), Sense::hover()); + ui.painter().circle_filled(rect.center(), DOT / 2.0, color(colors.accent)); + } + }); + }); +} + +/// One muted line of text. +fn muted_line(ui: &mut Ui, tokens: &Tokens, text: &str) { + ui.label(RichText::new(text).size(SMALL).color(color(tokens.theme.colors.text_muted))); +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + /// The answer of `activity.list` with two events. + fn answer() -> Value { + json!({ + "events": [ + { "id": 1, "ts_ms": 10, "kind": "mention", "actor": "u-ana", "actor_name": "Ana", + "note": "a.md", "thread_id": "c-1", "line": 3, "read": false }, + { "id": 2, "ts_ms": 20, "kind": "comment", "actor": "u-bob", "actor_name": "Bob", + "note": "b.md", "read": true } + ], + "head": 2, + "more": false, + "unread": 1 + }) + } + + #[test] + fn the_feed_comes_newest_first() { + let feed = parse_feed(&answer()); + assert_eq!(feed.events.iter().map(|event| event.id).collect::>(), [2, 1]); + assert_eq!(feed.unread, 1); + assert_eq!(feed.events[1].line, Some(3)); + assert_eq!(feed.events[1].thread_id.as_deref(), Some("c-1")); + assert!(!feed.events[1].read); + assert!(feed.events[0].read); + } + + #[test] + fn an_answer_without_events_is_empty() { + assert_eq!(parse_feed(&json!({})).events, Vec::new()); + assert_eq!(parse_feed(&json!({ "events": "no" })).events, Vec::new()); + assert_eq!(parse_feed(&json!({ "events": [1] })).events, Vec::new(), "an event without an id is skipped"); + } + + #[test] + fn every_kind_has_its_own_copy() { + assert_eq!(kind_action("mention"), "mentioned you"); + assert_eq!(kind_action("comment"), "commented on"); + assert_eq!(kind_action("suggestion"), "proposed a change to"); + assert_eq!(kind_action("note_changed"), "changed"); + assert_eq!(kind_action("invite_accepted"), "accepted your invite"); + assert_eq!(kind_action("member_joined"), "joined the vault"); + assert_eq!(kind_action("role_changed"), "changed a role"); + assert_eq!(kind_action("thread_resolved"), "resolved a thread"); + assert_eq!(kind_action("whatever"), "was active"); + } + + #[test] + fn an_actor_without_a_name_shows_its_id() { + let event = ActivityEvent { actor: "u-ana".to_owned(), ..ActivityEvent::default() }; + assert_eq!(actor_name(&event), "u-ana"); + let named = ActivityEvent { actor: "u-ana".to_owned(), actor_name: "Ana".to_owned(), ..ActivityEvent::default() }; + assert_eq!(actor_name(&named), "Ana"); + } + + #[test] + fn a_note_shows_its_line() { + assert_eq!(note_label("notes/a.md", None), "notes/a.md"); + assert_eq!(note_label("notes/a.md", Some(12)), "notes/a.md:12"); + let long = "a".repeat(100); + assert_eq!(note_label(&long, Some(1)), format!("{}…:1", "a".repeat(80))); + } + + #[test] + fn times_are_relative_and_never_negative() { + assert_eq!(relative_time(0, 0), "just now"); + assert_eq!(relative_time(0, 30_000), "just now"); + assert_eq!(relative_time(0, 5 * 60_000), "5m ago"); + assert_eq!(relative_time(0, 3 * 3_600_000), "3h ago"); + assert_eq!(relative_time(0, 2 * 86_400_000), "2d ago"); + assert_eq!(relative_time(10_000, 0), "just now", "a clock that went back does not go negative"); + } +} diff --git a/crates/ui-egui/src/panels/right/mod.rs b/crates/ui-egui/src/panels/right/mod.rs index 29102a8..3cb8300 100644 --- a/crates/ui-egui/src/panels/right/mod.rs +++ b/crates/ui-egui/src/panels/right/mod.rs @@ -1,5 +1,6 @@ //! Panels of the right card that only list data: backlinks, the outline and the comments. +pub mod activity_panel; pub mod backlinks_panel; pub mod comment_panel; pub mod outline; diff --git a/crates/ui-egui/src/panels/right_view.rs b/crates/ui-egui/src/panels/right_view.rs index 646dcb0..acf13aa 100644 --- a/crates/ui-egui/src/panels/right_view.rs +++ b/crates/ui-egui/src/panels/right_view.rs @@ -20,10 +20,20 @@ const SOURCE_CACHE_CAP: usize = 512; const COLLAPSE_SIZE: f32 = 28.0; const ICON: f32 = 16.0; const TAB_PAD: f32 = 10.0; +/// The least padding either side of a tab icon when the tabs get tight, in points. +const TAB_PAD_MIN: f32 = 4.0; const TAB_GAP: f32 = 8.0; const TAB_SPACING: f32 = 4.0; const TAB_HEIGHT: f32 = 36.0; const TAB_RADIUS: u8 = 12; +/// Height of the unread badge on the Activity tab, in points. +const BADGE_HEIGHT: f32 = 16.0; +/// Width of the unread badge on the Activity tab, in points. +const BADGE_WIDTH: f32 = 18.0; +/// Gap between the icon of the Activity tab and its badge, in points. +const BADGE_GAP: f32 = 4.0; +/// The largest count the badge shows before it shortens. +const BADGE_MAX: u64 = 99; /// The prepared data of the right card for the active note. #[derive(Debug, Default)] @@ -38,6 +48,8 @@ pub struct RightData { pub lint: crate::lint_panel::LintData, /// The comment threads of the active note, filled by the shell. pub comments: crate::comment_panel::CommentData, + /// The feed of the vault, filled by the shell. + pub activity: crate::activity_panel::ActivityData, } /// Reads note bodies through the session for the backlinks scan, caching per scan. @@ -96,6 +108,7 @@ pub fn data_for(session: &Session, active_path: Option<&str>, docs: &BTreeMap { pub lint: &'a crate::lint_panel::LintData, /// The comment threads of the active note. pub comments: &'a crate::comment_panel::CommentData, + /// The feed of the vault. + pub activity: &'a crate::activity_panel::ActivityData, } /// What the user did in the right card. @@ -160,6 +177,8 @@ pub struct RightResponse { pub lint: crate::lint_panel::LintResponse, /// What was pressed in the Comments tab. pub comments: crate::comment_panel::CommentResponse, + /// What was pressed in the Activity tab. + pub activity: crate::activity_panel::ActivityResponse, } /// Draws the tab strip and the tab's content, reporting the user's actions. @@ -178,7 +197,7 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu if collapse { ui.ctx().request_repaint(); } - tab_strip(ui, tokens, tab); + tab_strip(ui, tokens, tab, inputs.activity.unread); let mut response = RightResponse { collapse, ..RightResponse::default() }; ui.add_space(4.0); match tab { @@ -204,33 +223,41 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu } } RightTab::Comments => response.comments = crate::comment_panel::show(ui, tokens, inputs.comments), + RightTab::Activity => response.activity = crate::activity_panel::show(ui, tokens, inputs.activity), RightTab::Assistant => {} } response } /// The tab pills: the active tab shows icon and label, the others only the icon. -fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) { +/// The Activity tab carries the unread count of the feed. +fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, unread: u64) { let c = &tokens.theme.colors; let font = egui::FontId::new(14.0, crate::fonts::semibold()); let mut x = ui.cursor().left(); let top = ui.cursor().top(); - // The active label gives way before the tabs reach the collapse button. - let icons_only = 6.0 * (TAB_PAD * 2.0 + ICON) + 5.0 * TAB_SPACING; - let label_room = (ui.available_width() - COLLAPSE_SIZE - crate::theme::PANEL_GAP - icons_only - TAB_GAP).max(0.0); + // The tabs never reach the collapse button: the pills shrink first, then the active label gives way. + let unread_extra = if unread > 0 { BADGE_WIDTH + BADGE_GAP } else { 0.0 }; + let room = ui.available_width() - COLLAPSE_SIZE - crate::theme::PANEL_GAP - unread_extra; + let pad = (((room - 6.0 * TAB_SPACING) / 7.0 - ICON) / 2.0).clamp(TAB_PAD_MIN, TAB_PAD); + let icons_only = 7.0 * (pad * 2.0 + ICON) + 6.0 * TAB_SPACING; + let label_room = (room - icons_only - TAB_GAP).max(0.0); for (candidate, icon, label, hotkey) in [ (RightTab::Backlinks, "link", strings::backlinks(), "ui.show_backlinks"), (RightTab::Related, "sparkles", crate::text::knowledge_related::related(), "ui.show_related"), (RightTab::Lint, "list-checks", crate::text::lint_panel::lint(), "ui.show_lint"), (RightTab::Outline, "list", strings::outline(), "ui.show_outline"), (RightTab::Comments, "message-square", crate::text::review::comment_panel::title(), "ui.show_comments"), + (RightTab::Activity, "bell", crate::text::review::activity_panel::title(), "ui.show_activity"), (RightTab::Assistant, "bot", strings::assistant(), "ui.show_assistant"), ] { let active = *tab == candidate; let tint = if active { color(c.text) } else { color(c.text_muted) }; let text = ui.painter().layout_no_wrap(label.to_owned(), font.clone(), tint); let label_width = text.size().x.min(label_room); - let width = TAB_PAD * 2.0 + ICON + if active { TAB_GAP + label_width } else { 0.0 }; + let unread_badge = (candidate == RightTab::Activity).then_some(unread).filter(|count| *count > 0); + let badge_width = if unread_badge.is_some() { BADGE_WIDTH + BADGE_GAP } else { 0.0 }; + let width = pad * 2.0 + ICON + badge_width + if active { TAB_GAP + label_width } else { 0.0 }; let rect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(width, TAB_HEIGHT)); let hit = crate::hotkeys::on_hover_hotkey(ui.interact(rect, egui::Id::new(("notescraft-right-tab", label)), egui::Sense::click()), label, hotkey); let radius = egui::CornerRadius::same(TAB_RADIUS); @@ -245,10 +272,21 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) { let glow = crate::motion::hover(ui.ctx(), ("right-tab-hover", label), &hit); ui.painter().rect_filled(rect, radius, color(c.selection).gamma_multiply(glow)); } - let icon_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + TAB_PAD, rect.center().y - ICON / 2.0), egui::Vec2::splat(ICON)); + let icon_rect = egui::Rect::from_min_size(egui::pos2(rect.left() + pad, rect.center().y - ICON / 2.0), egui::Vec2::splat(ICON)); crate::icons::paint(ui, icon, icon_rect, tint); + let after_badge = match unread_badge { + Some(count) => { + let badge_rect = egui::Rect::from_min_size( + egui::pos2(icon_rect.right() + BADGE_GAP, rect.center().y - BADGE_HEIGHT / 2.0), + egui::vec2(BADGE_WIDTH, BADGE_HEIGHT), + ); + draw_unread_badge(ui, tokens, count, badge_rect); + badge_rect.right() + } + None => icon_rect.right(), + }; if active { - let clip = egui::Rect::from_min_max(egui::pos2(icon_rect.right() + TAB_GAP, rect.top()), rect.right_bottom()); + let clip = egui::Rect::from_min_max(egui::pos2(after_badge + TAB_GAP, rect.top()), rect.right_bottom()); ui.painter().with_clip_rect(clip).galley(egui::pos2(clip.left(), rect.center().y - text.size().y / 2.0), text, tint); } if hit.clicked() { @@ -259,6 +297,22 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) { ui.allocate_space(egui::vec2(ui.available_width(), TAB_HEIGHT)); } +/// The unread count on the Activity tab: a pill with the number of events. +fn draw_unread_badge(ui: &mut Ui, tokens: &Tokens, count: u64, rect: egui::Rect) { + let c = &tokens.theme.colors; + let text = badge_label(count); + let mut layout = egui::Layout::top_down(egui::Align::Center); + layout.main_align = egui::Align::Center; + let mut badge = ui.new_child(egui::UiBuilder::new().id_salt("notescraft-activity-badge").max_rect(rect).layout(layout)); + badge.painter().rect_filled(rect, egui::CornerRadius::same(6), color(c.accent)); + badge.label(egui::RichText::new(text).size(10.0).family(crate::fonts::semibold()).color(color(c.on_accent))); +} + +/// The number of the badge; past a hundred it shortens. +fn badge_label(count: u64) -> String { + if count > BADGE_MAX { format!("{BADGE_MAX}+") } else { count.to_string() } +} + #[cfg(test)] mod partial_tests { use super::*; diff --git a/crates/ui-egui/src/panels/tree.rs b/crates/ui-egui/src/panels/tree.rs index b62352b..8769d59 100644 --- a/crates/ui-egui/src/panels/tree.rs +++ b/crates/ui-egui/src/panels/tree.rs @@ -5,6 +5,9 @@ //! indentation per level, a rotating chevron per folder, hover and selected states; collapse //! state is a `BTreeSet` of folder paths owned by the caller (see [`collapse_all`] //! for the "folders start collapsed" default). +//! +//! The sidecar that holds the comments of a note ([`is_comment_sidecar`]) belongs to its +//! note, not to the user: it is never a row of the tree. use std::collections::{BTreeSet, HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; @@ -25,6 +28,7 @@ pub use crumbs::crumbs; pub use filter::{cached_filtered, filtered}; pub use hscroll::{MAX_CONTENT_WIDTH, content_width, row_width, wheel_to_horizontal}; pub use name_tip::{DELAY_SECS as TIP_DELAY_SECS, name_tip}; +pub use notescraft_index::is_comment_sidecar; pub use rows::{FlatRow, OVERSCAN_ROWS, cached_rows, first_visible_row, flatten, row_top, row_window}; /// Row height, in points. @@ -677,7 +681,9 @@ fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTre ); // The trash button sits at the right edge of the row and appears while the row is hovered. // It takes the note count's place: the two would overlap. - let show_trash = response.hovered() && writable; + // `contains_pointer`, not `hovered`: pressing the trash makes it the pressed widget and the row stops being hovered, + // and a trash that vanished on press could never be released on. + let show_trash = response.contains_pointer() && writable; if node.folder && node.notes > 0 && !show_trash { let badge = egui::FontId::proportional(crate::style::TEXT_TINY); ui.painter().text(Pos2::new(slot_rect.right() - ROW_PADDING, rect.center().y), Align2::RIGHT_CENTER, node.notes.to_string(), badge, muted); diff --git a/crates/ui-egui/src/panels/tree/hscroll.rs b/crates/ui-egui/src/panels/tree/hscroll.rs index 6a7037e..fabd421 100644 --- a/crates/ui-egui/src/panels/tree/hscroll.rs +++ b/crates/ui-egui/src/panels/tree/hscroll.rs @@ -105,7 +105,7 @@ mod tests { let flat = row_width(0, 100.0, 0.0); assert!(row_width(2, 100.0, 0.0) > flat + 2.0 * INDENT - 0.01, "each level adds an indent"); assert!(row_width(0, 100.0, 40.0) > flat, "a wide count adds room"); - assert!(row_width(0, 100.0, 5.0) - flat < f32::EPSILON, "a narrow count still keeps the trash room"); + assert!((row_width(0, 100.0, 5.0) - flat).abs() < f32::EPSILON, "a narrow count still keeps the trash room"); assert!(flat > 100.0 + 2.0 * (ICON_SIZE + ICON_GAP), "the label is never overlapped by the icons"); } diff --git a/crates/ui-egui/src/panels/tree/scroll_tests.rs b/crates/ui-egui/src/panels/tree/scroll_tests.rs index cc7c875..67bd5c7 100644 --- a/crates/ui-egui/src/panels/tree/scroll_tests.rs +++ b/crates/ui-egui/src/panels/tree/scroll_tests.rs @@ -8,7 +8,7 @@ use egui::{Event, Modifiers, MouseWheelUnit, TouchPhase, Vec2, pos2, vec2}; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; -use super::{TIP_DELAY_SECS, TreeModel, show}; +use super::{TreeModel, show}; use crate::testsupport; use crate::theme::Tokens; @@ -69,7 +69,8 @@ fn hovering_a_row_shows_its_full_name_and_path_after_the_delay() { harness.hover_at(pos2(60.0, 24.0)); harness.step(); assert!(harness.query_by_label(LONG_NAME).is_none(), "not before the delay"); - let steps = (TIP_DELAY_SECS * 4.0).ceil() as usize + 3; + // Frames of 0.25 s: enough to pass the tooltip delay with room to spare. + let steps: usize = 8; harness.run_steps(steps); assert!(harness.query_by_label(LONG_NAME).is_some(), "the full name shows"); assert!(harness.query_by_label(&format!("{LONG_NAME}.md")).is_some(), "the path shows below it"); diff --git a/crates/ui-egui/src/panels/tree_tests.rs b/crates/ui-egui/src/panels/tree_tests.rs index 02f1431..530edca 100644 --- a/crates/ui-egui/src/panels/tree_tests.rs +++ b/crates/ui-egui/src/panels/tree_tests.rs @@ -189,8 +189,8 @@ fn the_hover_trash_button_asks_for_the_row_it_sits_on() { frame(Vec::new()); // The trash sits at the right edge of the hovered row: sweep the edge until it is hit. let y = ROW_HEIGHT / 2.0; - for step in 0..48 { - let pos = egui::pos2(width - 2.0 - step as f32, y); + for step in 0..48_u16 { + let pos = egui::pos2(width - 2.0 - f32::from(step), y); frame(vec![egui::Event::PointerMoved(pos)]); frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]); let response = frame(vec![egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: egui::Modifiers::NONE }]); diff --git a/crates/ui-egui/src/shell/app.rs b/crates/ui-egui/src/shell/app.rs index 252c2ab..bd09a63 100644 --- a/crates/ui-egui/src/shell/app.rs +++ b/crates/ui-egui/src/shell/app.rs @@ -157,6 +157,8 @@ pub struct NotesApp { pub(crate) dev_hook: Option, /// The video embed that plays. pub(crate) video: video::VideoPlayer, + /// The audio file that plays (tab or embed). + pub(crate) audio: crate::media_view::audio::player::AudioPlayer, pub(crate) switch_jump: Option<(String, String, String)>, /// The note the next quick-switcher pick is merged into (set by "Merge into…"). pub(crate) merge_from: Option, @@ -296,6 +298,7 @@ impl NotesApp { player: player::Player::default(), dev_hook: dev_hook::DevHook::from_env(), video: video::VideoPlayer::default(), + audio: crate::media_view::audio::player::AudioPlayer::default(), link_hover: None, batch_review: None, grammar_dialog: None, diff --git a/crates/ui-egui/src/shell/app/editor/editor_tools_tests.rs b/crates/ui-egui/src/shell/app/editor/editor_tools_tests.rs index 19cee29..3e5ee66 100644 --- a/crates/ui-egui/src/shell/app/editor/editor_tools_tests.rs +++ b/crates/ui-egui/src/shell/app/editor/editor_tools_tests.rs @@ -158,6 +158,9 @@ fn typed_actions_dispatch_every_type_and_rename_remove() { app.handle_properties("note.md", vec![PropertiesAction::Rename { from: "text".into(), to: "title".into() }], 1.1, &ctx); assert!(app.active_text().is_some_and(|text| text.contains("title: value"))); app.handle_properties("note.md", vec![PropertiesAction::Remove { key: "title".into() }], 1.2, &ctx); + assert!(app.active_text().is_some_and(|text| text.contains("title:")), "removing asks first"); + assert!(app.menus.confirm.is_some()); + app.remove_property("note.md", "title", 1.2, &ctx); assert!(!app.active_text().is_some_and(|text| text.contains("title:"))); assert!(app.queued_cues().contains(&crate::sounds::Cue::Close)); } diff --git a/crates/ui-egui/src/shell/app/vault/explorer.rs b/crates/ui-egui/src/shell/app/vault/explorer.rs index 16368e7..b2d476a 100644 --- a/crates/ui-egui/src/shell/app/vault/explorer.rs +++ b/crates/ui-egui/src/shell/app/vault/explorer.rs @@ -170,6 +170,9 @@ impl NotesApp { self.split_tick(); self.player_tick(ctx); self.video_tick(ctx); + if let Some(notice) = self.audio.tick(ctx, self.session.vault().root()) { + self.toast = Some(notice.to_owned()); + } if self.tree.sort != self.state.explorer_sort { self.tree.resort(self.state.explorer_sort); } diff --git a/crates/ui-egui/src/shell/app/vault/indexing.rs b/crates/ui-egui/src/shell/app/vault/indexing.rs index f41c813..e30c1f0 100644 --- a/crates/ui-egui/src/shell/app/vault/indexing.rs +++ b/crates/ui-egui/src/shell/app/vault/indexing.rs @@ -17,8 +17,9 @@ const STAMP_BUCKETS: usize = 20; /// The explorer tree for `session` from its file listing, which exists before any note is parsed. /// Notes, canvases, whiteboards and diagrams always show; every other file shows while -/// `files.show_all_types` is on, minus the extensions in `files.hidden_extensions` and the -/// annotation sidecars of images that exist (unless `files.show_annotation_files`). Empty folders are kept. +/// `files.show_all_types` is on, minus the extensions in `files.hidden_extensions`, the +/// annotation sidecars of images that exist (unless `files.show_annotation_files`) and the +/// sidecar that holds the comments of a note. Empty folders are kept. /// /// # Errors /// @@ -36,6 +37,10 @@ pub(crate) fn tree_from_listing(session: ¬escraft_engine::Session, sort: crat /// Whether the Explorer lists the file `path` under the given settings; `present` holds every listed path. fn shows_in_explorer(path: &str, files: ¬escraft_engine::FilesConfig, present: &std::collections::HashSet<&str>) -> bool { use crate::tabs::TabKind; + if crate::tree::is_comment_sidecar(path) { + // The threads of a note belong to the note: the file that holds them never shows. + return false; + } if matches!(TabKind::of(path), TabKind::Note | TabKind::Canvas | TabKind::Whiteboard | TabKind::Diagram) { return true; } @@ -137,6 +142,14 @@ mod tests { assert_eq!(names, ["sub", "a", "board.canvas", "pic.png"], "every file type shows by default"); } + #[test] + fn the_tree_holds_no_comment_sidecar() { + let Some((app, _root)) = app_over("lazy-sidecar", &[("a.md", "x"), ("a.md.comments.json", "{}")]) else { return }; + let tree = tree_from_listing(&app.session, crate::tree::Sort::default()).expect("the tree of the vault"); + let names: Vec<&str> = tree.roots.iter().map(|node| node.name.as_str()).collect(); + assert_eq!(names, ["a"], "the sidecar of a note is not a row of the tree"); + } + #[test] fn each_frame_steps_and_repaints_only_while_work_remains() { let Some((mut app, _root)) = app_over_many("lazy-frames", 40) else { @@ -219,4 +232,14 @@ mod explorer_filter_tests { assert!(!shows_in_explorer("p.png", &config, &present)); assert!(shows_in_explorer("note.md", &config, &present)); } + + #[test] + fn the_sidecar_of_a_note_never_shows() { + let present: HashSet<&str> = ["note.md", "note.md.comments.json"].into_iter().collect(); + let mut config = files(); + assert!(shows_in_explorer("note.md", &config, &present)); + assert!(!shows_in_explorer("note.md.comments.json", &config, &present), "the threads of a note belong to the note"); + config.show_all_types = true; + assert!(!shows_in_explorer("note.md.comments.json", &config, &present), "no setting reveals it"); + } } diff --git a/crates/ui-egui/src/shell/app/vault/lifecycle.rs b/crates/ui-egui/src/shell/app/vault/lifecycle.rs index 77edf96..27bd33a 100644 --- a/crates/ui-egui/src/shell/app/vault/lifecycle.rs +++ b/crates/ui-egui/src/shell/app/vault/lifecycle.rs @@ -110,7 +110,8 @@ impl NotesApp { }; let Some(text) = loaded else { return }; crate::fonts::note_text(&text); - let doc = notescraft_markdown::parse(&text); + // Source files are not Markdown: parsing a multi-megabyte script as a note would only freeze the open. + let doc = notescraft_markdown::parse(if matches!(TabKind::of(&path), TabKind::Code | TabKind::Text) { "" } else { &text }); self.texts.clear(); self.docs.clear(); let _ = self.texts.insert(path.clone(), text.clone()); diff --git a/crates/ui-egui/src/shell/app/vault/preferences.rs b/crates/ui-egui/src/shell/app/vault/preferences.rs index 5f6e528..7d0fda7 100644 --- a/crates/ui-egui/src/shell/app/vault/preferences.rs +++ b/crates/ui-egui/src/shell/app/vault/preferences.rs @@ -168,6 +168,7 @@ impl NotesApp { if dismissed != self.state.dismissed_json { self.state.dismissed_json = dismissed; } + self.tick_languages(ctx); let current = self.state.to_json(); if current != self.preferences.observed { self.preferences.observed = current; @@ -181,6 +182,18 @@ impl NotesApp { } } } + + /// Hands the source-file tabs the saved language choices and saves what the user chose or ignored there. + fn tick_languages(&mut self, ctx: &egui::Context) { + use crate::code_view::language::{self, Prefs}; + let mut prefs = Prefs { chosen: self.state.file_languages.clone(), ignored: self.state.ignored_syntax.clone() }; + if let Some(action) = language::take_action(ctx) { + language::apply(&mut prefs, action); + self.state.file_languages.clone_from(&prefs.chosen); + self.state.ignored_syntax.clone_from(&prefs.ignored); + } + language::set_prefs(ctx, prefs); + } } #[cfg(test)] diff --git a/crates/ui-egui/src/shell/app/window/activity/mod.rs b/crates/ui-egui/src/shell/app/window/activity/mod.rs new file mode 100644 index 0000000..58c0eb0 --- /dev/null +++ b/crates/ui-egui/src/shell/app/window/activity/mod.rs @@ -0,0 +1,119 @@ +//! The Activity tab: the feed of the vault, read with `activity.list` and +//! marked read with `activity.read`. +//! +//! The panel itself is a pure widget: the shell reads the feed here and hands +//! it over, and every command runs after the frame, never while the panel +//! paints. + +use serde_json::json; + +use crate::activity_panel::{ActivityData, ActivityResponse, parse_feed}; +use crate::right_view::RightTab; +use crate::shell::app::NotesApp; + +/// The command that lists the feed. +const LIST: &str = "activity.list"; +/// The command that marks the events read. +const READ: &str = "activity.read"; + +impl NotesApp { + /// Reads the feed while the Activity tab is on screen. + /// + /// The feed is read once per visit: the server answers a poll, and the + /// panel asks for a new one when the user marks the events read. + pub(super) fn tick_activity(&mut self, _ctx: &egui::Context) { + let phone = self.state.compact_page == crate::shell::compact::CompactPage::Activity; + let tab = self.state.right_visible && self.right_tab == RightTab::Activity; + if !(phone || tab) { + return; + } + if self.right.activity.loaded { + return; + } + self.read_activity(); + } + + /// Reads the feed with `activity.list`. + /// + /// A vault with no server has no feed: the panel says so instead of + /// asking a server that is not there. + fn read_activity(&mut self) { + if !self.bound_to_a_server() { + self.right.activity = ActivityData { loaded: true, unbound: true, ..ActivityData::default() }; + return; + } + let feed = self.engine_command(LIST, &json!({})).as_ref().map(parse_feed).unwrap_or_default(); + self.right.activity = ActivityData { events: feed.events, unread: feed.unread, loaded: true, unbound: false }; + } + + /// Whether the open vault is bound to a server. + fn bound_to_a_server(&self) -> bool { + notescraft_engine::load_binding(self.session.vault()).ok().flatten().is_some() + } + + /// Carries out what the user pressed in the Activity tab: open the note of + /// an event and mark the events read. + pub(super) fn handle_activity(&mut self, response: &ActivityResponse) { + if let Some(target) = &response.open { + self.open_note(&target.path); + self.jump_to_line(target.line); + } + if let Some(ids) = &response.mark_read { + let params = if ids.is_empty() { json!({}) } else { json!({ "ids": ids }) }; + if self.engine_command(READ, ¶ms).is_some() { + self.read_activity(); + } + } + } + + /// Scrolls the open note to a line: the editor jumps to its first byte. + fn jump_to_line(&mut self, line: Option) { + let Some(line) = line else { return }; + let Some(path) = self.tabs.active_path().map(str::to_owned) else { return }; + if self.editing.view_of(Some(&path)) != crate::center::ViewMode::Markdown { + return; + } + let Some(tab) = self.editing.editor_of(&path) else { return }; + let Some(byte) = line_start(&tab.editor.text, line) else { return }; + if let Some(tab) = self.editing.editor_of(&path) { + tab.editor.jump = Some(byte); + } + } +} + +/// The byte offset of the start of a one-based line. +/// +/// `None` when the note has no such line. +#[must_use] +pub fn line_start(text: &str, line: u64) -> Option { + if line == 0 { + return None; + } + let mut rest = text; + for _ in 1..line { + let at = rest.find('\n')?; + rest = rest.get(at + 1..)?; + } + Some(text.len().saturating_sub(rest.len())) +} + +#[cfg(test)] +mod tests { + use super::line_start; + + #[test] + fn a_line_starts_where_it_begins() { + let text = "First line.\nSecond line.\nThird line."; + assert_eq!(line_start(text, 1), Some(0)); + assert_eq!(line_start(text, 2), Some(12)); + assert_eq!(line_start(text, 3), Some(25)); + assert_eq!(line_start(text, 4), None, "the note has no fourth line"); + assert_eq!(line_start(text, 0), None, "the lines are one-based"); + } + + #[test] + fn a_line_of_an_empty_note_is_nothing() { + assert_eq!(line_start("", 1), Some(0)); + assert_eq!(line_start("", 2), None); + } +} diff --git a/crates/ui-egui/src/shell/app/window/cards.rs b/crates/ui-egui/src/shell/app/window/cards.rs index 3cfe2d1..e85dd1c 100644 --- a/crates/ui-egui/src/shell/app/window/cards.rs +++ b/crates/ui-egui/src/shell/app/window/cards.rs @@ -43,6 +43,8 @@ impl NotesApp { let compact_title = compact.then(|| self.tabs.active_path()).flatten().map_or_else(String::new, |path| crate::tabs::TabKind::of(path).title(path).to_owned()); let compact_back = std::cell::Cell::new(false); + let compact_review_back = std::cell::Cell::new(false); + let compact_review_page: std::cell::Cell> = std::cell::Cell::new(None); let compact_tap = std::cell::Cell::new(None::); let vim_enabled = self.vim_enabled(); let vim_nav = &self.vim_nav; @@ -214,7 +216,31 @@ impl NotesApp { }); panels.right = Box::new(|ui| { crate::hotkeys::region(ui, crate::vim_nav::NavFocus::Right, crate::text::hotkeys::region_right()); + if compact && compact_page.is_review() { + // On a phone the feed and the comments are pages of their own. + let mut response = RightResponse::default(); + if compact_page == crate::shell::compact::CompactPage::Activity { + let (activity, action) = crate::shell::compact::review::activity(ui, tokens, &right.activity); + response.activity = activity; + compact_review_back.set(action.back); + } else { + let (comments, action) = crate::shell::compact::review::comments(ui, tokens, &right.comments); + response.comments = comments; + compact_review_back.set(action.back); + } + *right_response.borrow_mut() = response; + return; + } + let before = *right_tab; let mut response = draw_right(ui, tokens, right_tab, right, &mut right_expanded, &frame, caret); + if compact && *right_tab != before { + // A tab the user picked on a phone opens the page of that panel. + match *right_tab { + crate::right_view::RightTab::Activity => compact_review_page.set(Some(crate::shell::compact::CompactPage::Activity)), + crate::right_view::RightTab::Comments => compact_review_page.set(Some(crate::shell::compact::CompactPage::Comments)), + _ => {} + } + } if frame.right_content && *right_tab == crate::right_view::RightTab::Assistant { let active_note = active_path.as_deref().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note); response.assistant = assistant.show(ui, tokens, session_ref.assistant_settings(), active_note).actions; @@ -233,6 +259,8 @@ impl NotesApp { if compact { if compact_back.get() { self.state.compact_page = crate::shell::compact::CompactPage::Notes; + } else if let Some(page) = compact_review_page.get() { + self.state.compact_page = page; } else if let Some(tapped) = compact_tap.get() { match tapped { crate::shell::compact::CompactPage::Graph => self.run_command("ui.open_graph", &serde_json::Value::Null), @@ -243,6 +271,9 @@ impl NotesApp { self.search.focus = true; } } + if compact_review_back.get() { + self.state.compact_page = crate::shell::compact::CompactPage::More; + } } if let Some(pane) = split_response.into_inner() { self.handle_split(&pane); @@ -288,6 +319,7 @@ fn draw_right( related: &data.related, lint: &data.lint, comments: &data.comments, + activity: &data.activity, }; let response = crate::right_view::draw(ui, tokens, tab, &inputs, *expanded); if response.collapse { diff --git a/crates/ui-egui/src/shell/app/window/comments/mod.rs b/crates/ui-egui/src/shell/app/window/comments/mod.rs index aaedbca..b84eea7 100644 --- a/crates/ui-egui/src/shell/app/window/comments/mod.rs +++ b/crates/ui-egui/src/shell/app/window/comments/mod.rs @@ -18,7 +18,9 @@ impl NotesApp { /// Reads the threads of the active note while the Comments tab is on screen /// and keeps the selection of the editor up to date. pub(super) fn tick_comments(&mut self, ctx: &egui::Context) { - if !(self.state.right_visible && self.right_tab == RightTab::Comments) { + let phone = self.state.compact_page == crate::shell::compact::CompactPage::Comments; + let tab = self.state.right_visible && self.right_tab == RightTab::Comments; + if !(phone || tab) { return; } let path = self.tabs.active_path().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note).map(str::to_owned); diff --git a/crates/ui-egui/src/shell/app/window/frame.rs b/crates/ui-egui/src/shell/app/window/frame.rs index e4a98eb..c0dfa0c 100644 --- a/crates/ui-egui/src/shell/app/window/frame.rs +++ b/crates/ui-egui/src/shell/app/window/frame.rs @@ -213,18 +213,24 @@ impl NotesApp { self.sidebar_stamp = stamp; } if !self.right_valid { - let (related, lint, comments) = - (std::mem::take(&mut self.right.related), std::mem::take(&mut self.right.lint), std::mem::take(&mut self.right.comments)); + let (related, lint, comments, activity) = ( + std::mem::take(&mut self.right.related), + std::mem::take(&mut self.right.lint), + std::mem::take(&mut self.right.comments), + std::mem::take(&mut self.right.activity), + ); self.right = crate::right_view::data_for(&self.session, self.tabs.active_path(), &self.docs, self.state.right_visible); self.right.related = related; self.right.lint = lint; self.right.comments = comments; + self.right.activity = activity; self.mark_related_stale(ctx); self.right_valid = true; } self.tick_related(ctx); self.tick_lint(ctx); self.tick_comments(ctx); + self.tick_activity(ctx); let rows = self.palette_rows(); self.preferences.settings.ui = serde_json::to_value(&self.state).unwrap_or_default(); let inset = self.draw_titlebar(ctx); @@ -740,6 +746,7 @@ impl NotesApp { fn handle_right(&mut self, response: RightResponse, now: f64, ctx: &egui::Context) { self.handle_assistant(response.assistant); self.handle_comments(&response.comments); + self.handle_activity(&response.activity); if let Some(path) = response.open_note { self.open_note(&path); } diff --git a/crates/ui-egui/src/shell/app/window/mod.rs b/crates/ui-egui/src/shell/app/window/mod.rs index 4139763..1d6cba0 100644 --- a/crates/ui-egui/src/shell/app/window/mod.rs +++ b/crates/ui-egui/src/shell/app/window/mod.rs @@ -1,5 +1,6 @@ //! App window content: cards, chrome, the frame loop, menus, views and commands. +pub(super) mod activity; pub(super) mod assistant; pub(super) mod cards; pub(super) mod chrome; @@ -9,10 +10,12 @@ pub(super) mod confirm; pub(super) mod frame; pub(super) mod lint; #[cfg(test)] +#[path = "tests/menu_tests.rs"] pub(super) mod menu_tests; pub(super) mod menubar; pub(super) mod menus; #[cfg(test)] +#[path = "tests/origin_tests.rs"] pub(super) mod origin_tests; pub(super) mod related; pub(super) mod views; diff --git a/crates/ui-egui/src/shell/app/window/menu_tests.rs b/crates/ui-egui/src/shell/app/window/tests/menu_tests.rs similarity index 100% rename from crates/ui-egui/src/shell/app/window/menu_tests.rs rename to crates/ui-egui/src/shell/app/window/tests/menu_tests.rs diff --git a/crates/ui-egui/src/shell/app/window/origin_tests.rs b/crates/ui-egui/src/shell/app/window/tests/origin_tests.rs similarity index 100% rename from crates/ui-egui/src/shell/app/window/origin_tests.rs rename to crates/ui-egui/src/shell/app/window/tests/origin_tests.rs diff --git a/crates/ui-egui/src/shell/app/window/views.rs b/crates/ui-egui/src/shell/app/window/views.rs index 0c2fb79..fa845b4 100644 --- a/crates/ui-egui/src/shell/app/window/views.rs +++ b/crates/ui-egui/src/shell/app/window/views.rs @@ -224,8 +224,12 @@ impl Views { TabKind::Trash => self.trash(ui, inputs, response), TabKind::Versions => self.versions(ui, inputs, response), kind if kind.is_binary() => match kind { + TabKind::Image if crate::file_card::is_undecodable_picture(path) => { + response.open_file = crate::file_card::show_info(ui, inputs.tokens, inputs.session, path); + } TabKind::Image => crate::image_view::show(ui, inputs.tokens, inputs.session, path, &mut self.image), TabKind::Video => response.open_file = crate::media_view::video::show(ui, inputs.tokens, inputs.session, path), + TabKind::Audio => response.open_file = crate::media_view::audio::show(ui, inputs.tokens, inputs.session, path), TabKind::Pdf => response.open_file = crate::media_view::pdf::show(ui, inputs.tokens, inputs.session, path), _ => response.open_file = crate::file_card::show_info(ui, inputs.tokens, inputs.session, path), }, @@ -303,11 +307,15 @@ impl Views { let Some(path) = inputs.path else { return }; let Some(edit) = inputs.editor.as_mut() else { return }; let kind = TabKind::of(path); + if matches!(kind, TabKind::Code | TabKind::Text) { + response.edited = source_file(ui, inputs.tokens, path, &mut edit.editor.text, inputs.session.is_read_only()); + return; + } let entry = self.files.entry(path.to_owned()).or_insert_with(|| FileView::new(kind, &edit.editor.text)); if entry.source != edit.editor.text { *entry = FileView::new(kind, &edit.editor.text); } - if edit.view == ViewMode::Markdown && kind != TabKind::Diagram || kind == TabKind::Text { + if edit.view == ViewMode::Markdown && kind != TabKind::Diagram { response.edited = source_editor(ui, path, &mut edit.editor.text); return; } @@ -393,6 +401,30 @@ impl FileView { } } +/// The tab of a code or text file: a highlighted editor, or the read-only viewer under a notice when the +/// file is too big to edit (or the vault is read-only). `true` when the text changed. +fn source_file(ui: &mut egui::Ui, tokens: &crate::theme::Tokens, path: &str, text: &mut String, read_only: bool) -> bool { + use crate::code_view::language; + let ext = path.rsplit_once('.').map_or_else(String::new, |(_, ext)| ext.to_ascii_lowercase()); + let prefs = language::prefs(ui.ctx()); + let resolved = language::resolve(path, text.lines().next().unwrap_or_default(), &prefs.chosen); + language::set_current(ui.ctx(), path, resolved.as_deref()); + let too_large = text.len() > crate::code_view::MAX_EDIT_BYTES; + if language::needs_hint(path, resolved.as_deref(), &prefs) { + crate::code_view::banner(ui, tokens, path); + } + if !too_large && !read_only { + return crate::code_view::edit(ui, tokens, path, resolved.as_deref(), text); + } + if too_large { + ui.label( + egui::RichText::new(crate::text::code_view::too_large()).size(crate::style::TEXT_TINY).color(crate::theme::color(tokens.theme.colors.text_muted)), + ); + } + crate::code_view::show(ui, tokens, text, &ext); + false +} + fn source_editor(ui: &mut egui::Ui, path: &str, source: &mut String) -> bool { egui::ScrollArea::both() .id_salt((path, "source")) diff --git a/crates/ui-egui/src/shell/attachment_drop/images.rs b/crates/ui-egui/src/shell/attachment_drop/images.rs index 8a01251..0514f0c 100644 --- a/crates/ui-egui/src/shell/attachment_drop/images.rs +++ b/crates/ui-egui/src/shell/attachment_drop/images.rs @@ -231,7 +231,7 @@ impl Images { total = total.saturating_add(raster.pixels.len().saturating_mul(std::mem::size_of::())); Source::Raster(Arc::new(Raster { image: raster, texture: egui::mutex::Mutex::default() })) } else { - Source::Bytes(bytes.into()) + Source::Bytes(crate::image_view::picture_bytes(&bytes).into()) }; if total > DEFAULT_MAX_BYTES { return Err(TOO_LARGE.replace("{{limit}}", &DEFAULT_MAX_BYTES.to_string())); diff --git a/crates/ui-egui/src/shell/attachment_drop/mod.rs b/crates/ui-egui/src/shell/attachment_drop/mod.rs index 80b7aec..214f279 100644 --- a/crates/ui-egui/src/shell/attachment_drop/mod.rs +++ b/crates/ui-egui/src/shell/attachment_drop/mod.rs @@ -25,9 +25,10 @@ const MAX_ENCODE_BYTES: usize = 96 * 1024 * 1024; /// Longest local clipboard path accepted before parsing or allocating. const MAX_PATH_BYTES: usize = 16 * 1024; /// Number of image file extensions at the beginning of [`EXTENSIONS`]. -const IMAGE_EXTENSIONS: usize = 7; +const IMAGE_EXTENSIONS: usize = 8; /// All file types accepted by the engine's attachment commands. -const EXTENSIONS: [&str; 15] = ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "pdf", "mp3", "wav", "ogg", "m4a", "mp4", "webm", "mov"]; +const EXTENSIONS: [&str; 21] = + ["png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "pdf", "avif", "mp3", "wav", "ogg", "m4a", "flac", "3gp", "mp4", "webm", "mov", "mkv", "ogv"]; /// One thing to do with a pasted or dropped file. #[derive(Debug, Clone, PartialEq)] diff --git a/crates/ui-egui/src/shell/compact/mod.rs b/crates/ui-egui/src/shell/compact/mod.rs index 061dec8..64f4f74 100644 --- a/crates/ui-egui/src/shell/compact/mod.rs +++ b/crates/ui-egui/src/shell/compact/mod.rs @@ -7,6 +7,7 @@ pub mod banner; pub mod page; +pub mod review; pub mod tab_bar; use serde::{Deserialize, Serialize}; @@ -40,6 +41,10 @@ pub enum CompactPage { Calendar, /// The right panel's content: Outline, Backlinks, Related. More, + /// The activity feed of the vault, on its own page. + Activity, + /// The comment threads of the open note, on their own page. + Comments, } /// Which floating card a page renders into. @@ -59,9 +64,15 @@ impl CompactPage { match self { Self::Notes | Self::Search => Card::Sidebar, Self::Note | Self::Graph | Self::Calendar => Card::Editor, - Self::More => Card::Right, + Self::More | Self::Activity | Self::Comments => Card::Right, } } + + /// Whether the page is one of the review pages: the feed and the comments. + #[must_use] + pub fn is_review(self) -> bool { + matches!(self, Self::Activity | Self::Comments) + } } /// The compact card geometry: only the current page's card, from `[PANEL_GAP]` below the @@ -135,6 +146,8 @@ mod tests { (CompactPage::Graph, false, true, false), (CompactPage::Calendar, false, true, false), (CompactPage::More, false, false, true), + (CompactPage::Activity, false, false, true), + (CompactPage::Comments, false, false, true), ] { let rects = layout_of(screen, page, 0.0); assert_eq!(rects.sidebar.is_some(), sidebar); diff --git a/crates/ui-egui/src/shell/compact/review.rs b/crates/ui-egui/src/shell/compact/review.rs new file mode 100644 index 0000000..8fa9c87 --- /dev/null +++ b/crates/ui-egui/src/shell/compact/review.rs @@ -0,0 +1,66 @@ +//! The phone pages of the review panels: the activity feed and the comments. +//! +//! On a phone the right card shows one page at a time. The feed and the +//! comments get a page of their own, with a header that carries the title and +//! a back chevron; the rows keep the 44 pt touch target of the compact layout. + +use egui::{Rect, Sense, Ui, vec2}; + +use crate::theme::{Tokens, color}; + +/// Height of the header of a page, in points. +pub const HEADER: f32 = 48.0; +/// Side of the back chevron, in points. +const ICON: f32 = 24.0; +/// Size of the page title, in points. +const TITLE: f32 = 17.0; +/// Side of the chevron's hit area, in points (the compact touch target). +const TARGET: f32 = crate::design::style::compact::MIN_TARGET_COMPACT; + +/// What the page asks the shell to do. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PageAction { + /// The back chevron was tapped: the shell returns to the More page. + pub back: bool, +} + +/// The header of a review page: a back chevron and the title of the page. +pub fn header(ui: &mut Ui, tokens: &Tokens, title: &str) -> PageAction { + let colors = &tokens.theme.colors; + let mut back = false; + ui.horizontal(|ui| { + let (rect, response) = ui.allocate_exact_size(vec2(TARGET, TARGET), Sense::click()); + back = response.on_hover_text(crate::text::compact::back()).clicked(); + crate::icons::paint(ui, "chevron-left", Rect::from_center_size(rect.center(), vec2(ICON, ICON)), color(colors.text)); + ui.add(egui::Label::new(egui::RichText::new(title).size(TITLE).family(crate::fonts::semibold()).color(color(colors.text)))); + }); + ui.add_space(4.0); + PageAction { back } +} + +/// The phone page of the activity feed. +#[must_use] +pub fn activity(ui: &mut Ui, tokens: &Tokens, data: &crate::activity_panel::ActivityData) -> (crate::activity_panel::ActivityResponse, PageAction) { + let action = header(ui, tokens, crate::text::review::activity_panel::title()); + let response = crate::activity_panel::show(ui, tokens, data); + (response, action) +} + +/// The phone page of the comments of the open note. +#[must_use] +pub fn comments(ui: &mut Ui, tokens: &Tokens, data: &crate::comment_panel::CommentData) -> (crate::comment_panel::CommentResponse, PageAction) { + let action = header(ui, tokens, crate::text::review::comment_panel::title()); + let response = crate::comment_panel::show(ui, tokens, data); + (response, action) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_keeps_the_touch_target_of_the_phone() { + assert_eq!(TARGET, 44.0, "the chevron is a 44 pt target"); + const { assert!(HEADER >= TARGET) }; + } +} diff --git a/crates/ui-egui/src/shell/hotkeys/commands.rs b/crates/ui-egui/src/shell/hotkeys/commands.rs index f95654f..5e78e41 100644 --- a/crates/ui-egui/src/shell/hotkeys/commands.rs +++ b/crates/ui-egui/src/shell/hotkeys/commands.rs @@ -17,13 +17,14 @@ use crate::vim_nav::NavFocus; const MENU: &str = "View"; /// Opens the right panel on `tab`: `(command id, tab)`. -const SHOW: [(&str, RightTab); 6] = [ +const SHOW: [(&str, RightTab); 7] = [ ("ui.show_outline", RightTab::Outline), ("ui.show_related", RightTab::Related), ("ui.show_backlinks", RightTab::Backlinks), ("ui.show_assistant", RightTab::Assistant), ("ui.show_lint", RightTab::Lint), ("ui.show_comments", RightTab::Comments), + ("ui.show_activity", RightTab::Activity), ]; /// The palette label of a hotkey command. @@ -43,12 +44,13 @@ fn label(id: &str) -> Option<&'static str> { "ui.show_assistant" => t::show_assistant(), "ui.show_lint" => t::show_lint(), "ui.show_comments" => t::show_comments(), + "ui.show_activity" => t::show_activity(), _ => return None, }) } /// Every command this module runs. -const IDS: [&str; 14] = [ +const IDS: [&str; 15] = [ "ui.next_tab", "ui.prev_tab", "ui.goto_tab", @@ -63,6 +65,7 @@ const IDS: [&str; 14] = [ "ui.show_assistant", "ui.show_lint", "ui.show_comments", + "ui.show_activity", ]; #[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")] diff --git a/crates/ui-egui/src/shell/hotkeys/defaults.rs b/crates/ui-egui/src/shell/hotkeys/defaults.rs index ea0e0b5..39bec63 100644 --- a/crates/ui-egui/src/shell/hotkeys/defaults.rs +++ b/crates/ui-egui/src/shell/hotkeys/defaults.rs @@ -57,6 +57,7 @@ pub const DEFAULTS: &[Hotkey] = &[ dispatch("ui.show_assistant", Binding::key(Key::A).cmd().alt(), Scope::Global), // ⌘⌥A dispatch("ui.show_lint", Binding::key(Key::L).cmd().alt(), Scope::Global), // ⌘⌥L dispatch("ui.show_comments", Binding::key(Key::M).cmd().alt(), Scope::Global), // ⌘⌥M + dispatch("ui.show_activity", Binding::key(Key::N).cmd().alt(), Scope::Global), // ⌘⌥N dispatch("ui.toggle_right_panel", Binding::key(Key::Backslash).cmd().alt(), Scope::Global), // ⌘⌥\ // Older bindings, handled where they always were. held_by("ui.toggle_sidebar", Binding::key(Key::B).cmd(), Scope::Global, Owner::Legacy), // ⌘B (panel_resize::workspace::shortcut) diff --git a/crates/ui-egui/src/shell/hotkeys/tests.rs b/crates/ui-egui/src/shell/hotkeys/tests.rs index 364f451..aa0c1de 100644 --- a/crates/ui-egui/src/shell/hotkeys/tests.rs +++ b/crates/ui-egui/src/shell/hotkeys/tests.rs @@ -111,6 +111,7 @@ fn every_command_the_shell_runs_has_a_default() { "ui.show_assistant", "ui.show_lint", "ui.show_comments", + "ui.show_activity", "ui.toggle_right_panel", "ui.toggle_sidebar", ] { diff --git a/crates/ui-egui/src/shell/panel_resize/controls.rs b/crates/ui-egui/src/shell/panel_resize/controls.rs index 76216a4..d4bd96f 100644 --- a/crates/ui-egui/src/shell/panel_resize/controls.rs +++ b/crates/ui-egui/src/shell/panel_resize/controls.rs @@ -72,13 +72,14 @@ pub(crate) fn sidebar_rail(ui: &mut Ui, tokens: &Tokens, state: &mut crate::stat pub(crate) fn right_rail(ui: &mut Ui, tokens: &Tokens, tab: &mut crate::right_view::RightTab, expanded: &mut bool) { use crate::right_view::RightTab; - const ENTRIES: [(Option, &str, &str, &str); 7] = [ + const ENTRIES: [(Option, &str, &str, &str); 8] = [ (None, "panel-right", EXPAND, "ui.toggle_right_panel"), (Some(RightTab::Backlinks), "link", "Backlinks", "ui.show_backlinks"), (Some(RightTab::Related), "sparkles", "Related", "ui.show_related"), (Some(RightTab::Lint), "list-checks", "Lint", "ui.show_lint"), (Some(RightTab::Outline), "list", "Outline", "ui.show_outline"), (Some(RightTab::Comments), "message-square", "Comments", "ui.show_comments"), + (Some(RightTab::Activity), "bell", "Activity", "ui.show_activity"), (Some(RightTab::Assistant), "bot", "Assistant", "ui.show_assistant"), ]; for (choice, icon, label, hotkey) in ENTRIES { diff --git a/crates/ui-egui/src/shell/state.rs b/crates/ui-egui/src/shell/state.rs index 863f2bd..97bb5cd 100644 --- a/crates/ui-egui/src/shell/state.rs +++ b/crates/ui-egui/src/shell/state.rs @@ -99,6 +99,10 @@ pub struct UiState { pub pinned_folders: Vec, /// Bodies (as 16-digit hex hashes) of "this block looks like JSON" offers the user declined. pub dismissed_json: Vec, + /// The grammar the user chose per source-file type (extension or name, lowercase), bounded to 200. + pub file_languages: std::collections::BTreeMap, + /// File types whose "no syntax highlighting" hint the user dismissed, bounded to 200. + pub ignored_syntax: Vec, /// Set by the `find.open` command: open the find bar next frame; not saved. #[serde(skip)] pub find_requested: bool, @@ -197,6 +201,8 @@ impl UiState { pinned_folders: Vec::new(), pinned_tabs: Vec::new(), dismissed_json: Vec::new(), + file_languages: std::collections::BTreeMap::new(), + ignored_syntax: Vec::new(), find_requested: false, nav_request: None, explorer_sort: crate::tree::Sort::default(), diff --git a/crates/ui-egui/src/text/features/hotkeys.rs b/crates/ui-egui/src/text/features/hotkeys.rs index 29211b6..8f327fe 100644 --- a/crates/ui-egui/src/text/features/hotkeys.rs +++ b/crates/ui-egui/src/text/features/hotkeys.rs @@ -15,6 +15,7 @@ table! { show_assistant = "ui.hotkeys.show-assistant"; show_lint = "ui.hotkeys.show-lint"; show_comments = "ui.hotkeys.show-comments"; + show_activity = "ui.hotkeys.show-activity"; region_sidebar = "ui.hotkeys.region-sidebar"; region_editor = "ui.hotkeys.region-editor"; region_right = "ui.hotkeys.region-right"; diff --git a/crates/ui-egui/src/text/panels/review/activity_panel.rs b/crates/ui-egui/src/text/panels/review/activity_panel.rs new file mode 100644 index 0000000..869b615 --- /dev/null +++ b/crates/ui-egui/src/text/panels/review/activity_panel.rs @@ -0,0 +1,23 @@ +//! Copy for the activity tab of the right card. + +table! { + title = "ui.activity-panel.title"; + loading = "ui.activity-panel.loading"; + empty = "ui.activity-panel.empty"; + unbound = "ui.activity-panel.unbound"; + unread = "ui.activity-panel.unread"; + mark_read = "ui.activity-panel.mark-read"; + now = "ui.activity-panel.now"; + minutes = "ui.activity-panel.minutes"; + hours = "ui.activity-panel.hours"; + days = "ui.activity-panel.days"; + mention = "ui.activity-panel.mention"; + comment = "ui.activity-panel.comment"; + suggestion = "ui.activity-panel.suggestion"; + note_changed = "ui.activity-panel.note-changed"; + invite_accepted = "ui.activity-panel.invite-accepted"; + member_joined = "ui.activity-panel.member-joined"; + role_changed = "ui.activity-panel.role-changed"; + thread_resolved = "ui.activity-panel.thread-resolved"; + other = "ui.activity-panel.other"; +} diff --git a/crates/ui-egui/src/text/panels/review/mod.rs b/crates/ui-egui/src/text/panels/review/mod.rs index 01ae858..7107a08 100644 --- a/crates/ui-egui/src/text/panels/review/mod.rs +++ b/crates/ui-egui/src/text/panels/review/mod.rs @@ -1,3 +1,4 @@ -//! Copy for the review panels: comments and suggestions. +//! Copy for the review panels: comments, suggestions and the activity feed. +pub mod activity_panel; pub mod comment_panel; diff --git a/crates/ui-egui/src/text/views/code_view/mod.rs b/crates/ui-egui/src/text/views/code_view/mod.rs index 1a3d0bc..ccbdfa7 100644 --- a/crates/ui-egui/src/text/views/code_view/mod.rs +++ b/crates/ui-egui/src/text/views/code_view/mod.rs @@ -3,3 +3,15 @@ table! { more = "ui.code-view.more"; } + +table! { + too_large = "ui.source-file.too-large"; + plain = "ui.source-file.plain"; + chip = "ui.source-file.chip"; + banner = "ui.source-file.banner"; + install = "ui.source-file.install"; + other = "ui.source-file.other"; + ignore = "ui.source-file.ignore"; + search = "ui.source-file.search"; + no_match = "ui.source-file.no-match"; +} diff --git a/crates/ui-egui/src/text/views/files/file_card.rs b/crates/ui-egui/src/text/views/files/file_card.rs index 4fa407c..bb5ab29 100644 --- a/crates/ui-egui/src/text/views/files/file_card.rs +++ b/crates/ui-egui/src/text/views/files/file_card.rs @@ -13,3 +13,9 @@ table! { kind_pdf = "ui.file-card.kind-pdf"; kind_other = "ui.file-card.kind-other"; } + +table! { + avif_reason = "ui.source-file.avif-reason"; + copy_hint = "ui.source-file.copy-hint"; + kind_image = "ui.source-file.kind-image"; +} diff --git a/crates/ui-egui/src/text/views/image_annotate.rs b/crates/ui-egui/src/text/views/image_annotate.rs index c087987..845d3ff 100644 --- a/crates/ui-egui/src/text/views/image_annotate.rs +++ b/crates/ui-egui/src/text/views/image_annotate.rs @@ -17,8 +17,7 @@ table! { clear = "ui.image-annotate.clear"; clear_again = "ui.image-annotate.clear-again"; colour = "ui.image-annotate.colour"; - label = "ui.image-annotate.label"; - add_label = "ui.image-annotate.add-label"; + text_size = "ui.image-annotate.text-size"; saved = "ui.image-annotate.saved"; saved_plain = "ui.image-annotate.saved-plain"; saving = "ui.image-annotate.saving"; diff --git a/crates/ui-egui/src/text/views/media_view/audio.rs b/crates/ui-egui/src/text/views/media_view/audio.rs new file mode 100644 index 0000000..099fd85 --- /dev/null +++ b/crates/ui-egui/src/text/views/media_view/audio.rs @@ -0,0 +1,11 @@ +//! Copy for the audio tab and the audio embed, served from the `ui.audio-view` table. + +table! { + spectrum = "ui.audio-view.spectrum"; + spectrum_tip = "ui.audio-view.spectrum-tip"; + spectrum_label = "ui.audio-view.spectrum-label"; + waveform = "ui.audio-view.waveform"; + waveform_failed = "ui.audio-view.waveform-failed"; + no_player = "ui.audio-view.no-player"; + volume = "ui.audio-view.volume"; +} diff --git a/crates/ui-egui/src/text/views/media_view/mod.rs b/crates/ui-egui/src/text/views/media_view/mod.rs index 60f4dd7..68fe7f8 100644 --- a/crates/ui-egui/src/text/views/media_view/mod.rs +++ b/crates/ui-egui/src/text/views/media_view/mod.rs @@ -1,5 +1,7 @@ //! Copy for the media tabs (video and PDF), served from the `ui.media-view` table. +pub mod audio; + table! { play = "ui.media-view.play"; stop = "ui.media-view.stop"; diff --git a/crates/ui-egui/src/views/center/breadcrumb.rs b/crates/ui-egui/src/views/center/breadcrumb.rs index 03e112f..8fff4e2 100644 --- a/crates/ui-egui/src/views/center/breadcrumb.rs +++ b/crates/ui-egui/src/views/center/breadcrumb.rs @@ -62,6 +62,8 @@ pub(super) fn show( let text = color(colors.text); let muted = color(colors.text_muted); let style = RowStyle { font: FontId::new(11.0, crate::fonts::semibold()), text, muted }; + // Source files are always the editor: the Visual/Source choice is meaningless for them. + let toggle = path.is_none_or(|path| !matches!(crate::tabs::TabKind::of(path), crate::tabs::TabKind::Code | crate::tabs::TabKind::Text)); let Some(path) = path else { draw_toggle(ui, row, None, tokens, view, response, &style); return; @@ -140,7 +142,11 @@ pub(super) fn show( response.live_toggle = true; } } - draw_toggle(ui, row, Some(geometry.pill), tokens, view, response, &style); + if toggle { + draw_toggle(ui, row, Some(geometry.pill), tokens, view, response, &style); + } else { + crate::code_view::chip(ui, tokens, geometry.pill, path); + } } fn draw_toggle(ui: &mut Ui, row: Rect, fixed_pill: Option, tokens: &Tokens, view: ViewMode, response: &mut CenterResponse, style: &RowStyle) { diff --git a/crates/ui-egui/src/views/code_view/editor.rs b/crates/ui-egui/src/views/code_view/editor.rs new file mode 100644 index 0000000..f6f5587 --- /dev/null +++ b/crates/ui-egui/src/views/code_view/editor.rs @@ -0,0 +1,195 @@ +//! The editable source view of code and text files: the same token colours as the read-only viewer, +//! a line-number gutter and the theme's monospace font. +//! +//! The text the user edits is never altered for display: spans the grammar does not cover are laid out +//! plain, so the galley text always equals the buffer. Small files are highlighted on the spot, big ones by +//! the syntax worker (plain until it answers). + +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Align2, FontId, Ui}; + +use notescraft_syntax::{Highlighted, Highlighter, TokenKind}; + +use super::CODE_SIZE; +use crate::theme::{Tokens, color}; + +/// Largest file opened for editing, in bytes; bigger files open read-only. +pub const MAX_EDIT_BYTES: usize = 2 * 1024 * 1024; +/// Space between the line numbers and the text, in points. +const NUMBER_GAP: f32 = 12.0; +/// Space left of the line numbers, in points. +const GUTTER_PAD: f32 = 8.0; +/// Largest text highlighted on the UI thread, in bytes; bigger texts go to the worker. +const SYNC_BYTES: usize = 64 * 1024; +/// Opacity of the line numbers against the muted text colour. +const NUMBER_OPACITY: f32 = 0.7; + +/// The highlighted layout of one text, kept while the text and the palette stay the same. +#[derive(Clone)] +struct Cached { + /// Hash of the text, the extension and the colours it was built for. + key: u64, + /// The layout, wrapped nowhere. + job: Arc, +} + +/// Draws the editor for `text`, highlighted with the grammar `language` (none: plain); `true` when it changed. +pub fn edit(ui: &mut Ui, tokens: &Tokens, path: &str, language: Option<&str>, text: &mut String) -> bool { + let mono = FontId::monospace(CODE_SIZE); + let digits = text.bytes().filter(|byte| *byte == b'\n').count().saturating_add(1).to_string().len(); + let digit_width = ui.fonts_mut(|fonts| fonts.glyph_width(&mono, '0')); + #[expect(clippy::cast_precision_loss, reason = "a handful of digits")] + let gutter = GUTTER_PAD + digit_width * digits as f32 + NUMBER_GAP; + let cache_id = egui::Id::new((path, "code-layout")); + let muted = color(tokens.theme.colors.text_muted).gamma_multiply(NUMBER_OPACITY); + egui::ScrollArea::both() + .id_salt((path, "source")) + .auto_shrink([false, false]) + .show(ui, |ui| { + let mut layouter = |ui: &Ui, buffer: &dyn egui::TextBuffer, _wrap: f32| { + let job = job_for(ui.ctx(), cache_id, tokens, language, buffer.as_str(), &mono); + ui.ctx().fonts_mut(|fonts| fonts.layout_job((*job).clone())) + }; + let output = ui + .horizontal_top(|ui| { + ui.add_space(gutter); + egui::TextEdit::multiline(text) + .id_salt((path, "raw")) + .font(mono.clone()) + .frame(egui::Frame::NONE) + .layouter(&mut layouter) + .desired_width(f32::INFINITY) + .min_size(ui.available_size()) + .show(ui) + }) + .inner; + for (number, y) in line_tops(&output.galley) { + let at = egui::pos2(output.galley_pos.x - NUMBER_GAP, output.galley_pos.y + y); + ui.painter().text(at, Align2::RIGHT_CENTER, number.to_string(), mono.clone(), muted); + } + output.response.changed() + }) + .inner +} + +/// The number and vertical middle (galley space) of every row that starts a source line. +fn line_tops(galley: &egui::Galley) -> Vec<(usize, f32)> { + let mut out = Vec::new(); + let mut number = 1_usize; + let mut starts_line = true; + for row in &galley.rows { + if starts_line { + out.push((number, row.rect().center().y)); + } + starts_line = row.ends_with_newline; + if starts_line { + number = number.saturating_add(1); + } + } + out +} + +/// The layout of `text`, rebuilt only when the text, the grammar, the grammars installed or the palette changed. +fn job_for(ctx: &egui::Context, id: egui::Id, tokens: &Tokens, language: Option<&str>, text: &str, mono: &FontId) -> Arc { + let highlighted = language.filter(|language| *language != super::language::PLAIN).and_then(|language| spans_of(language, text)); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + (text, language, tokens.theme.name.as_str(), highlighted.is_some(), notescraft_syntax::load::current().generation).hash(&mut hasher); + let key = hasher.finish(); + if let Some(cached) = ctx.data(|data| data.get_temp::(id)).filter(|cached| cached.key == key) { + return cached.job; + } + let job = Arc::new(build_job(tokens, highlighted.as_deref(), text, mono)); + ctx.data_mut(|data| data.insert_temp(id, Cached { key, job: job.clone() })); + job +} + +/// The token spans of `text` for `language`: now when the text is small, from the worker (or `None` while +/// it is busy) when it is not, and `None` for a text over the highlighter's caps. +fn spans_of(language: &str, text: &str) -> Option> { + let lines = text.bytes().filter(|byte| *byte == b'\n').count().saturating_add(1); + if text.len() > SYNC_BYTES { + return notescraft_syntax::global().get(language, text, 0..lines).filter(|found| !found.lines.is_empty()); + } + Highlighter::new(language, text).map(|mut highlighter| Arc::new(highlighter.lines(0..lines))) +} + +/// The colour of a token kind in the theme's syntax palette. +fn kind_color(kind: TokenKind, tokens: &Tokens) -> egui::Color32 { + tokens.theme.syntax.all().get(kind.index()).copied().map_or_else(|| color(tokens.theme.colors.text), color) +} + +/// One section per token run; the sections together are exactly `text`. +fn build_job(tokens: &Tokens, highlighted: Option<&Highlighted>, text: &str, mono: &FontId) -> LayoutJob { + let plain = TextFormat::simple(mono.clone(), color(tokens.theme.colors.text)); + let mut job = LayoutJob::default(); + job.wrap.max_width = f32::INFINITY; + for (index, line) in text.split('\n').enumerate() { + if index > 0 { + job.append("\n", 0.0, plain.clone()); + } + let spans = highlighted.and_then(|found| index.checked_sub(found.first_line).and_then(|at| found.lines.get(at))); + let mut used = 0_usize; + for span in spans.map_or(&[][..], Vec::as_slice) { + // A span the grammar reports out of order or off a character boundary is left plain. + let Some(shown) = line.get(span.start..span.end).filter(|_| span.start >= used) else { continue }; + if let Some(gap) = line.get(used..span.start).filter(|gap| !gap.is_empty()) { + job.append(gap, 0.0, plain.clone()); + } + job.append(shown, 0.0, TextFormat::simple(mono.clone(), kind_color(span.kind, tokens))); + used = span.end; + } + if let Some(rest) = line.get(used..).filter(|rest| !rest.is_empty()) { + job.append(rest, 0.0, plain.clone()); + } + } + job +} + +#[cfg(test)] +mod tests { + use super::{build_job, edit}; + use crate::theme::Tokens; + + #[test] + fn the_editor_draws_numbers_and_leaves_the_text_alone() { + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 400.0))), ..Default::default() }; + let mut text = "const a = 1;\n// two\nlet b = 'x';\n".to_owned(); + let before = text.clone(); + let mut shown = Vec::new(); + for _ in 0..2 { + let mut changed = false; + let mut out = ctx.run_ui(input.clone(), |ui| changed = edit(ui, &tokens, "a.js", Some("js"), &mut text)); + out.textures_delta.clear(); + assert!(!changed); + shown = out + .shapes + .iter() + .filter_map(|row| if let egui::Shape::Text(shaped) = &row.shape { Some(shaped.galley.text().to_owned()) } else { None }) + .collect(); + } + assert_eq!(text, before); + for number in ["1", "2", "3", "4"] { + assert!(shown.iter().any(|text| text == number), "line number {number} in {shown:?}"); + } + } + + #[test] + fn the_sections_rebuild_the_buffer_exactly() { + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let long = format!("let s = \"{}\"; // end\r\n", "ب".repeat(3000)); + let text = format!("fn main() {{\r\n/* open\n still */ 12\n{long}\n\n"); + let found = super::spans_of("rust", &text); + assert!(found.is_some()); + let job = build_job(&tokens, found.as_deref(), &text, &egui::FontId::monospace(13.0)); + assert_eq!(job.text, text); + assert!(job.sections.len() > 5, "the grammar coloured it"); + } +} diff --git a/crates/ui-egui/src/views/code_view/language.rs b/crates/ui-egui/src/views/code_view/language.rs new file mode 100644 index 0000000..f799636 --- /dev/null +++ b/crates/ui-egui/src/views/code_view/language.rs @@ -0,0 +1,250 @@ +//! Which grammar highlights a source file: the user's choice for its type, then the file type itself +//! (extension, well-known name), then the shebang line. The answer is a grammar label the syntax crate +//! finds, or nothing, in which case the tab offers to install a grammar or pick another language. +//! +//! The choices live in the saved UI state; the view reads them from the egui context ([`set_prefs`]) +//! and posts what the user did as an [`Action`] the app takes ([`take_action`]) and saves. + +use std::collections::BTreeMap; + +use egui::Context; +use notescraft_syntax::load::is_known; + +/// Most extension choices (and ignored extensions) the saved state keeps. +pub const MAX_ENTRIES: usize = 200; +/// The grammar label of "no highlighting". +pub const PLAIN: &str = "text"; +/// Extensions of files that are plain on purpose: they never raise the hint. +const PLAIN_TYPES: &[&str] = &["txt", "text", "log", "csv", "tsv"]; +/// Well-known file names (lowercase) and the grammar label they mean. +const NAMES: &[(&str, &str)] = &[ + ("makefile", "makefile"), + ("gnumakefile", "makefile"), + ("dockerfile", "dockerfile"), + (".bashrc", "bash"), + (".zshrc", "bash"), + (".profile", "bash"), + (".editorconfig", "ini"), + (".gitconfig", "ini"), +]; +/// Interpreters named by a shebang line and the grammar label they mean. +const INTERPRETERS: &[(&str, &str)] = + &[("python", "python"), ("node", "javascript"), ("bash", "bash"), ("sh", "bash"), ("zsh", "bash"), ("ruby", "ruby"), ("perl", "perl"), ("php", "php")]; + +/// What the user chose, as the saved state holds it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Prefs { + /// File type key -> grammar label. + pub chosen: BTreeMap, + /// File type keys whose "no syntax highlighting" hint was dismissed. + pub ignored: Vec, +} + +/// Something the user did in the tab, for the app to save. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Action { + /// Highlight files of this type with this grammar from now on. + Choose { + /// The file type key. + key: String, + /// The grammar label. + language: String, + }, + /// Stop hinting for this file type. + Ignore { + /// The file type key. + key: String, + }, +} + +/// The key under which a file's type is remembered: its lowercase extension, or its lowercase name when +/// it has none (`makefile`, `.gitignore`), and how the banner names it (`.css`, `Makefile`). +#[must_use] +pub fn type_of(path: &str) -> (String, String) { + let name = path.rsplit('/').next().unwrap_or(path); + match name.rsplit_once('.').filter(|(stem, ext)| !stem.is_empty() && !ext.is_empty()) { + Some((_, ext)) => (ext.to_ascii_lowercase(), format!(".{ext}")), + None => (name.to_ascii_lowercase(), name.to_owned()), + } +} + +/// The grammar label for `path`, given its first line and the user's choices; `None` when nothing +/// bundled or installed fits. +#[must_use] +pub fn resolve(path: &str, first_line: &str, chosen: &BTreeMap) -> Option { + let (key, _) = type_of(path); + if let Some(language) = chosen.get(&key).filter(|language| is_known(language)) { + return Some(language.clone()); + } + if PLAIN_TYPES.contains(&key.as_str()) { + return Some(PLAIN.to_owned()); + } + if is_known(&key) { + return Some(key); + } + if let Some((_, label)) = NAMES.iter().find(|(name, _)| *name == key).filter(|(_, label)| is_known(label)) { + return Some((*label).to_owned()); + } + shebang(first_line) +} + +/// The grammar of a `#!` line: `#!/usr/bin/env python3` and `#!/bin/bash -e` both work. +fn shebang(first_line: &str) -> Option { + let rest = first_line.strip_prefix("#!")?; + let mut words = rest.split_whitespace(); + let mut program = words.next()?.rsplit('/').next()?; + if program == "env" { + program = words.find(|word| !word.starts_with('-'))?; + } + let program = program.trim_end_matches(|c: char| c.is_ascii_digit() || c == '.'); + INTERPRETERS.iter().find(|(name, _)| *name == program).map(|(_, label)| (*label).to_owned()).filter(|label| is_known(label)) +} + +/// Whether the hint for a file of this type should show: nothing fits it and the user did not dismiss it. +#[must_use] +pub fn needs_hint(path: &str, resolved: Option<&str>, prefs: &Prefs) -> bool { + resolved.is_none() && !prefs.ignored.contains(&type_of(path).0) +} + +/// Every language the picker offers, sorted, with the plain one first: the bundled and installed grammar names. +#[must_use] +pub fn languages() -> Vec { + let mut names: Vec = + notescraft_syntax::load::current().set.syntaxes().iter().filter(|syntax| !syntax.hidden).map(|syntax| syntax.name.clone()).collect(); + names.sort_by_key(|name| name.to_lowercase()); + names.dedup(); + names.retain(|name| name != "Plain Text"); + names.insert(0, "Plain Text".to_owned()); + names +} + +/// The label shown for a resolved grammar: its own name, or the plain one. +#[must_use] +pub fn name_of(language: Option<&str>) -> String { + let Some(language) = language else { return crate::text::code_view::plain().to_owned() }; + if language == PLAIN { + return crate::text::code_view::plain().to_owned(); + } + notescraft_syntax::load::current().find(language).map_or_else(|| language.to_owned(), |syntax| syntax.name.clone()) +} + +fn prefs_id() -> egui::Id { + egui::Id::new("notescraft-source-language-prefs") +} + +fn action_id() -> egui::Id { + egui::Id::new("notescraft-source-language-action") +} + +fn current_id() -> egui::Id { + egui::Id::new("notescraft-source-language-current") +} + +/// Hands the view the saved choices (the app calls this every frame). +pub fn set_prefs(ctx: &Context, prefs: Prefs) { + ctx.data_mut(|data| data.insert_temp(prefs_id(), prefs)); +} + +/// The saved choices the app handed over. +#[must_use] +pub fn prefs(ctx: &Context) -> Prefs { + ctx.data(|data| data.get_temp::(prefs_id())).unwrap_or_default() +} + +/// Posts what the user did. +pub fn post(ctx: &Context, action: Action) { + ctx.data_mut(|data| data.insert_temp(action_id(), action)); + ctx.request_repaint(); +} + +/// Takes the action the user posted this frame. +#[must_use] +pub fn take_action(ctx: &Context) -> Option { + ctx.data_mut(|data| { + let action = data.get_temp::(action_id()); + data.remove::(action_id()); + action + }) +} + +/// Remembers how the open source file is highlighted, for the chip of the tab header. +pub fn set_current(ctx: &Context, path: &str, language: Option<&str>) { + ctx.data_mut(|data| data.insert_temp(current_id(), (path.to_owned(), language.map(str::to_owned)))); +} + +/// The grammar of the open source file `path` as the view last resolved it. +#[must_use] +pub fn current(ctx: &Context, path: &str) -> Option { + ctx.data(|data| data.get_temp::<(String, Option)>(current_id())).filter(|(open, _)| open == path).and_then(|(_, language)| language) +} + +/// Applies an action to the saved choices, within the caps. +pub fn apply(prefs: &mut Prefs, action: Action) { + match action { + Action::Choose { key, language } => { + prefs.ignored.retain(|ignored| *ignored != key); + if prefs.chosen.len() < MAX_ENTRIES || prefs.chosen.contains_key(&key) { + prefs.chosen.insert(key, language); + } + } + Action::Ignore { key } => { + if prefs.ignored.len() < MAX_ENTRIES && !prefs.ignored.contains(&key) { + prefs.ignored.push(key); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{Action, Prefs, apply, languages, needs_hint, resolve, type_of}; + + fn chosen() -> std::collections::BTreeMap { + std::collections::BTreeMap::new() + } + + #[test] + fn the_file_type_decides() { + assert_eq!(type_of("a/b/Publish.CSS"), ("css".to_owned(), ".CSS".to_owned())); + assert_eq!(type_of("x/Makefile"), ("makefile".to_owned(), "Makefile".to_owned())); + assert_eq!(type_of(".gitignore"), (".gitignore".to_owned(), ".gitignore".to_owned())); + for path in ["a.css", "a.js", "a.json", "a.py", "a.rs", "a.ts", "a.toml", "a.yaml", "a.sh", "Makefile"] { + assert!(resolve(path, "", &chosen()).is_some(), "{path}"); + } + assert_eq!(resolve("notes.txt", "", &chosen()).as_deref(), Some("text")); + assert_eq!(resolve("data.xyz", "", &chosen()), None); + } + + #[test] + fn the_shebang_is_the_fallback_and_the_choice_wins() { + assert!(resolve("run", "#!/usr/bin/env python3", &chosen()).is_some()); + assert!(resolve("run", "#!/bin/bash -e", &chosen()).is_some()); + assert_eq!(resolve("run", "echo hi", &chosen()), None); + let mut picked = chosen(); + picked.insert("xyz".to_owned(), "rust".to_owned()); + assert_eq!(resolve("a.xyz", "", &picked).as_deref(), Some("rust")); + picked.insert("css".to_owned(), "rust".to_owned()); + assert_eq!(resolve("a.css", "", &picked).as_deref(), Some("rust"), "the choice beats the type"); + } + + #[test] + fn the_hint_shows_for_unknown_types_until_ignored_or_chosen() { + let mut prefs = Prefs::default(); + assert!(needs_hint("a.xyz", resolve("a.xyz", "", &prefs.chosen).as_deref(), &prefs)); + assert!(!needs_hint("a.css", resolve("a.css", "", &prefs.chosen).as_deref(), &prefs)); + apply(&mut prefs, Action::Ignore { key: "xyz".to_owned() }); + assert!(!needs_hint("a.xyz", None, &prefs)); + apply(&mut prefs, Action::Choose { key: "xyz".to_owned(), language: "rust".to_owned() }); + assert_eq!(prefs.ignored.len(), 0); + assert!(!needs_hint("a.xyz", resolve("a.xyz", "", &prefs.chosen).as_deref(), &prefs)); + } + + #[test] + fn the_picker_lists_the_common_languages() { + let all = languages(); + assert_eq!(all.first().map(String::as_str), Some("Plain Text")); + for name in ["Rust", "JavaScript", "Python", "JSON"] { + assert!(all.iter().any(|language| language == name), "{name}"); + } + } +} diff --git a/crates/ui-egui/src/views/code_view/language_ui.rs b/crates/ui-egui/src/views/code_view/language_ui.rs new file mode 100644 index 0000000..6a786ea --- /dev/null +++ b/crates/ui-egui/src/views/code_view/language_ui.rs @@ -0,0 +1,110 @@ +//! The language controls of a source file's tab: the hint banner for a file type no grammar fits and the +//! chip in the tab header that changes the language at any time. Both open the same searchable picker. + +use egui::{Align2, CornerRadius, FontId, Margin, Rect, Response, Sense, Ui, Vec2}; + +use super::language::{self, Action, PLAIN}; +use crate::text::code_view as strings; +use crate::theme::{Tokens, color}; + +/// Text size of the banner and the chip, in points. +const TEXT_SIZE: f32 = 12.0; +/// Space around the chip's text, in points. +const CHIP_PAD: f32 = 10.0; +/// Side of the chip's arrow, in points. +const ARROW: f32 = 12.0; +/// Width of the picker, in points. +const PICKER_WIDTH: f32 = 240.0; +/// Tallest the picker's list grows, in points. +const PICKER_HEIGHT: f32 = 260.0; +/// Space between banner parts, in points. +const BANNER_GAP: f32 = 8.0; + +/// The banner under the tab header: no grammar fits `path`'s type. Install one, pick a language or ignore it. +pub fn banner(ui: &mut Ui, tokens: &Tokens, path: &str) { + let colors = &tokens.theme.colors; + let (key, shown) = language::type_of(path); + egui::Frame::new().fill(color(colors.surface_2)).corner_radius(tokens.radius_small()).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| { + ui.set_width(ui.available_width()); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = BANNER_GAP; + ui.label(egui::RichText::new(strings::banner().replace("{{type}}", &shown) /* i18n-ok */).size(TEXT_SIZE).color(color(colors.text_muted))); + if ui.add(button(tokens, strings::install(), false)).clicked() { + crate::note_view::request_grammar(ui.ctx(), &key); + } + let other = ui.add(button(tokens, strings::other(), false)); + picker(&other, &key, None); + if ui.add(button(tokens, strings::ignore(), false)).clicked() { + language::post(ui.ctx(), Action::Ignore { key }); + } + }); + }); + ui.add_space(BANNER_GAP); +} + +/// The language chip in `rect` (the place the Visual/Source toggle takes in notes): shows the language of +/// `path` and opens the picker. +pub fn chip(ui: &mut Ui, tokens: &Tokens, rect: Rect, path: &str) { + let colors = &tokens.theme.colors; + let name = language::name_of(language::current(ui.ctx(), path).as_deref()); + let hit = ui.interact(rect, egui::Id::new(("notescraft-language-chip", path)), Sense::click()); + ui.painter().rect_filled(rect, CornerRadius::same(crate::style::radius_u8(tokens.radius_small())), color(colors.surface)); + let tint = color(colors.text); + let font = FontId::new(TEXT_SIZE, crate::fonts::semibold()); + // The room left of the arrow: the full label when it fits, the bare language name when it does not. + let room = rect.width() - CHIP_PAD * 2.0 - ARROW; + let full = strings::chip().replace("{{language}}", &name); + let fits = |text: &str| ui.painter().layout_no_wrap(text.to_owned(), font.clone(), tint).size().x <= room; + let label = if fits(&full) { full } else { name.clone() }; + hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + let painter = ui.painter().with_clip_rect(Rect::from_min_max(rect.min, egui::pos2(rect.right() - CHIP_PAD - ARROW, rect.bottom()))); + painter.text(egui::pos2(rect.left() + CHIP_PAD, rect.center().y), Align2::LEFT_CENTER, label, font, tint); + let arrow = Rect::from_center_size(egui::pos2(rect.right() - CHIP_PAD - ARROW / 2.0, rect.center().y), Vec2::splat(ARROW)); + crate::icons::paint(ui, "chevron-down", arrow, color(colors.text_muted)); + picker(&hit, &language::type_of(path).0, Some(&name)); +} + +/// A flat button in the theme's tones. +fn button<'a>(tokens: &Tokens, text: &'a str, selected: bool) -> egui::Button<'a> { + let colors = &tokens.theme.colors; + let fill = if selected { colors.selection } else { colors.surface }; + egui::Button::new(egui::RichText::new(text).size(TEXT_SIZE).color(color(colors.text))).fill(color(fill)).corner_radius(tokens.radius_small()) +} + +/// The searchable list of languages, a menu under `anchor`; choosing one remembers it for files of type `key`. +fn picker(anchor: &Response, key: &str, current: Option<&str>) { + let ctx = anchor.ctx.clone(); + let query_id = anchor.id.with("notescraft-language-query"); + let shown = egui::Popup::menu(anchor).width(PICKER_WIDTH).show(|ui| { + let mut query = ui.data_mut(|data| data.get_temp::(query_id)).unwrap_or_default(); + let search = ui.add(egui::TextEdit::singleline(&mut query).hint_text(strings::search()).desired_width(f32::INFINITY)); + if !ui.memory(|memory| memory.has_focus(search.id)) && !search.lost_focus() { + search.request_focus(); + } + let needle = query.to_lowercase(); + let matches: Vec = language::languages().into_iter().filter(|name| needle.is_empty() || name.to_lowercase().contains(&needle)).collect(); + let mut picked = None; + if search.lost_focus() && ui.input(|input| input.key_pressed(egui::Key::Enter)) { + picked = matches.first().cloned(); + } + egui::ScrollArea::vertical().max_height(PICKER_HEIGHT).show(ui, |ui| { + for name in matches.iter().filter(|name| !name.is_empty()) { + if ui.selectable_label(current == Some(name.as_str()), name).clicked() { + picked = Some(name.clone()); + } + } + if matches.is_empty() { + ui.label(strings::no_match()); + } + }); + ui.data_mut(|data| data.insert_temp(query_id, query)); + if let Some(name) = picked { + let language = if name == "Plain Text" { PLAIN.to_owned() } else { name }; + language::post(ui.ctx(), Action::Choose { key: key.to_owned(), language }); + ui.close(); + } + }); + if shown.is_none() { + ctx.data_mut(|data| data.remove_temp::(query_id)); + } +} diff --git a/crates/ui-egui/src/views/code_view/mod.rs b/crates/ui-egui/src/views/code_view/mod.rs index b4fa1fe..85d08a1 100644 --- a/crates/ui-egui/src/views/code_view/mod.rs +++ b/crates/ui-egui/src/views/code_view/mod.rs @@ -1,7 +1,13 @@ //! The read-only code and text viewer: highlighted rows with line numbers in the theme's monospace -//! font. It is not wired into the tab strip yet — code files still open in the text editor. +//! font, and [`edit`], the editable twin the tabs of code and text files use. +mod editor; mod highlight; +pub mod language; +mod language_ui; + +pub use editor::{MAX_EDIT_BYTES, edit}; +pub use language_ui::{banner, chip}; pub use highlight::{MAX_LINE_CHARS, MAX_LINES, Span, TokenClass, highlight_lines}; @@ -21,16 +27,38 @@ const GUTTER_GAP: &str = " "; /// `ext`: line numbers, highlighted rows in the theme's monospace font and, when the line cap cut /// the document short, a notice saying so. pub fn show(ui: &mut Ui, tokens: &Tokens, text: &str, ext: &str) { - let lines = highlight_lines(text, ext); + let (lines, total) = highlighted(ui.ctx(), text, ext); let mono = FontId::monospace(CODE_SIZE); let digits = lines.len().to_string().len(); - egui::ScrollArea::both().id_salt("notescraft-code-view").auto_shrink([false, false]).show(ui, |ui| { + let row_height = ui.fonts_mut(|fonts| fonts.row_height(&mono)) + ROW_GAP; + // Only the rows in view are laid out: a file of 20 000 lines costs the same as one of 20. + egui::ScrollArea::both().id_salt("notescraft-code-view").auto_shrink([false, false]).show_rows(ui, row_height, lines.len(), |ui, rows| { ui.spacing_mut().item_spacing.y = ROW_GAP; - for (index, spans) in lines.iter().enumerate() { - draw_row(ui, tokens, gutter_text(index.saturating_add(1), digits).as_str(), spans, &mono); + for index in rows { + if let Some(spans) = lines.get(index) { + draw_row(ui, tokens, gutter_text(index.saturating_add(1), digits).as_str(), spans, &mono); + } } - notice(ui, tokens, lines.len(), text.lines().count()); }); + notice(ui, tokens, lines.len(), total); +} + +/// The highlighted lines of `text` and its real line count. The text is large and unchanged from frame to +/// frame, so the answer is kept against the text's place and length instead of being computed again. +fn highlighted(ctx: &egui::Context, text: &str, ext: &str) -> (std::sync::Arc>>, usize) { + type Kept = (usize, usize, String, std::sync::Arc>>, usize); + let id = egui::Id::new("notescraft-code-view-lines"); + let key = (text.as_ptr() as usize, text.len()); + if let Some((ptr, len, kept_ext, lines, total)) = ctx.data(|data| data.get_temp::(id)) + && (ptr, len) == key + && kept_ext == ext + { + return (lines, total); + } + let lines = std::sync::Arc::new(highlight_lines(text, ext)); + let total = text.lines().count(); + ctx.data_mut(|data| data.insert_temp::(id, (key.0, key.1, ext.to_owned(), lines.clone(), total))); + (lines, total) } /// The right-aligned line number of row `number`, padded to `digits` digits plus a gap. diff --git a/crates/ui-egui/src/views/file_card.rs b/crates/ui-egui/src/views/file_card.rs index 862c8dd..d269c53 100644 --- a/crates/ui-egui/src/views/file_card.rs +++ b/crates/ui-egui/src/views/file_card.rs @@ -2,7 +2,8 @@ //! card of audio, video, PDF and unknown files, with `file.info`-like properties and //! Open-with-the-system, which the shell runs through its existing helper. -use egui::{Align, Align2, CornerRadius, FontId, Layout, Margin, Rect, RichText, Sense, Ui, Vec2}; +use egui::text::LayoutJob; +use egui::{Align, Align2, Color32, CornerRadius, FontId, Label, Layout, Margin, Rect, RichText, Sense, Ui, Vec2}; use notescraft_engine::Session; use notescraft_templates::{Date, DateTime}; @@ -31,8 +32,12 @@ const CARD_PADDING: i8 = 24; const ICON_SIZE: f32 = 56.0; /// Side of the icon glyph inside its square, points. const ICON_INNER: f32 = 24.0; -/// Width of the property table, points. -const ROWS_WIDTH: f32 = 240.0; +/// Width of the label column of the property table, points. +const LABEL_WIDTH: f32 = 72.0; +/// Space between the label column and the values, points. +const LABEL_GAP: f32 = 8.0; +/// The picture format no decoder here reads; its card says why. +const AVIF: &str = "avif"; /// Text size of one property row, points. const ROW_TEXT: f32 = 12.0; /// Space between property rows, points. @@ -60,8 +65,7 @@ pub fn show(ui: &mut Ui, tokens: &Tokens, path: &str) { /// kind icon, the name, the properties and Open-with-the-system. Returns the file the shell opened /// with the system when the button was clicked this frame. pub fn show_info(ui: &mut Ui, tokens: &Tokens, session: &Session, path: &str) -> Option { - let colors = &tokens.theme.colors; - let kind = TabKind::of(path); + let info = info_of(session, path); let mut opened = None; ui.vertical(|ui| { let full = ui.max_rect(); @@ -69,28 +73,66 @@ pub fn show_info(ui: &mut Ui, tokens: &Tokens, session: &Session, path: &str) -> let mut card = ui.new_child(egui::UiBuilder::new().id_salt("notescraft-file-card").max_rect(column)); // The estimate of the card's height, as a spacer above it, centres the card on the tab. card.add_space(((card.max_rect().height() - CARD_HEIGHT) / 2.0).max(0.0)); - egui::Frame::new().fill(color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::same(CARD_PADDING)).show(&mut card, |ui| { - ui.vertical_centered(|ui| { - ui.set_min_width(CARD_WIDTH - f32::from(CARD_PADDING) * 2.0); - backdrop(ui, tokens, kind.icon()); - ui.add_space(ICON_GAP); - ui.label(RichText::new(name_of(path)).font(FontId::new(NAME_SIZE, crate::fonts::semibold())).color(color(colors.text))); - if matches!(kind, TabKind::Video | TabKind::Audio | TabKind::Pdf) { - ui.label(RichText::new(strings::soon()).size(NOTE_SIZE).color(color(colors.text_muted))); - } - ui.add_space(ACTION_GAP); - rows(ui, tokens, kind, session, path); - ui.add_space(ACTION_GAP); - if ui.add(open_button(tokens)).clicked() { - opened = Some(path.to_owned()); - } - }); - }); + opened = card_in(&mut card, tokens, &info, path); ui.allocate_rect(card.min_rect(), Sense::hover()); }); opened } +/// The card itself, drawn into `ui` at the width it has; `Some` when the button was clicked. +fn card_in(ui: &mut Ui, tokens: &Tokens, info: &Info, path: &str) -> Option { + let colors = &tokens.theme.colors; + let kind = TabKind::of(path); + let mut opened = None; + egui::Frame::new().fill(color(colors.surface)).corner_radius(tokens.radius_medium()).inner_margin(Margin::same(CARD_PADDING)).show(ui, |ui| { + ui.vertical_centered(|ui| { + let width = ui.available_width().min(CARD_WIDTH - f32::from(CARD_PADDING) * 2.0); + ui.set_width(width); + backdrop(ui, tokens, kind.icon()); + ui.add_space(ICON_GAP); + let font = FontId::new(NAME_SIZE, crate::fonts::semibold()); + ui.add(Label::new(wrapped(name_of(path), font, color(colors.text), width, Align::Center))); + if let Some(note) = note_of(kind, path) { + ui.add(Label::new(wrapped(note, FontId::proportional(NOTE_SIZE), color(colors.text_muted), width, Align::Center))); + } + ui.add_space(ACTION_GAP); + rows(ui, tokens, kind, info, path, width); + ui.add_space(ACTION_GAP); + if ui.add(open_button(tokens)).clicked() { + opened = Some(path.to_owned()); + } + }); + }); + opened +} + +/// Whether `path` is a picture format this app has no decoder for: its tab is the info card. +#[must_use] +pub fn is_undecodable_picture(path: &str) -> bool { + extension_of(path) == AVIF +} + +/// The line under the name: why there is no preview. +fn note_of(kind: TabKind, path: &str) -> Option<&'static str> { + if is_undecodable_picture(path) { + return Some(strings::avif_reason()); + } + matches!(kind, TabKind::Video | TabKind::Audio | TabKind::Pdf).then(strings::soon) +} + +/// The lowercase extension of a path, or nothing. +fn extension_of(path: &str) -> String { + name_of(path).rsplit_once('.').map_or_else(String::new, |(_, ext)| ext.to_ascii_lowercase()) +} + +/// `text` as a layout that wraps at `width` and breaks inside a word that is longer than the line. +fn wrapped(text: &str, font: FontId, tint: Color32, width: f32, align: Align) -> LayoutJob { + let mut job = LayoutJob::simple(text.to_owned(), font, tint, width); + job.wrap.break_anywhere = true; + job.halign = align; + job +} + /// The rounded icon square at the top of the card, with the tab kind's icon inside it. fn backdrop(ui: &mut Ui, tokens: &Tokens, icon: &str) { let colors = &tokens.theme.colors; @@ -99,29 +141,70 @@ fn backdrop(ui: &mut Ui, tokens: &Tokens, icon: &str) { crate::icons::paint(ui, icon, Rect::from_center_size(rect.center(), Vec2::splat(ICON_INNER)), color(colors.text_muted)); } -/// The property table: kind, size, last modification and the vault-relative path. -fn rows(ui: &mut Ui, tokens: &Tokens, kind: TabKind, session: &Session, path: &str) { - let info = info_of(session, path); - ui.allocate_ui_with_layout(Vec2::new(ROWS_WIDTH, 0.0), Layout::top_down(Align::Min), |ui| { - row(ui, tokens, strings::label_type(), kind_label(kind)); - row(ui, tokens, strings::label_size(), size_text(info.size).as_str()); - row(ui, tokens, strings::label_modified(), modified_text(info.modified_ms).as_str()); - row(ui, tokens, strings::label_path(), path); - }); +/// The property table: a label column of fixed width and the values beside it, wrapped to the card. +fn rows(ui: &mut Ui, tokens: &Tokens, kind: TabKind, info: &Info, path: &str, width: f32) { + let value_width = (width - LABEL_WIDTH - LABEL_GAP).max(0.0); + let kind_text = kind_label(kind, path); + for (label, value) in [ + (strings::label_type(), kind_text.to_owned()), + (strings::label_size(), size_text(info.size)), + (strings::label_modified(), modified_text(info.modified_ms)), + ] { + row(ui, tokens, label, &value, value.clone(), value_width); + } + let shown = elide_middle(ui, path, &FontId::proportional(ROW_TEXT), value_width); + row(ui, tokens, strings::label_path(), &shown, path.to_owned(), value_width); } -/// One property row: the muted label left, the value right. -fn row(ui: &mut Ui, tokens: &Tokens, label: &str, value: &str) { +/// One property row. `value` is what is drawn, `full` what a click copies and the tooltip shows. +fn row(ui: &mut Ui, tokens: &Tokens, label: &str, value: &str, full: String, value_width: f32) { let colors = &tokens.theme.colors; - ui.with_layout(Layout::left_to_right(Align::Center), |ui| { - ui.label(RichText::new(label).size(ROW_TEXT).color(color(colors.text_muted))); - ui.with_layout(Layout::right_to_left(Align::Center), |ui| { - ui.label(RichText::new(value).size(ROW_TEXT).color(color(colors.text))); + let font = FontId::proportional(ROW_TEXT); + ui.allocate_ui_with_layout(Vec2::new(LABEL_WIDTH + LABEL_GAP + value_width, 0.0), Layout::left_to_right(Align::Min), |ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.allocate_ui(Vec2::new(LABEL_WIDTH, 0.0), |ui| { + ui.set_width(LABEL_WIDTH); + ui.add(Label::new(RichText::new(label).font(font.clone()).color(color(colors.text_muted))).wrap_mode(egui::TextWrapMode::Truncate)); + }); + ui.add_space(LABEL_GAP); + ui.allocate_ui(Vec2::new(value_width, 0.0), |ui| { + ui.set_width(value_width); + let shown = ui.add(Label::new(wrapped(value, font, color(colors.text), value_width, Align::Min)).sense(Sense::click())); + let hint = if value == full { strings::copy_hint().to_owned() } else { full.clone() }; + let shown = shown.on_hover_text(hint).on_hover_cursor(egui::CursorIcon::PointingHand); + if shown.clicked() { + ui.ctx().copy_text(full); + } }); }); ui.add_space(ROW_GAP); } +/// `text` cut in the middle with an ellipsis until it is no wider than `max_width`: both ends of a path stay readable. +fn elide_middle(ui: &Ui, text: &str, font: &FontId, max_width: f32) -> String { + let chars: Vec = text.chars().collect(); + let width_of = |candidate: &str| ui.painter().layout_no_wrap(candidate.to_owned(), font.clone(), Color32::WHITE).size().x; + if width_of(text) <= max_width { + return text.to_owned(); + } + let joined = |kept: usize| { + let head: String = chars.iter().take(kept.div_ceil(2)).collect(); + let tail: String = chars.iter().skip(chars.len().saturating_sub(kept / 2)).collect(); + format!("{head}…{tail}") + }; + // The widest cut that still fits; the width grows with the characters kept. + let (mut low, mut high) = (0_usize, chars.len()); + while low < high { + let middle = low + (high - low).div_ceil(2); + if width_of(&joined(middle)) <= max_width { + low = middle; + } else { + high = middle - 1; + } + } + joined(low) +} + /// The primary Open-with-the-system button of the card. fn open_button(tokens: &Tokens) -> egui::Button<'_> { egui::Button::new( @@ -138,8 +221,9 @@ fn name_of(path: &str) -> &str { } /// The label of the file kind, for the type row; the card is only drawn for kinds without a viewer. -fn kind_label(kind: TabKind) -> &'static str { +fn kind_label(kind: TabKind, path: &str) -> &'static str { match kind { + TabKind::Image if is_undecodable_picture(path) => strings::kind_image(), TabKind::Video => strings::kind_video(), TabKind::Audio => strings::kind_audio(), TabKind::Pdf => strings::kind_pdf(), @@ -206,17 +290,17 @@ fn unix_ms(moment: std::time::SystemTime) -> Option { #[cfg(test)] mod tests { - use super::{kind_label, modified_text, moment_of, name_of, size_text}; + use super::{Info, card_in, kind_label, modified_text, moment_of, name_of, size_text}; use crate::tabs::TabKind; use crate::text::file_card as strings; #[test] fn kinds_carry_their_label() { - assert_eq!(kind_label(TabKind::Video), strings::kind_video()); - assert_eq!(kind_label(TabKind::Audio), strings::kind_audio()); - assert_eq!(kind_label(TabKind::Pdf), strings::kind_pdf()); - assert_eq!(kind_label(TabKind::Other), strings::kind_other()); + assert_eq!(kind_label(TabKind::Video, "a.mp4"), strings::kind_video()); + assert_eq!(kind_label(TabKind::Audio, "x"), strings::kind_audio()); + assert_eq!(kind_label(TabKind::Pdf, "x"), strings::kind_pdf()); + assert_eq!(kind_label(TabKind::Other, "x"), strings::kind_other()); } #[test] @@ -250,4 +334,46 @@ mod tests { assert_eq!(size_text(Some(1024)), "1 KB"); assert_eq!(size_text(Some(3000)), "3 KB"); } + + /// Paints the card for `path` at `width` and returns the rectangle of every text it drew. + fn text_rects(path: &str, width: f32) -> Vec<(String, egui::Rect)> { + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return Vec::new() }; + let tokens = crate::theme::Tokens::new(theme); + let ctx = egui::Context::default(); + crate::fonts::install(&ctx); + let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 900.0))), ..Default::default() }; + let info = Info { size: Some(5_000_000), modified_ms: Some(1_790_000_000_000) }; + let mut rects = Vec::new(); + for _ in 0..2 { + let mut out = ctx.run_ui(input.clone(), |ui| { + card_in(ui, &tokens, &info, path); + }); + out.textures_delta.clear(); + rects = out + .shapes + .iter() + .filter_map(|row| match &row.shape { + egui::Shape::Text(shaped) => Some((shaped.galley.text().to_owned(), shaped.visual_bounding_rect())), + _ => None, + }) + .collect(); + } + rects + } + + #[test] + fn a_very_long_path_and_name_overlap_nothing() { + let name = format!("{}.ogg", "Excerpt-of-a-very-long-recording-name-".repeat(5)); + let path = format!("bn/Attachments/{}/{name}", "audio".repeat(20)); + assert!(path.len() >= 200 && name.len() >= 150); + let rects = text_rects(&path, 600.0); + assert!(rects.len() >= 6, "{rects:?}"); + for (index, (first_text, first)) in rects.iter().enumerate() { + assert!(first.width() <= 330.0, "{first_text:?} is wider than the card: {first:?}"); + for (second_text, second) in rects.iter().skip(index + 1) { + assert!(!first.shrink(0.5).intersects(second.shrink(0.5)), "{first_text:?} {first:?} overlaps {second_text:?} {second:?}"); + } + } + assert!(rects.iter().any(|(text, _)| text.contains('…')), "the path is cut in the middle"); + } } diff --git a/crates/ui-egui/src/views/folder_view.rs b/crates/ui-egui/src/views/folder_view.rs index 45779da..e615614 100644 --- a/crates/ui-egui/src/views/folder_view.rs +++ b/crates/ui-egui/src/views/folder_view.rs @@ -72,10 +72,6 @@ fn icon_of(node: &TreeNode) -> &'static str { if node.folder { return "folder"; } - let lower = node.path.to_ascii_lowercase(); - if [".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp"].iter().any(|ext| lower.ends_with(ext)) { - return "image"; - } crate::tabs::TabKind::of(&node.path).icon() } diff --git a/crates/ui-egui/src/views/image_view/annotate/draw.rs b/crates/ui-egui/src/views/image_view/annotate/draw.rs index 028d2db..6011fd2 100644 --- a/crates/ui-egui/src/views/image_view/annotate/draw.rs +++ b/crates/ui-egui/src/views/image_view/annotate/draw.rs @@ -4,15 +4,16 @@ //! the placement of the picture before it is drawn. The colour of a mark is a //! theme colour, resolved with the theme that is on now. +use egui::epaint::TextShape; use egui::{Color32, FontId, Pos2, Rect, Shape, Stroke, StrokeKind, Vec2}; use notescraft_annotate::{ColorRef, Item, Point, StrokeTool}; -use crate::theme::Tokens; +use crate::theme::{Tokens, color}; use super::Picture; use super::State; use super::colour_of; -use super::place::to_screen; +use super::place::{arrow_barbs, picture_rect, to_screen}; /// The alpha of a highlighter stroke, over the picture. const HIGHLIGHTER_ALPHA: u8 = 96; @@ -20,25 +21,47 @@ const HIGHLIGHTER_ALPHA: u8 = 96; /// The width of a highlighter stroke, as a multiple of the width of the tool. const HIGHLIGHTER_SCALE: f32 = 3.0; -/// The length of the head of an arrow, as a multiple of the width of the line. -const ARROW_HEAD: f32 = 4.0; +/// The room around the text of a label, as a multiple of its font size. +const PILL_PAD: f32 = 0.25; -/// The half angle of the head of an arrow, in radians. -const ARROW_ANGLE: f32 = 0.42; +/// The share of the background tone the pill under a label keeps, so the picture shows through. +const PILL_OPACITY: f32 = 0.7; -/// Paints the marks of the state over the picture. +/// Side of the squares on the corners of the selected label, in points. +const HANDLE: f32 = 6.0; + +/// Width of the outline of the selected label, in points. +const SELECTION: f32 = 1.5; + +/// Paints the marks of the state over the picture, never outside it. pub fn paint(ui: &egui::Ui, tokens: &Tokens, state: &State, picture: &Picture, canvas: Rect) { - let painter = ui.painter().with_clip_rect(canvas); - for item in &state.notes.items { - paint_item(&painter, tokens, item, picture, canvas); + if picture.size.is_none() { + return; + } + let painter = ui.painter().with_clip_rect(picture_rect(picture, canvas).intersect(canvas)); + let editing = state.label.as_ref().and_then(|label| label.index); + for (index, item) in state.notes.items.iter().enumerate() { + if editing == Some(index) { + continue; + } + let selected = state.selected == Some(index) && state.label.is_none(); + paint_item(&painter, tokens, item, picture, canvas, selected); } if let Some(draft) = &state.draft { - paint_item(&painter, tokens, draft, picture, canvas); + paint_item(&painter, tokens, draft, picture, canvas, false); + } +} + +/// A segment with round caps, as shapes. +fn capped(shapes: &mut Vec, start: Pos2, end: Pos2, stroke: Stroke) { + shapes.push(Shape::line_segment([start, end], stroke)); + for cap in [start, end] { + shapes.push(Shape::circle_filled(cap, stroke.width / 2.0, stroke.color)); } } /// Paints one mark. -fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &Picture, canvas: Rect) { +fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &Picture, canvas: Rect, selected: bool) { let zoom = picture.placement.zoom; match item { Item::Stroke { tool, color, width, points } => { @@ -48,11 +71,15 @@ fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &P Item::Line { color, width, from, to, arrow } => { let start = to_screen(picture, canvas, *from); let end = to_screen(picture, canvas, *to); - let stroke = Stroke::new(width * zoom, colour_of(tokens, *color)); - painter.add(Shape::line(vec![start, end], stroke)); - if *arrow { - paint_arrow(painter, start, end, stroke); + let stroke = Stroke::new((width * zoom).max(1.0), colour_of(tokens, *color)); + let mut shapes = Vec::new(); + capped(&mut shapes, start, end, stroke); + if *arrow && let Some(barbs) = arrow_barbs(start, end, stroke.width) { + for barb in barbs { + capped(&mut shapes, end, barb, stroke); + } } + painter.add(Shape::Vec(shapes)); } Item::Rect { color, width, min, max, filled } => { let rect = box_of(picture, canvas, *min, *max); @@ -71,13 +98,45 @@ fn paint_item(painter: &egui::Painter, tokens: &Tokens, item: &Item, picture: &P painter.add(Shape::ellipse_stroke(rect.center(), rect.size() / 2.0, stroke)); } Item::Text { color, size, at, text } => { - let anchor = to_screen(picture, canvas, *at); - let font = FontId::new(size * zoom, egui::FontFamily::Proportional); - painter.text(anchor, egui::Align2::LEFT_TOP, text.clone(), font, colour_of(tokens, *color)); + let area = paint_text(painter, tokens, picture, canvas, *at, *size, text, colour_of(tokens, *color)); + if selected { + paint_selection(painter, tokens, area); + } } } } +/// Paints a label on a pill, turned with the picture; answers the box it covers on the canvas. +#[expect(clippy::too_many_arguments, reason = "a label is its place, size, text and colour; a struct would only rename them")] +fn paint_text(painter: &egui::Painter, tokens: &Tokens, picture: &Picture, canvas: Rect, at: Point, size: f32, text: &str, ink: Color32) -> Rect { + let font = FontId::new(size * picture.placement.zoom, egui::FontFamily::Proportional); + let galley = painter.layout_no_wrap(text.to_owned(), font.clone(), ink); + let anchor = to_screen(picture, canvas, at); + let angle = picture.placement.rotation; + let (turn_sin, turn_cos) = angle.sin_cos(); + let pad = font.size * PILL_PAD; + let extent = galley.size(); + let mut area = Rect::NOTHING; + for corner in [Vec2::new(-pad, -pad), Vec2::new(extent.x + pad, -pad), Vec2::new(extent.x + pad, extent.y + pad), Vec2::new(-pad, extent.y + pad)] { + area.extend_with(anchor + Vec2::new(corner.x * turn_cos - corner.y * turn_sin, corner.x * turn_sin + corner.y * turn_cos)); + } + // ponytail: the pill is the box of the turned corners, exact on quarter turns; a turn in + // between (while the picture is spinning) shows a slightly larger pill. + let pill = color(tokens.theme.colors.background).gamma_multiply(PILL_OPACITY); + painter.add(Shape::rect_filled(area, pad, pill)); + painter.add(TextShape::new(anchor, galley, ink).with_angle_and_anchor(angle, egui::Align2::LEFT_TOP)); + area +} + +/// Outlines the selected label with a square on each corner. +fn paint_selection(painter: &egui::Painter, tokens: &Tokens, area: Rect) { + let accent = color(tokens.theme.colors.accent); + painter.add(Shape::rect_stroke(area, 0.0, Stroke::new(SELECTION, accent), StrokeKind::Outside)); + for corner in [area.left_top(), area.right_top(), area.right_bottom(), area.left_bottom()] { + painter.add(Shape::rect_filled(Rect::from_center_size(corner, Vec2::splat(HANDLE)), 1.0, accent)); + } +} + /// Paints a freehand stroke; a stroke of one point is a dot. fn paint_stroke(painter: &egui::Painter, tokens: &Tokens, tool: StrokeTool, colour: ColorRef, width: f32, screen: &[Pos2]) { let base = colour_of(tokens, colour); @@ -97,23 +156,6 @@ fn paint_stroke(painter: &egui::Painter, tokens: &Tokens, tool: StrokeTool, colo } } -/// Paints the head of an arrow at the end of a line. -fn paint_arrow(painter: &egui::Painter, start: Pos2, end: Pos2, stroke: Stroke) { - let length = (end - start).length(); - if length <= 0.0 { - return; - } - let delta = end - start; - let direction = delta / length; - let head = (stroke.width * ARROW_HEAD).max(4.0); - let angle = ARROW_ANGLE; - let (sine, cosine) = angle.sin_cos(); - for flip in [1.0_f32, -1.0_f32] { - let wing = Vec2::new(direction.x * cosine - delta.y * sine * flip, direction.x * sine + delta.y * cosine * flip); - painter.add(Shape::line(vec![end, end - wing * head], stroke)); - } -} - /// The box on the canvas that two corners of the picture span. fn box_of(picture: &Picture, canvas: Rect, min: Point, max: Point) -> Rect { let first = to_screen(picture, canvas, min); diff --git a/crates/ui-egui/src/views/image_view/annotate/keys.rs b/crates/ui-egui/src/views/image_view/annotate/keys.rs new file mode 100644 index 0000000..a9301e3 --- /dev/null +++ b/crates/ui-egui/src/views/image_view/annotate/keys.rs @@ -0,0 +1,43 @@ +//! The keys of the draw mode: one letter for each tool, undo and redo, Delete and Enter. +//! +//! They answer only while no text field has the keyboard, so typing a label never switches a tool. + +use egui::{Context, Key, KeyboardShortcut, Modifiers}; + +use super::tools::commit_label; +use super::{State, Tool, text}; + +/// Undo. +pub const UNDO: KeyboardShortcut = KeyboardShortcut::new(Modifiers::COMMAND, Key::Z); + +/// Redo. +pub const REDO: KeyboardShortcut = KeyboardShortcut::new(Modifiers { shift: true, ..Modifiers::COMMAND }, Key::Z); + +/// Answers the keys of the frame. +pub(super) fn handle(ctx: &Context, state: &mut State) { + if ctx.text_edit_focused() { + return; + } + let (redo, undo, done, delete, tool) = ctx.input_mut(|input| { + let redo = input.consume_shortcut(&REDO); + let undo = input.consume_shortcut(&UNDO); + let plain = !input.modifiers.command && !input.modifiers.alt; + let tool = if plain { Tool::ALL.into_iter().find(|tool| input.key_pressed(tool.key())) } else { None }; + (redo, undo, plain && input.key_pressed(Key::Enter), plain && (input.key_pressed(Key::Delete) || input.key_pressed(Key::Backspace)), tool) + }); + if redo { + state.redo(); + } else if undo { + state.undo(); + } + if delete { + text::delete_selected(state); + } + if let Some(tool) = tool { + state.pick(tool); + } + if done { + commit_label(state); + state.active = false; + } +} diff --git a/crates/ui-egui/src/views/image_view/annotate/mod.rs b/crates/ui-egui/src/views/image_view/annotate/mod.rs index 5b12990..3a4e973 100644 --- a/crates/ui-egui/src/views/image_view/annotate/mod.rs +++ b/crates/ui-egui/src/views/image_view/annotate/mod.rs @@ -16,18 +16,24 @@ mod banner; mod draw; mod io; +mod keys; mod palette; mod place; +mod text; +mod tool; mod tools; use std::time::Instant; use egui::{Color32, Pos2, Rect, Ui, Vec2}; -use notescraft_annotate::{Annotations, ColorRef, Edit, History, Item, Point, StrokeTool}; +use notescraft_annotate::{Annotations, ColorRef, Edit, History, Item, Point}; use notescraft_engine::Session; use crate::text::image_annotate as strings; + use crate::theme::{Tokens, color}; +pub use text::Label; +pub use tool::Tool; use super::transform::Placement; @@ -38,85 +44,6 @@ pub const WIDTHS: [f32; 3] = [2.0, 4.0, 8.0]; /// How many theme colours a mark can take. pub const COLOURS: usize = 8; -/// What the user can draw with. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum Tool { - /// A freehand pen stroke. - #[default] - Pen, - /// A translucent freehand highlighter stroke. - Highlighter, - /// A straight line. - Line, - /// A straight line with a head. - Arrow, - /// A rectangle between two corners. - Rectangle, - /// An ellipse inside the box of two corners. - Ellipse, - /// A text label. - Text, - /// The eraser, which takes a whole mark away. - Eraser, -} - -impl Tool { - /// The freehand tool this one is, when it is one. - #[must_use] - pub(super) fn stroke(self) -> Option { - match self { - Self::Pen => Some(StrokeTool::Pen), - Self::Highlighter => Some(StrokeTool::Highlighter), - _ => None, - } - } - - /// The name of the tool for the tool bar. - #[must_use] - pub(super) fn label(self) -> &'static str { - match self { - Self::Pen => strings::pen(), - Self::Highlighter => strings::highlighter(), - Self::Line => strings::line(), - Self::Arrow => strings::arrow(), - Self::Rectangle => strings::rectangle(), - Self::Ellipse => strings::ellipse(), - Self::Text => strings::text(), - Self::Eraser => strings::eraser(), - } - } - - /// A stable name of the tool, for the id of its button. - #[must_use] - pub(super) fn id(self) -> &'static str { - match self { - Self::Pen => "tool-pen", - Self::Highlighter => "tool-highlighter", - Self::Line => "tool-line", - Self::Arrow => "tool-arrow", - Self::Rectangle => "tool-rectangle", - Self::Ellipse => "tool-ellipse", - Self::Text => "tool-text", - Self::Eraser => "tool-eraser", - } - } - - /// The icon of the tool for the tool bar. - #[must_use] - pub(super) fn icon(self) -> &'static str { - match self { - Self::Pen => "pen", - Self::Highlighter => "highlighter", - Self::Line => "minus", - Self::Arrow => "move-up-right", - Self::Rectangle => "square", - Self::Ellipse => "circle", - Self::Text => "type", - Self::Eraser => "eraser", - } - } -} - /// What the viewer tells the draw mode about the picture on the canvas. #[derive(Debug, Clone, Default)] pub struct Picture { @@ -140,15 +67,6 @@ pub enum Notice { }, } -/// A text label being typed. -#[derive(Debug, Clone, PartialEq)] -pub struct Label { - /// The point the label stands on. - pub at: Point, - /// The text of the label. - pub text: String, -} - /// The marks of one picture and the tools that make them. #[derive(Debug)] pub struct State { @@ -168,6 +86,14 @@ pub struct State { draft: Option, /// The label being typed. label: Option