diff --git a/crates/i18n/locales/en.toml b/crates/i18n/locales/en.toml index d69412a..c2b1399 100644 --- a/crates/i18n/locales/en.toml +++ b/crates/i18n/locales/en.toml @@ -305,6 +305,7 @@ embed-missing = "Cannot find {{target}}" embed-cycle = "{{target}} is already open above (circular embed)" embed-depth = "Not shown: {{target}} is nested too deeply" embed-open = "Open note" +details = "Details" media-pdf = "PDF" media-audio = "Audio" media-video = "Video" @@ -624,6 +625,8 @@ more = "{{n}} more" close-others = "Close other tabs" close-all = "Close all tabs" close-tab = "Close tab" +search = "Search tabs" +no-match = "No tabs match" [ui.large-folder] title = "Open this large folder?" @@ -739,6 +742,14 @@ collapse-all = "Collapse all folders" follow = "Follow the active note" pinned = "PINNED" clear-filter = "Clear filter" +delete = "Delete" + +[ui.confirm] +delete-question = "Are you sure you want to delete this?" +delete = "Delete" +cancel = "Cancel" +element = "element" +elements = "elements" [ui.sidebar] tab-explorer = "Explorer" @@ -1532,6 +1543,21 @@ fit = "Fit width" error-page = "This page cannot be shown" error-video = "This video cannot be shown" time = "{{position}} / {{duration}}" +back-ten = "Back 10 seconds" +forward-ten = "Forward 10 seconds" +replay = "Play from the start" +speed = "Speed {{speed}}" +speed-chip = "{{speed}}×" +loop = "Loop" +mute = "Mute" +unmute = "Unmute" +reveal = "Show in the file manager" +copy-path = "Copy the path" +more = "More" +go-to-page = "Go to page" +fit-page = "Fit page" +seek = "Seek" +pause = "Pause" [ui.image-annotate] draw = "Draw" @@ -1562,3 +1588,104 @@ notice-changed = "The picture is {{actual}} now and was {{stored}} when the mark keep = "Keep" discard = "Discard" read-only = "This vault is read-only: the marks are shown but cannot be changed." +saved-short = "Saved" + +[ui.paste] +source-hint = "Switch to Source to paste images" +read-only = "Attachments are unavailable in a read-only vault" +drop-label = "Drop to attach" +too-large = "Attachment exceeds the {{limit}} byte limit" +unsupported = "Unsupported attachment file type" +invalid-path = "Attachment path is not valid UTF-8" +clipboard-unreadable = "The clipboard could not be read" +image-invalid = "The pasted image could not be read" +unsupported-files = "NotesCraft cannot attach {{names}}" +saved = "Saved {{names}}" +linked = "Linked {{names}}" +saved-and-linked = "Saved {{saved}} · Linked {{linked}}" + +[ui.lightbox] +zoom-in = "Zoom in" +zoom-out = "Zoom out" +fit = "Fit to window" +actual = "Actual size" +rotate-left = "Rotate left" +rotate-right = "Rotate right" +previous = "Previous image" +next = "Next image" +previous-page = "Previous page" +next-page = "Next page" +copy-path = "Copy the path" +open-tab = "Open in a tab" +open-system = "Open with the system" +reveal = "Show in the file manager" +close = "Close" +play = "Play" +stop = "Stop" +page = "Page {{page}}" +count = "{{index}} of {{total}}" +loading = "Loading…" +page-failed = "This page cannot be shown." +no-preview = "No preview for this kind of file." +open-browser = "Open in the browser" +size-px = "{{width}} × {{height}} px" +kind-image = "Image" +kind-pdf = "PDF" +kind-video = "Video" +kind-file = "File" + +[ui.menu-keys] +key = "Key" +file = "File actions" +folder = "Folder actions" +tab = "Tab actions" + +[ui.hotkeys] +next-tab = "Next tab" +prev-tab = "Previous tab" +goto-tab = "Go to tab" +reopen-tab = "Reopen closed tab" +focus-sidebar = "Focus the sidebar" +focus-editor = "Focus the editor" +focus-right = "Focus the right panel" +cycle-focus = "Move focus to the next panel" +show-outline = "Show the outline" +show-related = "Show related notes" +show-backlinks = "Show backlinks" +show-assistant = "Show the assistant" +show-lint = "Show lint" +show-comments = "Show comments" +region-sidebar = "Sidebar" +region-editor = "Editor" +region-right = "Right panel" +new-tab = "New tab" +doc = "Moves keyboard focus or switches tabs" + +[ui.comment-panel] +title = "Comments" +all = "All" +open = "Open" +resolved = "Resolved" +orphaned = "Orphaned" +compose = "Comment on selection" +compose-placeholder = "Write a comment…" +send = "Send" +reply = "Reply" +reply-placeholder = "Write a reply…" +resolve = "Resolve" +reopen = "Reopen" +reanchor = "Re-anchor" +reanchor-hint = "Select the text the comment belongs to" +no-selection = "Select text in the note first" +no-threads = "No comments on this note yet" +no-match = "No comments match this filter" +read-only = "This vault is read-only: the comments are shown but cannot be changed." +loading = "Loading the comments…" +moved = "Moved" +orphan-badge = "Orphaned" +replies-one = "1 reply" +replies-many = "{n} replies" +now = "just now" +minutes = "{n}m ago" +hours = "{n}h ago" +days = "{n}d ago" diff --git a/crates/ui-egui/src/design/fonts.rs b/crates/ui-egui/src/design/fonts.rs deleted file mode 100644 index c1fd621..0000000 --- a/crates/ui-egui/src/design/fonts.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! Fonts: `Inter` for the UI, `JetBrains Mono` for code and `STIX Two Math` for math, -//! shipped in `assets/fonts` like the other Craft apps (SIL OFL 1.1, see `ATTRIBUTION.md`). - -use std::sync::Arc; - -use egui::{FontData, FontDefinitions, FontFamily}; - -const INTER_REGULAR: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf"); -const INTER_MEDIUM: &[u8] = include_bytes!("../../../../assets/fonts/Inter-Medium.ttf"); -const INTER_SEMIBOLD: &[u8] = include_bytes!("../../../../assets/fonts/Inter-SemiBold.ttf"); -const JETBRAINS_MONO: &[u8] = include_bytes!("../../../../assets/fonts/JetBrainsMono-Regular.ttf"); -const STIX_MATH: &[u8] = include_bytes!("../../../../assets/fonts/STIXTwoMath-Regular.ttf"); - -/// Fonts the system ships with Hebrew and Arabic letters, read when present: the bundled fonts have none, and a -/// line of right-to-left text would otherwise show empty boxes. -const SYSTEM_FALLBACKS: [&str; 7] = [ - "/System/Library/Fonts/SFHebrew.ttf", - "/System/Library/Fonts/SFArabic.ttf", - "C:\\Windows\\Fonts\\segoeui.ttf", - "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/dejavu/DejaVuSans.ttf", - "/usr/share/fonts/truetype/noto/NotoSansHebrew-Regular.ttf", - "/usr/share/fonts/noto/NotoSansHebrew-Regular.ttf", -]; -/// Largest system font read, in bytes. -const MAX_FALLBACK_BYTES: u64 = 24 * 1024 * 1024; - -/// The bytes of the system fallback fonts that exist here. -fn system_fallbacks() -> Vec> { - SYSTEM_FALLBACKS - .iter() - .filter(|path| std::fs::metadata(path).is_ok_and(|meta| meta.len() <= MAX_FALLBACK_BYTES)) - .filter_map(|path| std::fs::read(path).ok()) - .collect() -} - -/// Named family for medium-weight text. -pub const MEDIUM: &str = "inter-medium"; -/// Named family for semibold text (titles, tab labels). -pub const SEMIBOLD: &str = "inter-semibold"; -/// Named family for math glyphs (`SPEC` item 30). -pub const MATH: &str = "stix-math"; - -/// The math family: STIX Two Math with the UI fonts as glyph fallbacks. -#[must_use] -pub fn math() -> FontFamily { - FontFamily::Name(MATH.into()) -} - -/// The medium-weight family. -#[must_use] -pub fn medium() -> FontFamily { - FontFamily::Name(MEDIUM.into()) -} - -/// The semibold family. -#[must_use] -pub fn semibold() -> FontFamily { - FontFamily::Name(SEMIBOLD.into()) -} - -/// Installs `Inter` as the proportional font, `JetBrains Mono` as the monospace font and the two -/// named weight families. egui's bundled fonts stay behind them as glyph fallbacks. -pub fn install(ctx: &egui::Context) { - let mut fonts = FontDefinitions::default(); - for (name, bytes) in [("inter", INTER_REGULAR), (MEDIUM, INTER_MEDIUM), (SEMIBOLD, INTER_SEMIBOLD), ("jetbrains-mono", JETBRAINS_MONO), (MATH, STIX_MATH)] { - fonts.font_data.insert(name.to_owned(), Arc::new(FontData::from_static(bytes))); - } - let proportional = fonts.families.entry(FontFamily::Proportional).or_default(); - proportional.insert(0, "inter".to_owned()); - 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); - } - } - let monospace = fonts.families.entry(FontFamily::Monospace).or_default(); - monospace.insert(0, "jetbrains-mono".to_owned()); - for (family, face) in [(MEDIUM, MEDIUM), (SEMIBOLD, SEMIBOLD)] { - let mut chain = vec![face.to_owned(), "inter".to_owned()]; - if let Some(base) = fonts.families.get(&FontFamily::Proportional) { - chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); - } - fonts.families.insert(FontFamily::Name(family.into()), chain); - } // Math family first, then the UI fonts as glyph fallbacks for symbols STIX lacks. - let mut math_chain = vec![MATH.to_owned(), "inter".to_owned()]; - if let Some(base) = fonts.families.get(&FontFamily::Proportional) { - math_chain.extend(base.iter().filter(|n| n.as_str() != "inter").cloned()); - } - math_chain.push("jetbrains-mono".to_owned()); - fonts.families.insert(FontFamily::Name(MATH.into()), math_chain); - ctx.set_fonts(fonts); -} diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index 2f1ae4e..35a5a81 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -18,7 +18,9 @@ use crate::center::ViewMode; use crate::theme::{Tokens, color}; use prompt::PromptScope; +mod meta; pub mod prompt; +pub use meta::TabMeta; mod save; pub(crate) mod vim_status; @@ -134,8 +136,16 @@ impl TabEdit { /// The editing state of every note ever opened this session, keyed by vault path. #[derive(Debug, Clone, Default, PartialEq)] pub struct Editing { - /// Per-tab state; entries survive tab close so reopening restores them. + /// Per-tab state of the HOT tabs only: the active one and the few most recently used. pub tabs: BTreeMap, + /// What every COLD tab (hibernated or closed) remembers; a few bytes each. + pub meta: BTreeMap, + /// Last-use tick per hot tab. + used: BTreeMap, + clock: u64, + last_active: Option, + /// Widget ids of dropped editors whose egui undo memory is still to be cleared. + pub(crate) released: Vec, } impl Editing { @@ -143,6 +153,7 @@ impl Editing { pub fn ensure(&mut self, path: &str, text: &str) { if !self.tabs.contains_key(path) { let _ = self.tabs.insert(path.to_owned(), TabEdit::new(text)); + self.wake(path); } } @@ -232,6 +243,8 @@ impl Editing { /// Drops the state of `path` (a discarded tab reopens from disk). pub fn drop_tab(&mut self, path: &str) { let _ = self.tabs.remove(path); + let _ = self.meta.remove(path); + let _ = self.used.remove(path); } } diff --git a/crates/ui-egui/src/editing/meta.rs b/crates/ui-egui/src/editing/meta.rs new file mode 100644 index 0000000..d1a26ff --- /dev/null +++ b/crates/ui-egui/src/editing/meta.rs @@ -0,0 +1,77 @@ +//! Cold tabs: the few bytes a hibernated tab keeps, and the recency that decides who sleeps. + +use std::collections::BTreeSet; + +use super::{Editing, TabEdit}; +use crate::center::ViewMode; + +/// What a hibernated (cold) tab remembers. The text, undo history and parsed document are gone; +/// the scroll offset and the caret range live in egui's per-id memory, which is a few bytes. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TabMeta { + /// Primary caret byte offset when the tab went cold. + pub cursor: Option, + /// How the tab rendered (visual or source). + pub view: ViewMode, + /// Reading mode flag. + pub reading: bool, + /// Folded headings of the source view. + pub folded: BTreeSet, + /// Folded callouts of the visual view. + pub callouts: crate::note_view::CalloutFolds, +} + +impl Editing { + /// Marks `path` as the most recently used tab. + pub fn touch(&mut self, path: &str) { + self.clock = self.clock.saturating_add(1); + let _ = self.used.insert(path.to_owned(), self.clock); + self.last_active = Some(path.to_owned()); + } + + /// The path last passed to [`Editing::touch`]. + #[must_use] + pub fn last_active(&self) -> Option<&str> { + self.last_active.as_deref() + } + + /// The hot tabs with their last-use tick (0 when never touched). + #[must_use] + pub fn hot_by_use(&self) -> Vec<(String, u64)> { + self.tabs.keys().map(|path| (path.clone(), self.used.get(path).copied().unwrap_or(0))).collect() + } + + /// Whether `path` has editor work that the buffer has not seen or the disk has not got. + #[must_use] + pub fn has_pending_work(&self, path: &str) -> bool { + self.tabs.get(path).is_some_and(|tab| tab.last_change.is_some()) + } + + /// Makes `path` cold: drops its editor state and keeps a [`TabMeta`]. The widget id is queued + /// in `released` so the frame loop can clear the widget's egui undo memory. + pub fn hibernate(&mut self, path: &str) -> Option { + let edit = self.tabs.remove(path)?; + let meta = TabMeta { cursor: edit.editor.cursor, view: edit.view, reading: edit.reading, folded: edit.editor.folded.clone(), ..TabMeta::default() }; + let _ = self.meta.insert(path.to_owned(), meta); + let _ = self.used.remove(path); + self.released.extend(edit.widget_id); + Some(edit) + } + + /// Applies the cold-tab memory of `path` (if any) to a freshly built state, consuming it. + pub(super) fn wake(&mut self, path: &str) { + let (Some(meta), Some(tab)) = (self.meta.remove(path), self.tabs.get_mut(path)) else { return }; + tab.editor.cursor = meta.cursor; + tab.editor.folded = meta.folded; + tab.view = meta.view; + tab.reading = meta.reading; + } + + /// Forgets the cold memory of closed tabs once the map grows past `cap`. + pub fn prune_meta(&mut self, cap: usize, is_open: impl Fn(&str) -> bool) { + if self.meta.len() <= cap { + return; + } + self.meta.retain(|path, _| is_open(path)); + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 460719c..ccbb697 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -22,6 +22,7 @@ pub use panels::assistant_panel; pub use panels::backlinks_panel; pub use panels::bookmarks_view; pub use panels::buffers_panel; +pub use panels::comment_panel; pub use panels::lint_panel; pub use panels::outline; pub use panels::related_panel; @@ -37,6 +38,7 @@ pub use shell::app::feed_sync; pub use shell::app::services; pub use shell::attachment_drop; pub use shell::commands_ui; +pub use shell::hotkeys; pub use shell::layout; pub use shell::panel_resize; pub use shell::recovery; @@ -55,6 +57,7 @@ pub use views::file_card; pub use views::folder_view; pub use views::graph_view; pub use views::image_view; +pub use views::lightbox; pub use views::math_face; pub use views::math_view; pub use views::media_view; @@ -71,6 +74,7 @@ pub use widgets::color_menu; pub use widgets::context_menu; pub use widgets::dialog; pub use widgets::export_dialog; +pub use widgets::menu_keys; pub use widgets::palette; pub use widgets::previews; pub use widgets::slash_popup; diff --git a/crates/ui-egui/src/panels/bookmarks_view.rs b/crates/ui-egui/src/panels/bookmarks_view.rs index 29e675d..579533f 100644 --- a/crates/ui-egui/src/panels/bookmarks_view.rs +++ b/crates/ui-egui/src/panels/bookmarks_view.rs @@ -121,13 +121,15 @@ fn bookmark_row(ui: &mut Ui, tokens: &Tokens, bookmark: &Bookmark, active: Optio ui.rect_contains_pointer(remove_rect), ); } + crate::tree::name_tip(ui, tokens, &response, &title, &bookmark.path); if remove.is_some_and(|response| response.clicked()) { return Some(BookmarkAction::Remove(bookmark.clone())); } response.clicked().then(|| BookmarkAction::Open(bookmark.clone())) } -fn title(bookmark: &Bookmark) -> String { +/// The label of a bookmark row: its own title, or the file name and heading. +pub(crate) fn title(bookmark: &Bookmark) -> String { if let Some(title) = &bookmark.title { return title.clone(); } @@ -168,12 +170,7 @@ impl crate::app::NotesApp { pub(crate) fn handle_bookmark(&mut self, action: BookmarkAction) { match action { BookmarkAction::Add(path) => self.run_command("bookmarks.add", &serde_json::json!({"path":path})), - BookmarkAction::Remove(bookmark) => { - let params = bookmark - .heading - .map_or_else(|| serde_json::json!({"path":bookmark.path}), |heading| serde_json::json!({"path":bookmark.path, "heading":heading})); - self.run_command("bookmarks.remove", ¶ms); - } + BookmarkAction::Remove(bookmark) => self.confirm_bookmark_remove(bookmark), BookmarkAction::Open(bookmark) => { self.open_note(&bookmark.path); if let Some(heading) = bookmark.heading { diff --git a/crates/ui-egui/src/panels/buffers_panel.rs b/crates/ui-egui/src/panels/buffers_panel.rs index 2c42a80..e30e83e 100644 --- a/crates/ui-egui/src/panels/buffers_panel.rs +++ b/crates/ui-egui/src/panels/buffers_panel.rs @@ -141,6 +141,7 @@ fn buffer_row(ui: &mut Ui, tokens: &Tokens, row: &BufferRow, active: bool, out: crate::motion::mix(color(c.text_muted), color(c.text), close_hover).gamma_multiply(appear), close.hovered(), ); + crate::tree::name_tip(ui, tokens, &response, &row.title, &row.path); if close.clicked() || response.clicked_by(egui::PointerButton::Middle) { out.close = Some(row.path.clone()); } else if response.clicked() { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 5aadd68..b5b1164 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, outline}; +pub use right::{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/comment_panel.rs b/crates/ui-egui/src/panels/right/comment_panel.rs new file mode 100644 index 0000000..1a5980b --- /dev/null +++ b/crates/ui-egui/src/panels/right/comment_panel.rs @@ -0,0 +1,399 @@ +//! The Comments tab of the right card: the threads of the open note. +//! +//! Pure widget: the shell reads the threads with the `comment.list` command and +//! hands them over as [`CommentData`]; what the user pressed comes back as +//! [`CommentResponse`]. No command runs while the panel paints. + +use std::collections::{BTreeMap, BTreeSet}; +use std::ops::Range; + +use egui::{RichText, Ui}; +use notescraft_engine::review::AnchorState; +use serde_json::Value; + +use crate::text::review::comment_panel as strings; +use crate::theme::{Tokens, color}; + +mod rows; + +/// Text size of the small parts, in points. +const SMALL: f32 = 12.0; +/// Text size of the body of a comment, in points. +const BODY: f32 = 13.0; +/// Height of a filter chip, in points. +const CHIP_HEIGHT: f32 = 28.0; +/// Corner radius of the cards and of the chips, in points. +const RADIUS: f32 = 12.0; +/// Inner margin of a card, in points. +const CARD_MARGIN: i8 = 12; +/// Most characters of a quote shown in a row. +const QUOTE_CHARS: usize = 120; + +/// The text the editor has selected: the anchor of a new comment. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Selection { + /// The selected text. + pub text: String, + /// Where the text sits in the note, when it maps onto it. + pub range: Option>, +} + +/// One reply of a thread. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommentReply { + /// The id of the reply. + pub id: String, + /// The author of the reply. + pub author: String, + /// When the reply was written, in milliseconds. + pub created_ms: u64, + /// The text of the reply. + pub body: String, +} + +/// One thread of the note. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommentThread { + /// The id of the thread. + pub id: String, + /// The author of the comment. + pub author: String, + /// When the thread was opened, in milliseconds. + pub created_ms: u64, + /// The text of the comment. + pub body: String, + /// Whether the thread is closed. + pub resolved: bool, + /// How far the anchor of the thread drifted. + pub state: AnchorState, + /// The quoted text of the note. + pub quote: String, + /// Where the quote sits in the note, when it still resolves. + pub start: Option, + /// The replies of the thread, in order. + pub replies: Vec, +} + +/// What the tab shows. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommentData { + /// The note the threads belong to. + pub path: Option, + /// The threads, in document order. + pub threads: Vec, + /// The list has been read at least once. + pub loaded: bool, + /// The vault is read-only: nothing may be changed. + pub read_only: bool, + /// The text the editor has selected. + pub selection: Option, +} + +/// What the user did in the tab. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommentResponse { + /// A new comment was composed: the quote and the body. + pub add: Option<(String, String)>, + /// A reply was written: the thread id and the body. + pub reply: Option<(String, String)>, + /// A thread was closed or opened again: the thread id and the new state. + pub resolve: Option<(String, bool)>, + /// A thread must move to the current selection: the thread id. + pub reanchor: Option, +} + +/// Which threads the list shows. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum Filter { + /// Every thread. + #[default] + All, + /// The threads that are still open. + Open, + /// The threads that were closed. + Resolved, + /// The threads whose quote is gone from the note. + Orphaned, +} + +impl Filter { + /// Whether a thread belongs under this filter. + #[must_use] + pub fn keeps(self, thread: &CommentThread) -> bool { + match self { + Self::All => true, + Self::Open => !thread.resolved, + Self::Resolved => thread.resolved, + Self::Orphaned => thread.state == AnchorState::Orphaned, + } + } +} + +/// What the pane remembers between frames. +#[derive(Debug, Clone, Default)] +struct PanelState { + /// The note the state belongs to; another note starts over. + path: Option, + /// The filter chip that is on. + filter: Filter, + /// The threads that are open. + expanded: BTreeSet, + /// The text of the composer. + compose: String, + /// The reply drafts, by thread id. + drafts: BTreeMap, +} + +/// The memory of the panel state. +fn state_id() -> egui::Id { + egui::Id::new("notescraft-comment-state") +} + +/// The threads of the answer of `comment.list`, in document order: the threads +/// whose quote still resolves come first, in the order of the note, and the +/// orphaned ones last. +#[must_use] +pub fn parse_list(result: &Value) -> Vec { + let Some(items) = result.get("comments").and_then(Value::as_array) else { return Vec::new() }; + let mut threads: Vec = items.iter().filter_map(parse_thread).collect(); + threads.sort_by_key(|thread| (thread.start.is_none(), thread.start.unwrap_or(0))); + threads +} + +/// One thread of the answer of `comment.list`. +fn parse_thread(value: &Value) -> Option { + let id = value.get("id").and_then(Value::as_str)?; + let quote = value.get("anchor").and_then(|anchor| anchor.get("quote")).and_then(Value::as_str).unwrap_or_default().to_owned(); + let start = value.get("range").and_then(|range| range.get("start")).and_then(Value::as_u64).and_then(|start| usize::try_from(start).ok()); + let replies = value.get("thread").and_then(Value::as_array).map_or_else(Vec::new, |items| items.iter().filter_map(parse_reply).collect()); + Some(CommentThread { + id: id.to_owned(), + author: value.get("author").and_then(Value::as_str).unwrap_or_default().to_owned(), + created_ms: value.get("created_ms").and_then(Value::as_u64).unwrap_or(0), + body: value.get("body").and_then(Value::as_str).unwrap_or_default().to_owned(), + resolved: value.get("resolved").and_then(Value::as_bool).unwrap_or(false), + state: parse_state(value.get("state").and_then(Value::as_str)), + quote, + start, + replies, + }) +} + +/// One reply of the answer of `comment.list`. +fn parse_reply(value: &Value) -> Option { + let id = value.get("id").and_then(Value::as_str)?; + Some(CommentReply { + id: id.to_owned(), + author: value.get("author").and_then(Value::as_str).unwrap_or_default().to_owned(), + created_ms: value.get("created_ms").and_then(Value::as_u64).unwrap_or(0), + body: value.get("body").and_then(Value::as_str).unwrap_or_default().to_owned(), + }) +} + +/// The anchor state of a thread, as the engine names it. An unknown name is +/// an orphan: the badge warns instead of hiding a thread. +fn parse_state(name: Option<&str>) -> AnchorState { + match name { + Some("exact") => AnchorState::Exact, + Some("reanchored") => AnchorState::Reanchored, + Some("fuzzy") => AnchorState::Fuzzy, + _ => AnchorState::Orphaned, + } +} + +/// 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 a comment, as a short relative text. +#[must_use] +pub fn relative_time(created_ms: u64, now_ms: u64) -> String { + let minutes = now_ms.saturating_sub(created_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 comments of the active note: the composer, the filters and the +/// threads. What the user pressed comes back in the answer. +pub fn show(ui: &mut Ui, tokens: &Tokens, data: &CommentData) -> CommentResponse { + let mut response = CommentResponse::default(); + let mut state = ui.ctx().data(|data| data.get_temp::(state_id())).unwrap_or_default(); + if state.path != data.path { + state.path.clone_from(&data.path); + state.expanded.clear(); + state.compose.clear(); + state.drafts.clear(); + } + if data.read_only { + muted_line(ui, tokens, strings::read_only()); + } else { + composer(ui, tokens, data, &mut state, &mut response); + } + ui.add_space(6.0); + filters(ui, tokens, &mut state); + ui.add_space(6.0); + list(ui, tokens, data, &mut state, &mut response); + ui.ctx().data_mut(|data| data.insert_temp(state_id(), state)); + response +} + +/// The composer at the top: a comment on the current selection. +fn composer(ui: &mut Ui, tokens: &Tokens, data: &CommentData, state: &mut PanelState, response: &mut CommentResponse) { + 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()); + ui.label(RichText::new(strings::compose()).size(SMALL).family(crate::fonts::semibold()).color(color(colors.text))); + let quote = data.selection.as_ref().map(|selection| selection.text.as_str()); + let shown = quote.map_or_else(|| strings::no_selection().to_owned(), |text| excerpt(text, QUOTE_CHARS)); + ui.label(RichText::new(shown).size(SMALL).color(color(colors.text_muted))); + ui.horizontal(|ui| { + let width = (ui.available_width() - 72.0).max(60.0); + ui.add(egui::TextEdit::singleline(&mut state.compose).hint_text(strings::compose_placeholder()).desired_width(width)); + let enabled = quote.is_some() && !state.compose.trim().is_empty(); + if ui.add_enabled(enabled, egui::Button::new(RichText::new(strings::send()).size(SMALL))).clicked() + && let Some(text) = quote + { + response.add = Some((text.to_owned(), state.compose.trim().to_owned())); + state.compose.clear(); + } + }); + }); +} + +/// The filter chips: All, Open, Resolved and Orphaned. +fn filters(ui: &mut Ui, tokens: &Tokens, state: &mut PanelState) { + let chips = + [(Filter::All, strings::all()), (Filter::Open, strings::open()), (Filter::Resolved, strings::resolved()), (Filter::Orphaned, strings::orphaned())]; + ui.horizontal_wrapped(|ui| { + for (filter, label) in chips { + if chip(ui, tokens, label, state.filter == filter) { + state.filter = filter; + } + } + }); +} + +/// One filter chip; true when it was pressed. +fn chip(ui: &mut Ui, tokens: &Tokens, label: &str, selected: bool) -> bool { + let colors = &tokens.theme.colors; + let tint = if selected { color(colors.accent) } else { color(colors.text_muted) }; + let fill = if selected { color(colors.surface_2) } else { color(colors.canvas) }; + let height = crate::style::compact::target(ui.ctx(), CHIP_HEIGHT); + ui.add( + egui::Button::new(RichText::new(label).size(SMALL).family(crate::fonts::semibold()).color(tint)) + .fill(fill) + .corner_radius(RADIUS) + .min_size(egui::vec2(0.0, height)), + ) + .clicked() +} + +/// The list of threads, in a scroll area. +fn list(ui: &mut Ui, tokens: &Tokens, data: &CommentData, state: &mut PanelState, response: &mut CommentResponse) { + if !data.loaded { + muted_line(ui, tokens, strings::loading()); + return; + } + if data.threads.is_empty() { + muted_line(ui, tokens, strings::no_threads()); + return; + } + let shown: Vec<&CommentThread> = data.threads.iter().filter(|thread| state.filter.keeps(thread)).collect(); + if shown.is_empty() { + muted_line(ui, tokens, strings::no_match()); + return; + } + egui::ScrollArea::vertical().id_salt("notescraft-comment-scroll").auto_shrink([false, false]).show(ui, |ui| { + ui.spacing_mut().item_spacing.y = 8.0; + for thread in shown { + rows::thread(ui, tokens, thread, data, state, response); + } + }); +} + +/// 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 `comment.list` with two threads. + fn answer() -> Value { + json!({ + "path": "a.md", + "comments": [ + { "id": "c-2", "author": "ana", "created_ms": 10, "body": "second", "resolved": false, "state": "exact", + "anchor": { "quote": "b" }, "range": { "start": 20, "end": 21 }, "thread": [] }, + { "id": "c-1", "author": "bob", "created_ms": 5, "body": "first", "resolved": true, "state": "orphaned", + "anchor": { "quote": "a" }, "range": null, "thread": [{ "id": "r-1", "author": "ana", "created_ms": 7, "body": "sure" }] } + ] + }) + } + + #[test] + fn the_threads_come_in_document_order_and_the_orphans_last() { + let threads = parse_list(&answer()); + assert_eq!(threads.iter().map(|thread| thread.id.as_str()).collect::>(), ["c-2", "c-1"]); + assert_eq!(threads[0].state, AnchorState::Exact); + assert_eq!(threads[1].state, AnchorState::Orphaned); + assert_eq!(threads[1].replies.len(), 1); + assert_eq!(threads[1].replies[0].body, "sure"); + } + + #[test] + fn the_filters_keep_what_they_name() { + let threads = parse_list(&answer()); + let keeps = |filter: Filter| threads.iter().filter(|thread| filter.keeps(thread)).map(|thread| thread.id.as_str()).collect::>(); + assert_eq!(keeps(Filter::All), ["c-2", "c-1"]); + assert_eq!(keeps(Filter::Open), ["c-2"]); + assert_eq!(keeps(Filter::Resolved), ["c-1"]); + assert_eq!(keeps(Filter::Orphaned), ["c-1"]); + } + + #[test] + fn an_answer_without_threads_is_empty() { + assert_eq!(parse_list(&json!({})), Vec::new()); + assert_eq!(parse_list(&json!({ "comments": "no" })), Vec::new()); + } + + #[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"); + } + + #[test] + fn an_excerpt_is_cut_with_an_ellipsis() { + assert_eq!(excerpt("short", 10), "short"); + assert_eq!(excerpt("abcdef", 3), "abc…"); + assert_eq!(excerpt("éclair", 2), "éc…"); + } +} diff --git a/crates/ui-egui/src/panels/right/comment_panel/rows.rs b/crates/ui-egui/src/panels/right/comment_panel/rows.rs new file mode 100644 index 0000000..88f1f05 --- /dev/null +++ b/crates/ui-egui/src/panels/right/comment_panel/rows.rs @@ -0,0 +1,132 @@ +//! The rows of the comments panel: one card per thread, with its replies. + +use egui::{Align, Layout, RichText, Sense, Ui}; +use notescraft_engine::review::AnchorState; + +use super::{BODY, CARD_MARGIN, CommentData, CommentResponse, CommentThread, PanelState, QUOTE_CHARS, RADIUS, SMALL, excerpt, now_ms, relative_time}; +use crate::text::review::comment_panel as strings; +use crate::theme::{Tokens, color}; + +/// Size of the chevron that opens a thread, in points. +const CHEVRON: f32 = 14.0; +/// Height of a row that holds a button, in points. +const ROW_HEIGHT: f32 = 28.0; + +/// One thread: the author, the quote, the body, the replies and the buttons. +pub(super) fn thread(ui: &mut Ui, tokens: &Tokens, thread: &CommentThread, data: &CommentData, state: &mut PanelState, response: &mut CommentResponse) { + 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()); + header(ui, tokens, thread); + if !thread.quote.is_empty() { + ui.label(RichText::new(excerpt(&thread.quote, QUOTE_CHARS)).size(SMALL).italics().color(color(colors.text_muted))); + } + ui.add(egui::Label::new(RichText::new(&thread.body).size(BODY).color(color(colors.text))).wrap()); + let expanded = state.expanded.contains(&thread.id); + ui.horizontal(|ui| { + if toggle(ui, tokens, thread, expanded) { + if expanded { + state.expanded.remove(&thread.id); + } else { + state.expanded.insert(thread.id.clone()); + } + } + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if !data.read_only { + buttons(ui, thread, data, response); + } + }); + }); + if expanded { + replies(ui, tokens, thread, data, state, response); + } + }); +} + +/// The author, the time and the badge of the anchor. +fn header(ui: &mut Ui, tokens: &Tokens, thread: &CommentThread) { + let colors = &tokens.theme.colors; + ui.horizontal(|ui| { + ui.label(RichText::new(&thread.author).size(SMALL).family(crate::fonts::semibold()).color(color(colors.text))); + ui.label(RichText::new(relative_time(thread.created_ms, now_ms())).size(SMALL).color(color(colors.text_muted))); + ui.with_layout(Layout::right_to_left(Align::Center), |ui| { + if let Some((label, tint)) = badge(tokens, thread.state) { + ui.label(RichText::new(label).size(SMALL).color(tint)); + } + }); + }); +} + +/// The badge of the anchor state; `None` when the anchor still sits where it was. +fn badge(tokens: &Tokens, state: AnchorState) -> Option<(&'static str, egui::Color32)> { + let colors = &tokens.theme.colors; + match state { + AnchorState::Exact => None, + AnchorState::Reanchored | AnchorState::Fuzzy => Some((strings::moved(), color(colors.text_muted))), + AnchorState::Orphaned => Some((strings::orphan_badge(), color(colors.warning))), + } +} + +/// The button that opens the thread; true when it was pressed. +fn toggle(ui: &mut Ui, tokens: &Tokens, thread: &CommentThread, expanded: bool) -> bool { + let colors = &tokens.theme.colors; + let count = thread.replies.len(); + let label = match count { + 0 => strings::reply().to_owned(), + 1 => strings::replies_one().to_owned(), + count => strings::replies_many().replace("{n}", &count.to_string()), + }; + let icon = if expanded { "chevron-down" } else { "chevron-right" }; + let height = crate::style::compact::target(ui.ctx(), ROW_HEIGHT); + ui.horizontal(|ui| { + let (rect, response) = ui.allocate_exact_size(egui::vec2(CHEVRON, height), Sense::click()); + let tint = if response.hovered() { color(colors.text) } else { color(colors.text_muted) }; + crate::icons::paint(ui, icon, egui::Rect::from_center_size(rect.center(), egui::Vec2::splat(CHEVRON)), tint); + let text = ui.add(egui::Button::new(RichText::new(label).size(SMALL)).frame(false)); + response.clicked() || text.clicked() + }) + .inner +} + +/// The buttons of a thread: Resolve or Reopen, and Re-anchor for an orphan. +fn buttons(ui: &mut Ui, thread: &CommentThread, data: &CommentData, response: &mut CommentResponse) { + let label = if thread.resolved { strings::reopen() } else { strings::resolve() }; + if ui.add(egui::Button::new(RichText::new(label).size(SMALL))).clicked() { + response.resolve = Some((thread.id.clone(), !thread.resolved)); + } + if thread.state == AnchorState::Orphaned { + let enabled = data.selection.is_some(); + let button = ui.add_enabled(enabled, egui::Button::new(RichText::new(strings::reanchor()).size(SMALL))); + if button.clicked() { + response.reanchor = Some(thread.id.clone()); + } + if !enabled { + button.on_hover_text(strings::reanchor_hint()); + } + } +} + +/// The replies of a thread and the box to write one. +fn replies(ui: &mut Ui, tokens: &Tokens, thread: &CommentThread, data: &CommentData, state: &mut PanelState, response: &mut CommentResponse) { + let colors = &tokens.theme.colors; + for reply in &thread.replies { + ui.horizontal(|ui| { + ui.label(RichText::new(&reply.author).size(SMALL).family(crate::fonts::semibold()).color(color(colors.text))); + ui.label(RichText::new(relative_time(reply.created_ms, now_ms())).size(SMALL).color(color(colors.text_muted))); + }); + ui.add(egui::Label::new(RichText::new(&reply.body).size(SMALL).color(color(colors.text))).wrap()); + } + if data.read_only { + return; + } + let draft = state.drafts.entry(thread.id.clone()).or_default(); + ui.horizontal(|ui| { + let width = (ui.available_width() - 72.0).max(60.0); + ui.add(egui::TextEdit::singleline(draft).hint_text(strings::reply_placeholder()).desired_width(width)); + let enabled = !draft.trim().is_empty(); + if ui.add_enabled(enabled, egui::Button::new(RichText::new(strings::send()).size(SMALL))).clicked() { + response.reply = Some((thread.id.clone(), draft.trim().to_owned())); + draft.clear(); + } + }); +} diff --git a/crates/ui-egui/src/panels/right/mod.rs b/crates/ui-egui/src/panels/right/mod.rs index 8498917..29102a8 100644 --- a/crates/ui-egui/src/panels/right/mod.rs +++ b/crates/ui-egui/src/panels/right/mod.rs @@ -1,4 +1,5 @@ -//! Panels of the right card that only list data: backlinks and the outline. +//! Panels of the right card that only list data: backlinks, the outline and the comments. 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 4554fe2..646dcb0 100644 --- a/crates/ui-egui/src/panels/right_view.rs +++ b/crates/ui-egui/src/panels/right_view.rs @@ -36,6 +36,8 @@ pub struct RightData { pub related: crate::related_panel::RelatedData, /// Lint issues of the active note, filled by the shell. pub lint: crate::lint_panel::LintData, + /// The comment threads of the active note, filled by the shell. + pub comments: crate::comment_panel::CommentData, } /// Reads note bodies through the session for the backlinks scan, caching per scan. @@ -93,6 +95,7 @@ pub fn data_for(session: &Session, active_path: Option<&str>, docs: &BTreeMap { pub related: &'a crate::related_panel::RelatedData, /// The lint issues of the active note. pub lint: &'a crate::lint_panel::LintData, + /// The comment threads of the active note. + pub comments: &'a crate::comment_panel::CommentData, } /// What the user did in the right card. @@ -151,6 +158,8 @@ pub struct RightResponse { pub toggle_related: Option, /// What was pressed in the Lint tab. pub lint: crate::lint_panel::LintResponse, + /// What was pressed in the Comments tab. + pub comments: crate::comment_panel::CommentResponse, } /// Draws the tab strip and the tab's content, reporting the user's actions. @@ -194,6 +203,7 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab, inputs: &RightInpu response.outline_copy = shown.copy_link; } } + RightTab::Comments => response.comments = crate::comment_panel::show(ui, tokens, inputs.comments), RightTab::Assistant => {} } response @@ -206,14 +216,15 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) { 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 = 5.0 * (TAB_PAD * 2.0 + ICON) + 4.0 * TAB_SPACING; + 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); - for (candidate, icon, label) in [ - (RightTab::Backlinks, "link", strings::backlinks()), - (RightTab::Related, "sparkles", crate::text::knowledge_related::related()), - (RightTab::Lint, "list-checks", crate::text::lint_panel::lint()), - (RightTab::Outline, "list", strings::outline()), - (RightTab::Assistant, "bot", strings::assistant()), + 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::Assistant, "bot", strings::assistant(), "ui.show_assistant"), ] { let active = *tab == candidate; let tint = if active { color(c.text) } else { color(c.text_muted) }; @@ -221,7 +232,7 @@ fn tab_strip(ui: &mut Ui, tokens: &Tokens, tab: &mut RightTab) { 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 rect = egui::Rect::from_min_size(egui::pos2(x, top), egui::vec2(width, TAB_HEIGHT)); - let hit = ui.interact(rect, egui::Id::new(("notescraft-right-tab", label)), egui::Sense::click()).on_hover_text(label); + 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); if active { // The pill glides between the tabs. diff --git a/crates/ui-egui/src/panels/search_panel/results.rs b/crates/ui-egui/src/panels/search_panel/results.rs index 230d728..91b6d60 100644 --- a/crates/ui-egui/src/panels/search_panel/results.rs +++ b/crates/ui-egui/src/panels/search_panel/results.rs @@ -122,10 +122,10 @@ fn card(ui: &mut Ui, tokens: &Tokens, hit: &Hit, (session, selected, collapsed): let background = ui.painter().add(Shape::Noop); let width = ui.available_width(); let mut icon_rect = None; + let title = session.index().note(&hit.path).map_or(hit.path.as_str(), |note| note.title.as_str()); let row = egui::Frame::new().inner_margin(CARD_PADDING).show(ui, |ui| { ui.set_width((width - f32::from(CARD_PADDING) * 2.0).max(0.0)); ui.spacing_mut().item_spacing.y = 4.0; - let title = session.index().note(&hit.path).map_or(hit.path.as_str(), |note| note.title.as_str()); ui.horizontal(|ui| { icon_rect = Some(ui.allocate_exact_size(Vec2::splat(ICON_SIZE), Sense::hover()).0); ui.add(egui::Label::new(RichText::new(title).font(FontId::new(14.0, fonts::semibold())).color(color(c.text))).truncate()); @@ -140,6 +140,7 @@ fn card(ui: &mut Ui, tokens: &Tokens, hit: &Hit, (session, selected, collapsed): crate::lottie_icon::paint_hover(ui, response.id, "file-text", rect, color(c.accent), ui.rect_contains_pointer(row.response.rect)); } response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &hit.path)); + crate::tree::name_tip(ui, tokens, &response, title, &hit.path); let fade = motion::hover(ui.ctx(), response.id, &response); let base = if selected { color(c.surface_2) } else { egui::Color32::TRANSPARENT }; let fill = motion::mix(base, color(c.selection), fade); diff --git a/crates/ui-egui/src/panels/sidebar.rs b/crates/ui-egui/src/panels/sidebar.rs index ac82ddc..dd7c61c 100644 --- a/crates/ui-egui/src/panels/sidebar.rs +++ b/crates/ui-egui/src/panels/sidebar.rs @@ -414,6 +414,11 @@ pub(crate) fn calendar(ui: &mut Ui, tokens: &Tokens, cal: &CalendarData) -> Opti calendar_card(ui, tokens, cal, None).day } +/// Gap between the calendar title and its link icon, points. +const TITLE_LINK_GAP: f32 = 6.0; +/// Side of the link icon after the calendar title, points. +const TITLE_LINK_ICON: f32 = 14.0; + /// The chevron at the left of the calendar title that folds the card to its title row or unfolds it. fn fold_control(ui: &mut Ui, tokens: &Tokens, card: Rect, fold: &mut bool) { let c = &tokens.theme.colors; @@ -432,7 +437,10 @@ fn navigation(ui: &mut Ui, tokens: &Tokens, cal: &CalendarData, place: (Rect, f3 let c = &tokens.theme.colors; let (card, title_left) = place; let title = Rect::from_min_size(Pos2::new(card.left() + title_left - 6.0, card.top() + 6.0), Vec2::new(card.width() - 120.0 - title_left, 28.0)); - let hit = ui.interact(title, ui.id().with("calendar-title"), Sense::click()).on_hover_text(strings::open_calendar()); + let hit = ui + .interact(title, ui.id().with("calendar-title"), Sense::click()) + .on_hover_cursor(egui::CursorIcon::PointingHand) + .on_hover_text(strings::open_calendar()); click.open = hit.clicked(); for (i, (direction, hint)) in [(-1, strings::previous_month()), (1, strings::next_month())].into_iter().enumerate() { let center = Pos2::new(card.right() - 42.0 + 24.0 * small(i), card.top() + 20.0); @@ -469,13 +477,20 @@ pub(crate) fn calendar_card(ui: &mut Ui, tokens: &Tokens, cal: &CalendarData, fo fold_control(ui, tokens, card, fold); } let month_name = strings::months().get(usize::from(cal.month.saturating_sub(1))).copied().unwrap_or(""); - ui.painter().text( + let title_rect = ui.painter().text( Pos2::new(card.left() + title_left, card.top() + 20.0), Align2::LEFT_CENTER, format!("{month_name} {}", cal.year), FontId::new(14.0, crate::fonts::semibold()), color(c.text), ); + if navigable { + // The title is a link to the full calendar: the icon after it says so. + let icon = + Rect::from_center_size(Pos2::new(title_rect.right() + TITLE_LINK_GAP + TITLE_LINK_ICON / 2.0, title_rect.center().y), Vec2::splat(TITLE_LINK_ICON)); + let hovered = ui.rect_contains_pointer(title_rect.union(icon)); + crate::icons::paint(ui, "link", icon, if hovered { color(c.accent) } else { color(c.text_muted) }); + } if folded { return click; } diff --git a/crates/ui-egui/src/panels/sidebar_tabs.rs b/crates/ui-egui/src/panels/sidebar_tabs.rs index f6da3ea..a412c5b 100644 --- a/crates/ui-egui/src/panels/sidebar_tabs.rs +++ b/crates/ui-egui/src/panels/sidebar_tabs.rs @@ -80,6 +80,17 @@ pub fn register(registry: &mut Registry) -> Result<(), notescraft_engin Ok(()) } +/// The hotkey id whose chord a tab's tooltip shows: Explorer is the sidebar focus chord. +fn hotkey_of(tab: SidebarTab) -> &'static str { + match tab { + SidebarTab::Explorer => "ui.focus_sidebar", + SidebarTab::Search => "ui.sidebar.search", + SidebarTab::Tags => "ui.sidebar.tags", + SidebarTab::Buffers => "ui.sidebar.buffers", + SidebarTab::Source => "ui.sidebar.source", + } +} + /// The tab names, in [`TABS`] order. fn tab_names() -> [&'static str; 5] { use crate::text::sidebar as strings; @@ -110,7 +121,7 @@ pub fn draw(ui: &mut Ui, tokens: &Tokens, tab: &mut SidebarTab, changes: usize) let previous = ui.ctx().data(|data| data.get_temp::(previous_key)).unwrap_or(selected); for ((candidate, icon, label, _), name) in TABS.into_iter().zip(tab_names()) { let rect = Rect::from_min_size(egui::pos2(left, strip.top()), vec2(cell, HEIGHT)); - let hit = ui.interact(rect, ui.id().with(candidate), Sense::click()).on_hover_text(label); + let hit = crate::hotkeys::on_hover_hotkey(ui.interact(rect, ui.id().with(candidate), Sense::click()), label, hotkey_of(candidate)); let active = selected == candidate; let radius = CornerRadius::same(crate::style::radius_u8(tokens.radius_medium())); if active { diff --git a/crates/ui-egui/src/panels/tags_panel.rs b/crates/ui-egui/src/panels/tags_panel.rs index 15f259a..ca966b1 100644 --- a/crates/ui-egui/src/panels/tags_panel.rs +++ b/crates/ui-egui/src/panels/tags_panel.rs @@ -109,5 +109,6 @@ fn row(ui: &mut Ui, tokens: &Tokens, node: &TagNode, collapsed: &mut BTreeSet, /// Context-menu choice, dispatched by the app. pub action: Option, + /// The hover trash button of a row was clicked: the app asks before deleting it. + pub delete: Option, /// A row was dropped on a folder: the move to make. pub moved: Option, } @@ -441,23 +450,29 @@ pub fn show_with_options( let pending = requested.clone().or_else(|| crate::vim_nav::pending_scroll(ui.ctx())); let centered = requested.is_some().then_some(egui::Align::Center); let mut toggles = Vec::new(); - ScrollArea::vertical().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + hscroll::map_shift_wheel(ui); + ScrollArea::both().auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { let origin = ui.min_rect().min; - let width = ui.available_width(); + // Rows are as wide as the widest laid-out row, so a narrow panel scrolls sideways instead of cutting names. + let window = row_window(viewport.min.y, viewport.max.y, rows.len()); + let measured = hscroll::measure_rows(ui, tokens, rows.get(window.clone()).unwrap_or_default()); + let width = hscroll::content_width(hscroll::widest_seen(ui.ctx(), &rows, measured), viewport.width()); + // What stays put while the list scrolls sideways: the sticky path and scroll requests. + let view_left = origin.x + viewport.min.x; + let view_width = viewport.width(); // Rows are laid out a little beyond the viewport (overscan) and by absolute position: whatever is painted // must stay inside the visible part, however fast the scroll offset moves between frames. ui.set_clip_rect(ui.clip_rect().intersect(Rect::from_min_size(origin + viewport.min.to_vec2(), viewport.size()))); let extra = if model.skipped > 0 { ROW_HEIGHT } else { 0.0 }; let total = list_height(rows.len()) + extra; - // Only the viewport (plus overscan) is laid out; the rest of the height is reserved below. - let window = row_window(viewport.min.y, viewport.max.y, rows.len()); if let Some(top) = pending.as_deref().and_then(|path| row_top(&rows, path)) { - ui.scroll_to_rect(Rect::from_min_size(Pos2::new(origin.x, origin.y + top), Vec2::new(width, ROW_HEIGHT)), centered); + ui.scroll_to_rect(Rect::from_min_size(Pos2::new(view_left, origin.y + top), Vec2::new(view_width, ROW_HEIGHT)), centered); } for (index, node) in rows.iter().enumerate().skip(window.start).take(window.len()) { let rect = Rect::from_min_size(Pos2::new(origin.x, origin.y + list_height(index)), Vec2::new(width, ROW_HEIGHT)); ui.scope_builder(egui::UiBuilder::new().max_rect(rect), |ui| { - let row = draw_row(ui, tokens, node, collapsed, selected); + let row = draw_row(ui, tokens, node, collapsed, selected, writable); + name_tip(ui, tokens, &row.hit, &node.name, &node.path); if node.folder { drop_on(ui, tokens, &row.hit, &node.path, &mut response); } @@ -491,9 +506,9 @@ pub fn show_with_options( } let trail = crumbs(&rows, first_visible_row(viewport.min.y)); if !trail.is_empty() { - let bar = Rect::from_min_size(Pos2::new(origin.x, origin.y + viewport.min.y), Vec2::new(width, crumbs::BAR_HEIGHT)); + let bar = Rect::from_min_size(Pos2::new(view_left, origin.y + viewport.min.y), Vec2::new(view_width, crumbs::BAR_HEIGHT)); if let Some(top) = crumbs::paint(ui, tokens, &trail, bar).and_then(|folder| row_top(&rows, &folder)) { - ui.scroll_to_rect(Rect::from_min_size(Pos2::new(origin.x, origin.y + top), Vec2::new(width, ROW_HEIGHT)), Some(egui::Align::TOP)); + ui.scroll_to_rect(Rect::from_min_size(Pos2::new(view_left, origin.y + top), Vec2::new(view_width, ROW_HEIGHT)), Some(egui::Align::TOP)); } } ui.allocate_rect(Rect::from_min_size(origin, Vec2::new(width, total)), Sense::hover()); @@ -545,6 +560,9 @@ fn merge(response: &mut TreeResponse, row: &RowOutcome) { if response.context.is_none() { response.context.clone_from(&row.context); } + if response.delete.is_none() { + response.delete.clone_from(&row.delete); + } } /// Result of drawing one row. @@ -559,11 +577,13 @@ struct RowOutcome { toggled: Option, /// Right-clicked path. context: Option, + /// The hover trash button was clicked: the path to delete. + delete: Option, } /// Draws one row: background by state (selected on `surface_2`, hover tinted), a rotating /// chevron for folders and the label. Reports clicks. -fn draw_row(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet, selected: Option<&str>) -> RowOutcome { +fn draw_row(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet, selected: Option<&str>, writable: bool) -> RowOutcome { // Rows fade in when they (re)appear, e.g. after their folder opens. // Rows drop into place with a small overshoot when they (re)appear, e.g. after their // folder opens; the opacity follows the plain (non-overshooting) curve. @@ -572,7 +592,7 @@ fn draw_row(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet, selected: Option<&str>, dy: f32) -> RowOutcome { +fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet, selected: Option<&str>, dy: f32, writable: bool) -> RowOutcome { let depth = node.depth; let (slot_rect, response) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::click_and_drag()); if response.drag_started() { @@ -655,23 +675,37 @@ fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTre egui::FontId::proportional(crate::style::TEXT_BODY), label_color, ); - if node.folder && node.notes > 0 { + // 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; + 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); } + if row_actions::trash_button(ui, tokens, slot_rect, &node.path, show_trash) { + // The trash answers for the row: the note is not opened and the folder is not toggled. + return RowOutcome { + hit: response, + opened: None, + opened_folder: None, + toggled: None, + context: None, + delete: Some(crate::context_menu::MenuAction { kind: crate::context_menu::MenuKind::Delete, path: node.path.clone(), folder: node.folder }), + }; + } if node.folder && response.double_clicked() { - return RowOutcome { hit: response, opened: None, opened_folder: Some(node.path.clone()), toggled: None, context: None }; + return RowOutcome { hit: response, opened: None, opened_folder: Some(node.path.clone()), toggled: None, context: None, delete: None }; } let clicked = response.clicked() || response.secondary_clicked(); if !clicked { - return RowOutcome { hit: response, opened: None, opened_folder: None, toggled: None, context: None }; + return RowOutcome { hit: response, opened: None, opened_folder: None, toggled: None, context: None, delete: None }; } let path = node.path.clone(); if response.secondary_clicked() { - RowOutcome { hit: response, context: Some(path), opened: None, opened_folder: None, toggled: None } + RowOutcome { hit: response, context: Some(path), opened: None, opened_folder: None, toggled: None, delete: None } } else if node.folder { - RowOutcome { hit: response, toggled: Some(path), opened: None, opened_folder: None, context: None } + RowOutcome { hit: response, toggled: Some(path), opened: None, opened_folder: None, context: None, delete: None } } else { - RowOutcome { hit: response, opened: Some(path), opened_folder: None, toggled: None, context: None } + RowOutcome { hit: response, opened: Some(path), opened_folder: None, toggled: None, context: None, delete: None } } } diff --git a/crates/ui-egui/src/panels/tree/hscroll.rs b/crates/ui-egui/src/panels/tree/hscroll.rs new file mode 100644 index 0000000..6a7037e --- /dev/null +++ b/crates/ui-egui/src/panels/tree/hscroll.rs @@ -0,0 +1,119 @@ +//! Horizontal scrolling of the explorer: how wide the content is, and the wheel mapping. +//! +//! The content is as wide as its widest laid-out row ([`row_width`]), never wider than +//! [`MAX_CONTENT_WIDTH`] and never narrower than the viewport ([`content_width`]). [`widest_seen`] +//! remembers the widest row measured since the visible rows last changed, so the width (and the scrollbar) +//! does not jump while the window of laid-out rows slides along a long list. + +use std::sync::Arc; + +use egui::{Context, Id, Ui, Vec2}; + +use super::{FlatRow, ICON_SIZE, INDENT, ROW_PADDING}; +use crate::theme::Tokens; + +/// The widest the scrollable content may get, in points. +pub const MAX_CONTENT_WIDTH: f32 = 2000.0; +/// Room kept right of the label for the note count or the hover trash button, in points. +const TRAILING_MIN: f32 = 28.0; +/// Gap between the label and what trails it, in points. +const TRAILING_GAP: f32 = 12.0; +/// Gap between the icon column and the label, in points. +const ICON_GAP: f32 = 4.0; + +/// The width a row needs: indent, chevron and icon columns, the whole label, and its trailing count. +#[must_use] +pub fn row_width(depth: usize, label: f32, trailing: f32) -> f32 { + let columns = 2.0 * (ICON_SIZE + ICON_GAP); + ROW_PADDING + INDENT * super::depth_points(depth) + columns + label + TRAILING_GAP + trailing.max(TRAILING_MIN) + ROW_PADDING +} + +/// The content width for a widest row of `widest` points in a viewport of `viewport` points: +/// at least the viewport, at most [`MAX_CONTENT_WIDTH`] (a viewport already wider wins). A hostile +/// `widest` (NaN, infinite) gives the viewport. +#[must_use] +pub fn content_width(widest: f32, viewport: f32) -> f32 { + if !widest.is_finite() { + return viewport; + } + widest.min(MAX_CONTENT_WIDTH).max(viewport) +} + +/// Shift plus the wheel scrolls sideways: the vertical wheel delta becomes a horizontal one. +#[must_use] +pub fn wheel_to_horizontal(delta: Vec2, shift: bool) -> Vec2 { + if shift { Vec2::new(delta.x + delta.y, 0.0) } else { delta } +} + +/// Turns this frame's shift-wheel into a horizontal scroll, while the pointer is over `ui`. +pub fn map_shift_wheel(ui: &Ui) { + if !ui.rect_contains_pointer(ui.max_rect()) { + return; + } + ui.input_mut(|input| input.smooth_scroll_delta = wheel_to_horizontal(input.smooth_scroll_delta, input.modifiers.shift)); +} + +/// What the width memory holds: the row list it was measured for and the widest row so far. +#[derive(Clone, Copy)] +struct Widest { + rows: usize, + width: f32, +} + +/// The widest row measured so far for this row list (`rows` identifies the list), grown by `measured`. +pub fn widest_seen(ctx: &Context, rows: &Arc>, measured: f32) -> f32 { + let id = Id::new("explorer-widest-row"); + let key = Arc::as_ptr(rows) as usize; + let before = ctx.data(|data| data.get_temp::(id)).filter(|seen| seen.rows == key).map_or(0.0, |seen| seen.width); + let width = before.max(measured); + ctx.data_mut(|data| data.insert_temp(id, Widest { rows: key, width })); + width +} + +/// The width the visible `window` of rows needs, measured with the real fonts. +#[must_use] +pub fn measure_rows(ui: &Ui, tokens: &Tokens, rows: &[FlatRow]) -> f32 { + let colors = &tokens.theme.colors; + let text = crate::theme::color(colors.text); + let body = egui::FontId::proportional(crate::style::TEXT_BODY); + let tiny = egui::FontId::proportional(crate::style::TEXT_TINY); + rows.iter() + .map(|row| { + let label = ui.painter().layout_no_wrap(row.name.clone(), body.clone(), text).size().x; + let badge = if row.folder && row.notes > 0 { ui.painter().layout_no_wrap(row.notes.to_string(), tiny.clone(), text).size().x } else { 0.0 }; + row_width(row.depth, label, badge) + }) + .fold(0.0, f32::max) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn content_is_at_least_the_viewport_and_at_most_the_cap() { + assert!((content_width(120.0, 300.0) - 300.0).abs() < f32::EPSILON, "a narrow list fills the viewport"); + assert!((content_width(640.0, 300.0) - 640.0).abs() < f32::EPSILON, "a wide row widens the content"); + assert!((content_width(9000.0, 300.0) - MAX_CONTENT_WIDTH).abs() < f32::EPSILON, "capped"); + assert!((content_width(9000.0, 2500.0) - 2500.0).abs() < f32::EPSILON, "a wider viewport wins over the cap"); + assert!((content_width(f32::NAN, 300.0) - 300.0).abs() < f32::EPSILON); + assert!((content_width(f32::INFINITY, 300.0) - 300.0).abs() < f32::EPSILON); + } + + #[test] + fn a_row_needs_room_for_its_indent_label_and_count() { + 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!(flat > 100.0 + 2.0 * (ICON_SIZE + ICON_GAP), "the label is never overlapped by the icons"); + } + + #[test] + fn shift_turns_the_vertical_wheel_sideways() { + let wheel = Vec2::new(0.0, -30.0); + assert_eq!(wheel_to_horizontal(wheel, true), Vec2::new(-30.0, 0.0)); + assert_eq!(wheel_to_horizontal(wheel, false), wheel); + assert_eq!(wheel_to_horizontal(Vec2::new(8.0, -30.0), true), Vec2::new(-22.0, 0.0), "a trackpad swipe keeps its horizontal part"); + } +} diff --git a/crates/ui-egui/src/panels/tree/name_tip.rs b/crates/ui-egui/src/panels/tree/name_tip.rs new file mode 100644 index 0000000..cc01211 --- /dev/null +++ b/crates/ui-egui/src/panels/tree/name_tip.rs @@ -0,0 +1,37 @@ +//! The hover tooltip of an explorer row: the full name, and the vault-relative path below it. + +use egui::{Context, PopupAnchor, Response, Ui}; + +use crate::theme::{Tokens, color}; + +/// How long the pointer must rest on a row before the tooltip shows, in seconds. +pub const DELAY_SECS: f32 = 0.4; +/// Gap between the pointer and the tooltip, in points. +const GAP: f32 = 12.0; + +/// Whether a tooltip may show now: not while a row is dragged or a menu is open. +fn is_quiet(ctx: &Context) -> bool { + ctx.dragged_id().is_none() && !egui::DragAndDrop::has_any_payload(ctx) && !egui::Popup::is_any_open(ctx) +} + +/// Shows the full `name` (and `path`, muted) next to the pointer once it has rested on `response` +/// for [`DELAY_SECS`]. +pub fn name_tip(ui: &Ui, tokens: &Tokens, response: &Response, name: &str, path: &str) { + let ctx = ui.ctx(); + if !response.hovered() || !is_quiet(ctx) { + return; + } + let still = ctx.input(|input| input.pointer.time_since_last_movement()); + if still < DELAY_SECS { + ctx.request_repaint_after_secs(DELAY_SECS - still); + return; + } + let colors = &tokens.theme.colors; + egui::Popup::from_response(response).kind(egui::PopupKind::Tooltip).anchor(PopupAnchor::Pointer).gap(GAP).show(|ui| { + ui.set_max_width(ui.spacing().tooltip_width); + ui.label(egui::RichText::new(name).color(color(colors.text)).size(crate::style::TEXT_BODY)); + if path != name { + ui.label(egui::RichText::new(path).color(color(colors.text_muted)).size(crate::style::TEXT_TINY)); + } + }); +} diff --git a/crates/ui-egui/src/panels/tree/row_actions.rs b/crates/ui-egui/src/panels/tree/row_actions.rs new file mode 100644 index 0000000..5b269f4 --- /dev/null +++ b/crates/ui-egui/src/panels/tree/row_actions.rs @@ -0,0 +1,41 @@ +//! The action a tree row offers on hover: the trash button. +//! +//! The explorer rows are virtualised, so a button is only painted inside the row's own rect; it +//! never asks the layout for room. Clicking it does not delete anything: it reports the row, and +//! the app asks for confirmation through the ordinary delete prompt. + +use egui::{Pos2, Rect, Sense, Ui, Vec2}; + +use super::ROW_PADDING; +use crate::theme::{Tokens, color}; + +/// Edge length of the trash icon, points. +const ICON_SIZE: f32 = 14.0; +/// Edge of the button's hit area, points. +const BUTTON_SIZE: f32 = 22.0; + +/// The rect the trash icon of a row is painted in: at the right edge of the row. +#[must_use] +pub(crate) fn icon_rect(row: Rect) -> Rect { + Rect::from_center_size(Pos2::new(row.right() - ROW_PADDING - ICON_SIZE / 2.0, row.center().y), Vec2::splat(ICON_SIZE)) +} + +/// The row's trash button, painted inside `row` while the row is hovered; `true` when it was +/// clicked. Nothing is drawn (and nothing can be clicked) while the row is not hovered. +pub(crate) fn trash_button(ui: &mut Ui, tokens: &Tokens, row: Rect, path: &str, hovered: bool) -> bool { + if !hovered { + return false; + } + let colors = &tokens.theme.colors; + let icon = icon_rect(row); + let hit = ui.interact(Rect::from_center_size(icon.center(), Vec2::splat(BUTTON_SIZE)), ui.id().with(("tree-trash", path)), Sense::click()); + let label = crate::text::sidebar::explorer::delete(); + hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); + // The trash is red while the pointer is on it: the action it starts is destructive. + let tint = if hit.hovered() { color(colors.danger) } else { color(colors.text_muted) }; + crate::lottie_icon::paint_hover(ui, hit.id, "trash-2", icon, tint, hit.hovered()); + if hit.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + hit.on_hover_text(label).clicked() +} diff --git a/crates/ui-egui/src/panels/tree/scroll_tests.rs b/crates/ui-egui/src/panels/tree/scroll_tests.rs new file mode 100644 index 0000000..cc7c875 --- /dev/null +++ b/crates/ui-egui/src/panels/tree/scroll_tests.rs @@ -0,0 +1,76 @@ +//! A narrow explorer scrolls sideways (shift + wheel) and a hovered row names itself in full. + +#![cfg(test)] + +use std::collections::BTreeSet; + +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 crate::testsupport; +use crate::theme::Tokens; + +const LONG_NAME: &str = "A very long note name that cannot possibly fit a narrow sidebar"; + +fn harness() -> Option> { + let tokens = Tokens::new(testsupport::theme("catppuccin-mocha")?); + let model = TreeModel::from_paths(&[format!("{LONG_NAME}.md"), "short.md".to_owned()]); + let mut collapsed = BTreeSet::new(); + let mut harness = Harness::builder().with_size(vec2(160.0, 300.0)).build_ui_state( + move |ui, (): &mut ()| { + crate::motion::set_enabled(ui.ctx(), false); + let _ = show(ui, &tokens, &model, &mut collapsed, None); + }, + (), + ); + harness.run(); + Some(harness) +} + +/// Where the long row's label starts: it moves left as the list scrolls right. +fn label_x(harness: &Harness<'_, ()>) -> f32 { + harness + .output() + .shapes + .iter() + .find_map(|clipped| match &clipped.shape { + egui::epaint::Shape::Text(text) if text.galley.text() == LONG_NAME => Some(text.pos.x), + _ => None, + }) + .unwrap_or(f32::NAN) +} + +fn wheel(harness: &mut Harness<'_, ()>, delta: Vec2, modifiers: Modifiers) { + harness.hover_at(pos2(60.0, 24.0)); + harness.run(); + harness.event_modifiers(Event::MouseWheel { unit: MouseWheelUnit::Point, delta, phase: TouchPhase::Move, modifiers }, modifiers); + harness.run(); +} + +#[test] +fn shift_wheel_scrolls_a_narrow_tree_sideways() { + let Some(mut harness) = harness() else { return }; + let start = label_x(&harness); + assert!(start.is_finite(), "the long row is painted"); + wheel(&mut harness, vec2(0.0, -60.0), Modifiers::NONE); + assert!((label_x(&harness) - start).abs() < 0.5, "the plain wheel scrolls up and down only"); + wheel(&mut harness, vec2(0.0, -60.0), Modifiers::SHIFT); + let moved = label_x(&harness); + assert!(moved < start - 1.0, "shift + wheel scrolled right: {start} -> {moved}"); + wheel(&mut harness, vec2(200.0, 0.0), Modifiers::NONE); + assert!(label_x(&harness) > moved, "a horizontal swipe scrolls back"); +} + +#[test] +fn hovering_a_row_shows_its_full_name_and_path_after_the_delay() { + let Some(mut harness) = harness() else { return }; + 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; + 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 797bddc..02f1431 100644 --- a/crates/ui-egui/src/panels/tree_tests.rs +++ b/crates/ui-egui/src/panels/tree_tests.rs @@ -168,6 +168,42 @@ fn explorer_drags_file_and_folder_context_without_opening_or_toggling() { } } +#[test] +fn the_hover_trash_button_asks_for_the_row_it_sits_on() { + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let model = TreeModel::from_paths(&paths(&["solo.md"])); + let mut collapsed = BTreeSet::new(); + let ctx = egui::Context::default(); + crate::motion::set_enabled(&ctx, false); + let width = 300.0; + let mut frame = |events| { + let mut response = crate::tree::TreeResponse::default(); + let mut output = ctx.run_ui( + RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 200.0))), ..RawInput::default() }, + |ui| response = show(ui, &tokens, &model, &mut collapsed, None), + ); + output.textures_delta.clear(); + response + }; + 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); + 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 }]); + let Some(action) = response.delete else { continue }; + assert_eq!(action.kind, crate::context_menu::MenuKind::Delete); + assert_eq!(action.path, "solo.md"); + assert!(!action.folder); + assert!(response.opened.is_none(), "the trash answers for the row: the note is not opened"); + return; + } + panic!("the hover trash button was not found at the right edge of the row"); +} + #[test] fn folders_carry_the_count_of_notes_below_them() { let model = TreeModel::from_paths_and_folders(&paths(&["a/x.md", "a/y.md", "a/deep/z.md", "solo.md"]), &paths(&["empty"])); @@ -458,3 +494,40 @@ fn dropping_a_row_on_a_folder_moves_it_and_refuses_no_ops() { assert_eq!(crate::tree::move_of(&folder, "a/deep"), None, "into its own child"); assert_eq!(crate::tree::move_of(&folder, "ab").map(|m| m.to), Some("ab/a".to_owned()), "a name prefix is not a parent"); } + +#[test] +fn debug_hover_tmp() { + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let model = TreeModel::from_paths(&paths(&["solo.md"])); + let mut collapsed = BTreeSet::new(); + let ctx = egui::Context::default(); + let width = 300.0; + let mut frame = |events| { + let mut response = crate::tree::TreeResponse::default(); + let mut output = ctx.run_ui( + RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 200.0))), ..RawInput::default() }, + |ui| response = show(ui, &tokens, &model, &mut collapsed, None), + ); + output.textures_delta.clear(); + response + }; + frame(Vec::new()); + let pos = egui::pos2(280.0, 16.0); + frame(vec![egui::Event::PointerMoved(pos)]); + let out = ctx.run_ui( + RawInput { events: vec![], screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(width, 200.0))), ..RawInput::default() }, + |ui| { + let _ = show(ui, &tokens, &model, &mut collapsed, None); + }, + ); + for shape in &out.shapes { + if let egui::epaint::Shape::Rect(rect) = &shape.shape { + println!("RECT {:?} fill {:?}", rect.rect, rect.fill); + } + if let egui::epaint::Shape::Text(text) = &shape.shape { + println!("TEXT {:?} at {:?}", text.galley.text(), text.pos); + } + } + println!("pointer {:?}", ctx.input(|i| i.pointer.interact_pos())); +} diff --git a/crates/ui-egui/src/shell/app/editor/attachments.rs b/crates/ui-egui/src/shell/app/editor/attachments.rs index af5346e..85fc9aa 100644 --- a/crates/ui-egui/src/shell/app/editor/attachments.rs +++ b/crates/ui-egui/src/shell/app/editor/attachments.rs @@ -1,10 +1,10 @@ //! Attachment commands and one combined insertion for each paste/drop batch. use crate::shell::app::NotesApp; +use crate::text::paste as strings; use crate::{attachment_drop::Request, center::ViewMode, sounds::Cue, tabs::TabKind}; use serde_json::json; -const SAVED: &str = "Saved"; const MIB: usize = 1024 * 1024; impl NotesApp { @@ -16,7 +16,7 @@ impl NotesApp { let source = self.editing.view_of(Some(path)) == ViewMode::Markdown; let focused = self.editing.tabs.get(path).and_then(|tab| tab.widget_id).is_some_and(|id| ctx.memory(|memory| memory.has_focus(id))); self.attachment_input.max_bytes = usize::try_from(self.session.config().files.max_attachment_mib).unwrap_or_default().saturating_mul(MIB); - self.attachment_input.collect(ctx, source, focused, self.session.is_read_only()) + self.attachment_input.collect(ctx, source, focused, self.session.is_read_only(), self.session.vault().root()) } pub(in crate::shell::app) fn save_note_attachments(&mut self, path: Option<&str>, requests: Vec, now: f64, ctx: &egui::Context) { @@ -28,18 +28,27 @@ impl NotesApp { return; } let mut links = Vec::new(); - let mut names = Vec::new(); + let mut saved = Vec::new(); + let mut linked = Vec::new(); let mut failure = None; for request in requests { - if let Some(result) = self.engine_command(request.command_id, &request.params) { - if let Some(link) = result.get("link").and_then(serde_json::Value::as_str) { - links.push(link.to_owned()); + match request { + Request::Command { command_id, params } => { + if let Some(result) = self.engine_command(command_id, ¶ms) { + if let Some(link) = result.get("link").and_then(serde_json::Value::as_str) { + links.push(link.to_owned()); + } + if let Some(name) = result.get("path").and_then(serde_json::Value::as_str) { + saved.push(name.rsplit('/').next().unwrap_or(name).to_owned()); + } + } else { + failure.clone_from(&self.toast); + } } - if let Some(name) = result.get("path").and_then(serde_json::Value::as_str) { - names.push(name.rsplit('/').next().unwrap_or(name).to_owned()); + Request::Link { text, name } => { + links.push(text); + linked.push(name); } - } else { - failure.clone_from(&self.toast); } } if !links.is_empty() { @@ -54,7 +63,7 @@ impl NotesApp { let text = links.join("\n"); let result = json!({"cursor":text.len(),"text":text}); if self.insert_command_result(path, primary.min(secondary)..primary.max(secondary), &result, now, ctx) { - self.toast = Some(format!("{SAVED} {}", names.join(", "))); + self.toast = Some(paste_toast(&saved, &linked)); self.cue(Cue::Pop); } self.refresh_tree(); @@ -65,6 +74,16 @@ impl NotesApp { } } +/// The toast of a paste: the files the engine saved and the files it linked. +fn paste_toast(saved: &[String], linked: &[String]) -> String { + match (saved.is_empty(), linked.is_empty()) { + (false, false) => strings::saved_and_linked_files(&saved.join(", "), &linked.join(", ")), + (false, true) => strings::saved_files(&saved.join(", ")), + (true, false) => strings::linked_files(&linked.join(", ")), + (true, true) => String::new(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -83,7 +102,7 @@ mod tests { app.open_note("origin.md"); app.open_note("other.md"); assert_eq!(app.tabs.active_path(), Some("other.md")); - let request = Request { command_id: "attachment.save", params: json!({"ext":"png", "data_base64":"YWJj", "name":"picture.png"}) }; + let request = Request::Command { command_id: "attachment.save", params: json!({"ext":"png", "data_base64":"YWJj", "name":"picture.png"}) }; app.save_note_attachments(Some("origin.md"), vec![request], 1.0, &egui::Context::default()); assert_eq!(app.session.buffer("origin.md").map(notescraft_engine::Buffer::text), Some("Origin![[picture.png]]")); assert_eq!(app.session.buffer("other.md").map(notescraft_engine::Buffer::text), Some("Other")); @@ -100,9 +119,25 @@ mod tests { let mut app = NotesApp::new(session, theme); app.open_note("origin.md"); app.editing.tabs.remove("origin.md"); - let request = Request { command_id: "attachment.save", params: json!({"ext":"png", "data_base64":"YWJj", "name":"picture.png"}) }; + let request = Request::Command { command_id: "attachment.save", params: json!({"ext":"png", "data_base64":"YWJj", "name":"picture.png"}) }; app.save_note_attachments(Some("origin.md"), vec![request], 1.0, &egui::Context::default()); assert!(!vault.join("Attachments/picture.png").exists()); assert_eq!(app.session.buffer("origin.md").map(notescraft_engine::Buffer::text), Some("Origin")); } + + #[test] + fn a_link_request_inserts_the_link_without_copying_anything() { + let vault = crate::testsupport::temp_vault("attachment-link-request"); + let Ok(mut session) = notescraft_engine::Session::open(&vault) else { return }; + session.use_settings_dir(vault.join(".notescraft/test-config")); + assert!(session.create_note("origin.md", "Origin").is_ok()); + let Some(theme) = crate::testsupport::theme("catppuccin-mocha") else { return }; + let mut app = NotesApp::new(session, theme); + app.open_note("origin.md"); + let request = Request::Link { text: "[[Attachments/paper.pdf]]".to_owned(), name: "paper.pdf".to_owned() }; + app.save_note_attachments(Some("origin.md"), vec![request], 1.0, &egui::Context::default()); + assert_eq!(app.session.buffer("origin.md").map(notescraft_engine::Buffer::text), Some("Origin[[Attachments/paper.pdf]]")); + assert!(!vault.join("Attachments/paper.pdf").exists()); + assert_eq!(app.toast.as_deref(), Some("Linked paper.pdf")); + } } diff --git a/crates/ui-egui/src/shell/app/editor/editor_tools.rs b/crates/ui-egui/src/shell/app/editor/editor_tools.rs index 62751cc..14f02bd 100644 --- a/crates/ui-egui/src/shell/app/editor/editor_tools.rs +++ b/crates/ui-egui/src/shell/app/editor/editor_tools.rs @@ -72,7 +72,11 @@ impl NotesApp { let name = if kind == notescraft_markdown::PropType::List { "multitext" } else { kind.as_str() }; ("note.property_set", json!({"path":path,"key":key,"type":name,"value":value}), Cue::Pop) } - PropertiesAction::Remove { key } => ("note.property_remove", json!({"path":path,"key":key}), Cue::Close), + PropertiesAction::Remove { key } => { + // A property is easy to add back, but removing one changes the note: ask first. + self.menus.confirm = Some(crate::shell::app::window::confirm::Confirm::Property { path: path.to_owned(), key }); + continue; + } PropertiesAction::Rename { from, to } => ("note.property_rename", json!({"path":path,"from":from,"to":to}), Cue::Tick), PropertiesAction::ReplaceRaw { range, text } => { let result = json!({"text":text,"cursor":0}); @@ -93,6 +97,19 @@ impl NotesApp { } } + /// Removes a frontmatter property, after the user confirmed the removal. + pub(in crate::shell::app) fn remove_property(&mut self, path: &str, key: &str, now: f64, ctx: &egui::Context) { + let params = json!({"path":path,"key":key}); + let before = self.session.buffer(path).map(|buffer| buffer.text().to_owned()); + if self.engine_command("note.property_remove", ¶ms).is_some() { + if let Some(before) = before { + self.remember_property_edit(path, &before, ctx); + } + self.editing.mark_changed(path, now); + self.cue(Cue::Close); + } + } + fn remember_property_edit(&mut self, path: &str, before: &str, ctx: &egui::Context) { let Some(edit) = self.editing.editor_of(path) else { return }; let Some(id) = edit.widget_id else { return }; diff --git a/crates/ui-egui/src/shell/app/vault/hibernation/mod.rs b/crates/ui-egui/src/shell/app/vault/hibernation/mod.rs new file mode 100644 index 0000000..3ef2390 --- /dev/null +++ b/crates/ui-egui/src/shell/app/vault/hibernation/mod.rs @@ -0,0 +1,107 @@ +//! Tab hibernation: memory use does not grow with the number of open tabs. Only the active tab +//! and the most recently used few stay HOT (editor text, undo, parsed document, session buffer, +//! file views); every other tab is COLD and keeps a [`crate::editing::TabMeta`]. Activating a cold +//! tab reloads it from disk through `ensure_active_loaded`. +//! +//! A tab with unsaved work is never dropped: it stays hot past the limit until the autosave +//! writes it, then it becomes a normal candidate. That is the safe choice: no edit can be lost +//! and the paint path does no disk IO. + +use crate::shell::app::NotesApp; + +/// Tabs kept hot, the active one included. Dirty and live tabs stay hot on top of this. +/// ponytail: a constant; wire to an `editor.hot_tabs` setting when the engine grows one. +pub const HOT_TABS: usize = 6; + +/// Closed-tab memory (view mode, caret) kept before the oldest-closed are forgotten. +const MAX_COLD_META: usize = crate::tabs::MAX_PERSISTED_TABS; + +/// The hot tabs to put to sleep, oldest use first. Keeps the active tab, every tab `keep` names +/// (unsaved or live) and the most recently used ones up to `limit` hot tabs counting the active one. +#[must_use] +pub fn plan_drops(hot: &[(String, u64)], active: Option<&str>, keep: impl Fn(&str) -> bool, limit: usize) -> Vec { + let mut idle: Vec<&(String, u64)> = hot.iter().filter(|(path, _)| Some(path.as_str()) != active && !keep(path)).collect(); + idle.sort_by_key(|(_, used)| std::cmp::Reverse(*used)); + let budget = limit.saturating_sub(usize::from(active.is_some_and(|a| hot.iter().any(|(path, _)| path == a)))); + idle.into_iter().skip(budget).rev().map(|(path, _)| path.clone()).collect() +} + +impl NotesApp { + /// One small step per frame: records the active tab's use and puts at most one tab to sleep. + pub(in crate::shell::app) fn hibernate_step(&mut self, ctx: &egui::Context) { + if let Some(active) = self.tabs.active_path() + && self.editing.last_active() != Some(active) + { + let active = active.to_owned(); + self.editing.touch(&active); + } + for id in std::mem::take(&mut self.editing.released) { + if let Some(mut state) = egui::text_edit::TextEditState::load(ctx, id) { + // The undo history is the heavy part of the widget's memory; the caret stays. + state.clear_undoer(); + state.store(ctx, id); + } + } + if self.editing.tabs.len() <= HOT_TABS { + return; + } + let hot = self.editing.hot_by_use(); + let drops = plan_drops(&hot, self.tabs.active_path(), |path| self.is_unsettled(path), HOT_TABS); + if let Some(path) = drops.first() { + self.hibernate_tab(path); + } + let tabs = &self.tabs; + self.editing.prune_meta(MAX_COLD_META, |path| tabs.is_open(path)); + } + + /// Whether a tab must stay hot: unsaved work, a dirty buffer, or a live room. + fn is_unsettled(&self, path: &str) -> bool { + let buffer_differs = self + .session + .buffer(path) + .is_some_and(|buffer| buffer.is_dirty() || self.editing.tabs.get(path).is_some_and(|tab| tab.editor.text != buffer.text())); + buffer_differs || self.editing.has_pending_work(path) || self.live.status(path) != crate::shell::app::live::Status::Off + } + + /// Drops the live state of `path` and keeps its cold memory. Never called for an unsettled tab. + pub(in crate::shell::app) fn hibernate_tab(&mut self, path: &str) { + let _ = self.editing.hibernate(path); + if let (Some(callouts), Some(meta)) = (self.folds.remove(path), self.editing.meta.get_mut(path)) { + meta.callouts = callouts; + } + let _ = self.vim_state.remove(path); + let _ = self.views.files.remove(path); + self.views.inline.retain(|(owner, _), _| owner != path); + let _ = self.texts.remove(path); + let _ = self.docs.remove(path); + let _ = self.session.close_buffer(path, false); + } +} + +#[cfg(test)] +mod tests; + +#[cfg(test)] +mod policy_tests { + use super::*; + + fn hot(paths: &[(&str, u64)]) -> Vec<(String, u64)> { + paths.iter().map(|(path, used)| ((*path).to_owned(), *used)).collect() + } + + #[test] + fn the_oldest_idle_tabs_go_first_and_the_active_one_stays() { + let tabs = hot(&[("a", 1), ("b", 5), ("c", 3), ("d", 9), ("e", 2)]); + assert_eq!(plan_drops(&tabs, Some("a"), |_| false, 3), ["e", "c"], "a is active; b and d are the two freshest others"); + assert_eq!(plan_drops(&tabs, Some("a"), |_| false, 5), Vec::::new()); + assert_eq!(plan_drops(&tabs, None, |_| false, 2), ["a", "e", "c"]); + } + + #[test] + fn unsaved_tabs_are_never_planned_and_do_not_use_the_budget() { + let tabs = hot(&[("a", 9), ("dirty", 1), ("c", 3), ("d", 2)]); + let drops = plan_drops(&tabs, Some("a"), |path| path == "dirty", 2); + assert_eq!(drops, ["d"], "dirty stays; c fills the one free slot"); + assert!(!plan_drops(&tabs, Some("a"), |_| true, 1).iter().any(|p| p == "dirty")); + } +} diff --git a/crates/ui-egui/src/shell/app/vault/hibernation/tests.rs b/crates/ui-egui/src/shell/app/vault/hibernation/tests.rs new file mode 100644 index 0000000..21ad0f6 --- /dev/null +++ b/crates/ui-egui/src/shell/app/vault/hibernation/tests.rs @@ -0,0 +1,166 @@ +//! Hibernation end to end: many tabs, few hot ones, nothing unsaved ever lost. +use crate::center::ViewMode; +use crate::shell::app::NotesApp; +use crate::shell::app::vault::hibernation::HOT_TABS; +use crate::testsupport; + +fn vault_with(name: &str, count: usize) -> Option<(std::path::PathBuf, NotesApp)> { + let root = testsupport::temp_vault(name); + for index in 0..count { + testsupport::write_note(&root, &format!("n{index}"), &format!("# Note {index}\n\nbody {index}\n")); + } + let mut session = notescraft_engine::Session::open(&root).ok()?; + session.use_settings_dir(root.join("config")); + let mut app = NotesApp::new(session, testsupport::theme("catppuccin-mocha")?); + app.state.animations = false; + Some((root, app)) +} + +/// One frame's worth of hibernation after opening a note. +fn open_and_step(app: &mut NotesApp, ctx: &egui::Context, index: usize) { + app.open_note(&format!("n{index}.md")); + app.hibernate_step(ctx); +} + +#[test] +fn five_hundred_tabs_leave_only_the_hot_ones_in_every_map() { + let Some((_root, mut app)) = vault_with("hib-500", 500) else { return }; + let ctx = egui::Context::default(); + for index in 0..500 { + open_and_step(&mut app, &ctx, index); + } + assert_eq!(app.tabs.open.len(), 500, "every tab stays open"); + assert!(app.editing.tabs.len() <= HOT_TABS, "editing: {}", app.editing.tabs.len()); + assert!(app.session.buffers().len() <= HOT_TABS, "buffers: {}", app.session.buffers().len()); + assert!(app.texts.len() <= 1 && app.docs.len() <= 1); + assert!(app.views.files.len() <= HOT_TABS && app.folds.len() <= HOT_TABS && app.vim_state.len() <= HOT_TABS); + assert!(app.editing.meta.len() >= 500 - HOT_TABS, "cold tabs keep their small memory"); + assert!(app.editing.tabs.contains_key("n499.md"), "the active tab is hot"); +} + +#[test] +fn a_cold_tab_wakes_from_disk_with_its_view_and_caret() { + let Some((_root, mut app)) = vault_with("hib-round", 20) else { return }; + let ctx = egui::Context::default(); + open_and_step(&mut app, &ctx, 0); + app.editing.set_view("n0.md", ViewMode::Markdown); + if let Some(tab) = app.editing.editor_of("n0.md") { + tab.editor.cursor = Some(5); + } + for index in 1..20 { + open_and_step(&mut app, &ctx, index); + } + assert!(!app.editing.tabs.contains_key("n0.md"), "n0 went cold"); + assert!(app.session.buffer("n0.md").is_none()); + assert_eq!(app.editing.meta.get("n0.md").map(|meta| meta.view), Some(ViewMode::Markdown)); + app.open_note("n0.md"); + let tab = app.editing.tabs.get("n0.md"); + assert_eq!(tab.map(|tab| tab.view), Some(ViewMode::Markdown)); + assert_eq!(tab.and_then(|tab| tab.editor.cursor), Some(5)); + assert_eq!(tab.map(|tab| tab.editor.text.as_str()), Some("# Note 0\n\nbody 0\n"), "text came back from disk"); + assert!(!app.editing.meta.contains_key("n0.md"), "hot again"); +} + +#[test] +fn a_tab_with_unsaved_edits_stays_hot_with_its_text() { + let Some((_root, mut app)) = vault_with("hib-dirty", 30) else { return }; + let ctx = egui::Context::default(); + open_and_step(&mut app, &ctx, 0); + let typed = "# Note 0\n\nunsaved words\n"; + let len = app.session.buffer("n0.md").map_or(0, |buffer| buffer.text().len()); + assert!(app.session.buffer_mut("n0.md").is_ok_and(|buffer| buffer.edit(0..len, typed).is_ok())); + if let Some(tab) = app.editing.editor_of("n0.md") { + tab.editor.text = typed.to_owned(); + } + app.editing.mark_changed("n0.md", 1.0); + for index in 1..30 { + open_and_step(&mut app, &ctx, index); + } + assert_eq!(app.editing.tabs.get("n0.md").map(|tab| tab.editor.text.as_str()), Some(typed)); + assert_eq!(app.session.buffer("n0.md").map(notescraft_engine::Buffer::text), Some(typed)); + assert!(app.editing.tabs.len() <= HOT_TABS + 1, "only the dirty tab is over the limit: {}", app.editing.tabs.len()); + // Saved: it is a normal candidate again and goes cold on the next steps. + assert!(app.session.save_buffer("n0.md").is_ok()); + app.editing.saved("n0.md"); + for _ in 0..3 { + app.hibernate_step(&ctx); + } + assert!(!app.editing.tabs.contains_key("n0.md")); + app.open_note("n0.md"); + assert_eq!(app.active_text(), Some(typed), "the saved text is what the disk holds"); +} + +#[test] +fn restoring_a_workspace_of_two_thousand_tabs_reads_one_note() { + let Some((root, mut app)) = vault_with("hib-restore", 2_000) else { return }; + app.tabs.open = (0..2_000).map(|index| format!("n{index}.md")).collect(); + app.tabs.active = 1_234; + app.save_workspace_named("big"); + let Some(session) = notescraft_engine::Session::open(&root).ok() else { return }; + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let mut fresh = NotesApp::new(session, theme); + fresh.session.use_settings_dir(root.join("config")); + fresh.state.animations = false; + fresh.load_workspace_named("big"); + assert_eq!(fresh.tabs.open.len(), 2_000); + assert_eq!(fresh.tabs.active_path(), Some("n1234.md")); + assert!(fresh.session.buffers().len() <= 1, "only the active note was read: {}", fresh.session.buffers().len()); + assert!(fresh.editing.tabs.len() <= 1); +} + +/// Prints the resident size of the per-tab state for 300 real notes, with and without hibernation. +/// `NC_HIBERNATE=0 cargo test ... memory_report -- --ignored --nocapture` for the "before" run. +#[test] +#[ignore = "reads ~/Documents/vaults/mdn-docs and prints numbers"] +fn memory_report() { + let source = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()).join("Documents/vaults/mdn-docs"); + let mut files = Vec::new(); + let mut stack = vec![source]; + while let Some(dir) = stack.pop() { + for entry in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let path = entry.path(); + if path.is_dir() { + stack.push(path); + } else if path.extension().is_some_and(|ext| ext == "md") && entry.metadata().is_ok_and(|meta| meta.len() > 8_000) { + files.push(path); + } + } + if files.len() >= 300 { + break; + } + } + files.sort(); + files.truncate(300); + let root = testsupport::temp_vault("hib-memory"); + for (index, file) in files.iter().enumerate() { + let _ = std::fs::copy(file, root.join(format!("m{index}.md"))); + } + let Some(mut session) = notescraft_engine::Session::open(&root).ok() else { return }; + session.use_settings_dir(root.join("config")); + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let mut app = NotesApp::new(session, theme); + app.state.animations = false; + let hibernate = std::env::var("NC_HIBERNATE").map_or(true, |value| value != "0"); + let ctx = egui::Context::default(); + let rss = || { + let out = std::process::Command::new("ps").args(["-o", "rss=", "-p", &std::process::id().to_string()]).output().map(|o| o.stdout).unwrap_or_default(); + String::from_utf8_lossy(&out).trim().parse::().unwrap_or(0) + }; + let before = rss(); + for index in 0..files.len() { + app.open_note(&format!("m{index}.md")); + if hibernate { + app.hibernate_step(&ctx); + } + } + let editor_bytes: usize = app.editing.tabs.values().map(|tab| tab.editor.text.len()).sum(); + let buffer_bytes: usize = app.session.buffers().values().map(|buffer| buffer.text().len()).sum(); + println!( + "hibernation={hibernate} tabs={} hot={} buffers={} editor_text_bytes={editor_bytes} buffer_text_bytes={buffer_bytes} cold_meta={} rss_growth_kb={}", + app.tabs.open.len(), + app.editing.tabs.len(), + app.session.buffers().len(), + app.editing.meta.len(), + rss().saturating_sub(before) + ); +} diff --git a/crates/ui-egui/src/shell/app/vault/history.rs b/crates/ui-egui/src/shell/app/vault/history.rs index 93ec923..fcfc71a 100644 --- a/crates/ui-egui/src/shell/app/vault/history.rs +++ b/crates/ui-egui/src/shell/app/vault/history.rs @@ -218,7 +218,7 @@ impl NotesApp { pub(in crate::shell::app) fn history_tick(&mut self) { if let Some(request) = self.state.nav_request.take() { // A stop that cannot be opened any more is dropped and the next one tried. - for _ in 0..crate::tabs::MAX_TABS.saturating_add(MAX_STOPS) { + for _ in 0..MAX_STOPS { let target = match request { NavRequest::Back => self.history.back(), NavRequest::Forward => self.history.forward(), diff --git a/crates/ui-egui/src/shell/app/vault/indexing.rs b/crates/ui-egui/src/shell/app/vault/indexing.rs index 305b129..f41c813 100644 --- a/crates/ui-egui/src/shell/app/vault/indexing.rs +++ b/crates/ui-egui/src/shell/app/vault/indexing.rs @@ -28,6 +28,7 @@ pub(crate) fn tree_from_listing(session: ¬escraft_engine::Session, sort: crat let files = &session.config().files; let entries = if files.show_hidden_files { vault.list_with_hidden() } else { vault.list() }.map_err(|error| error.to_string())?; let present: std::collections::HashSet<&str> = entries.iter().map(|entry| entry.path.as_str()).collect(); + crate::fonts::note_names(present.iter().copied()); let keep = |path: &str| shows_in_explorer(path, files, &present); Ok(TreeModel::from_entries(&entries, keep, sort)) } diff --git a/crates/ui-egui/src/shell/app/vault/lifecycle.rs b/crates/ui-egui/src/shell/app/vault/lifecycle.rs index a27e1be..77edf96 100644 --- a/crates/ui-egui/src/shell/app/vault/lifecycle.rs +++ b/crates/ui-egui/src/shell/app/vault/lifecycle.rs @@ -15,10 +15,7 @@ impl NotesApp { } self.source.diff = None; match self.tabs.open(path) { - OpenOutcome::Full => { - self.toast = Some(format!("{}: tab list is full", strings::open_failed())); - return; - } + OpenOutcome::Empty => return, OpenOutcome::Opened => self.cue(crate::sounds::Cue::Pop), OpenOutcome::Activated => self.cue(crate::sounds::Cue::Tick), } @@ -72,8 +69,8 @@ impl NotesApp { /// Removes a tab without sound (the closing animation already played its cue). The /// session buffer, its recovery mirror and the cached text go with it, so a reopen - /// loads fresh from disk; the editing state stays for a reopened tab to restore its - /// view mode, scroll and caret. + /// loads fresh from disk; a cold `TabMeta` stays for a reopened tab to restore its + /// view mode and caret. pub(in crate::shell::app) fn remove_tab(&mut self, path: &str) { // A note that goes away leaves its room. self.live.leave(path); @@ -82,15 +79,11 @@ impl NotesApp { self.sync_state(); self.right_valid = false; } - let _ = self.texts.remove(path); - let _ = self.docs.remove(path); - if self.session.buffer(path).is_some_and(|b| !b.is_dirty()) { - let _ = self.session.close_buffer(path, false); - } if TabKind::of(path).is_file() { crate::recovery::clear(&self.session, path); - self.views.files.remove(path); } + // The editor state goes cold (view mode and caret are kept for a reopen, a few bytes). + self.hibernate_tab(path); } /// Copies the tab list into the persisted state. @@ -116,12 +109,16 @@ impl NotesApp { } }; let Some(text) = loaded else { return }; + crate::fonts::note_text(&text); let doc = notescraft_markdown::parse(&text); self.texts.clear(); self.docs.clear(); let _ = self.texts.insert(path.clone(), text.clone()); let _ = self.docs.insert(path.clone(), doc); - let fresh = !self.editing.tabs.contains_key(&path); + let fresh = !self.editing.tabs.contains_key(&path) && !self.editing.meta.contains_key(&path); + if let Some(callouts) = self.editing.meta.get_mut(&path).map(|meta| std::mem::take(&mut meta.callouts)).filter(|c| !c.is_empty()) { + let _ = self.folds.insert(path.clone(), callouts); + } self.editing.ensure(&path, &text); if fresh && self.preferences.settings.editor.default_view == notescraft_engine::ViewDefault::Source { self.editing.set_view(&path, crate::center::ViewMode::Markdown); diff --git a/crates/ui-egui/src/shell/app/vault/mod.rs b/crates/ui-egui/src/shell/app/vault/mod.rs index 42d1674..559355a 100644 --- a/crates/ui-egui/src/shell/app/vault/mod.rs +++ b/crates/ui-egui/src/shell/app/vault/mod.rs @@ -4,6 +4,7 @@ pub(super) mod batch_review; pub(super) mod explorer; pub(super) mod file_commands; pub(super) mod grammar_dialog; +pub(super) mod hibernation; pub(super) mod history; pub(super) mod indexing; pub(super) mod large_folder; diff --git a/crates/ui-egui/src/shell/app/vault/preferences.rs b/crates/ui-egui/src/shell/app/vault/preferences.rs index ff0f552..5f6e528 100644 --- a/crates/ui-egui/src/shell/app/vault/preferences.rs +++ b/crates/ui-egui/src/shell/app/vault/preferences.rs @@ -25,7 +25,7 @@ fn read_ui(value: &Value) -> Result { || !state.volume.is_finite() || !(0.0..=1.0).contains(&state.volume) || widths.iter().any(|width| !width.is_finite() || *width <= 0.0 || *width > MAX_PANEL_WIDTH) - || state.tabs.len() > crate::tabs::MAX_TABS + || state.tabs.len() > crate::tabs::MAX_PERSISTED_TABS { return Err(INVALID_UI.to_owned()); } diff --git a/crates/ui-egui/src/shell/app/vault/workspaces/mod.rs b/crates/ui-egui/src/shell/app/vault/workspaces/mod.rs index 26ef4ab..39715a1 100644 --- a/crates/ui-egui/src/shell/app/vault/workspaces/mod.rs +++ b/crates/ui-egui/src/shell/app/vault/workspaces/mod.rs @@ -38,15 +38,18 @@ impl NotesApp { layout.get("tabs").and_then(Value::as_array).map(|tabs| tabs.iter().filter_map(Value::as_str).map(str::to_owned).collect()).unwrap_or_default(); let root = self.session.vault().root().to_path_buf(); let exists = |path: &String| !TabKind::of(path).is_file() || root.join(path).exists(); - let wanted: Vec = wanted.into_iter().filter(exists).take(crate::tabs::MAX_TABS).collect(); + let wanted: Vec = wanted.into_iter().filter(exists).take(crate::tabs::MAX_PERSISTED_TABS).collect(); let open_now: Vec = self.tabs.open.clone(); for path in open_now.iter().filter(|path| !wanted.contains(path)) { self.close_note(path); } + // The tabs are listed cold; only the one that ends up active is read from disk. for path in &wanted { - self.open_note(path); + let _ = self.tabs.open(path); } - if let Some(active) = layout.get("active").and_then(Value::as_str).filter(|active| wanted.iter().any(|path| path == active)) { + let active = + layout.get("active").and_then(Value::as_str).filter(|active| wanted.iter().any(|path| path == active)).or(wanted.last().map(String::as_str)); + if let Some(active) = active { self.open_note(active); } for path in layout.get("pinned").and_then(Value::as_array).into_iter().flatten().filter_map(Value::as_str) { diff --git a/crates/ui-egui/src/shell/app/window/cards.rs b/crates/ui-egui/src/shell/app/window/cards.rs index 8a9ab20..3cfe2d1 100644 --- a/crates/ui-egui/src/shell/app/window/cards.rs +++ b/crates/ui-egui/src/shell/app/window/cards.rs @@ -99,6 +99,7 @@ impl NotesApp { let mut right_expanded = state.right_visible; crate::layout::shell_at_rects(ctx, tokens, frame.rects, |panels| { panels.sidebar = Box::new(|ui| { + crate::hotkeys::region(ui, crate::vim_nav::NavFocus::Sidebar, crate::text::hotkeys::region_sidebar()); let mut inputs = crate::sidebar::SidebarInputs { data: sidebar_data_ref, tree, @@ -125,13 +126,14 @@ impl NotesApp { } } let mut response = draw_sidebar(ui, tokens, &mut inputs, &frame); - if vim_enabled { + if vim_enabled || vim_nav.hotkeys.ring { vim_nav.paint_focus(ui, tokens, ui.max_rect(), crate::vim_nav::NavFocus::Sidebar); } *tree_response.borrow_mut() = response.tree.take(); *sidebar_response.borrow_mut() = response; }); panels.editor = Box::new(|ui| { + crate::hotkeys::region(ui, crate::vim_nav::NavFocus::Editor, crate::text::hotkeys::region_editor()); if compact && compact_page == crate::shell::compact::CompactPage::Note && crate::shell::compact::page::top_bar(ui, tokens, &compact_title).back { compact_back.set(true); @@ -206,18 +208,19 @@ impl NotesApp { }; *split_response.borrow_mut() = Some(split.draw(&mut pane, views, &mut env)); } - if vim_enabled { + if vim_enabled || vim_nav.hotkeys.ring { vim_nav.paint_focus(ui, tokens, ui.max_rect(), crate::vim_nav::NavFocus::Editor); } }); panels.right = Box::new(|ui| { + crate::hotkeys::region(ui, crate::vim_nav::NavFocus::Right, crate::text::hotkeys::region_right()); let mut response = draw_right(ui, tokens, right_tab, right, &mut right_expanded, &frame, caret); 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; } *right_response.borrow_mut() = response; - if vim_enabled { + if vim_enabled || vim_nav.hotkeys.ring { vim_nav.paint_focus(ui, tokens, ui.max_rect(), crate::vim_nav::NavFocus::Right); } }); @@ -284,6 +287,7 @@ fn draw_right( active_entry: caret.and_then(|offset| crate::outline::active_at(&data.outline, offset)), related: &data.related, lint: &data.lint, + comments: &data.comments, }; let response = crate::right_view::draw(ui, tokens, tab, &inputs, *expanded); if response.collapse { diff --git a/crates/ui-egui/src/shell/app/window/commands.rs b/crates/ui-egui/src/shell/app/window/commands.rs index 374ff25..f4fbca2 100644 --- a/crates/ui-egui/src/shell/app/window/commands.rs +++ b/crates/ui-egui/src/shell/app/window/commands.rs @@ -75,6 +75,7 @@ impl NotesApp { if let Err(error) = self.ui_registry.register(share) { self.toast = Some(error.to_string()); } + self.register_hotkey_commands(); } pub(crate) fn start_pdf_export(&mut self, params: Value) { @@ -164,7 +165,7 @@ impl NotesApp { /// Dispatches a registered command and applies its result to the live shell. /// Errors become the app toast; settings use the engine's validated atomic store. pub fn run_command(&mut self, id: &str, params: &Value) { - if self.run_vault_command(id) { + if self.run_vault_command(id) || self.run_hotkey_command(id, params) { return; } match id { diff --git a/crates/ui-egui/src/shell/app/window/comments/mod.rs b/crates/ui-egui/src/shell/app/window/comments/mod.rs new file mode 100644 index 0000000..aaedbca --- /dev/null +++ b/crates/ui-egui/src/shell/app/window/comments/mod.rs @@ -0,0 +1,104 @@ +//! The Comments tab: the threads of the active note, read with `comment.list` +//! and changed with the `comment.*` commands. +//! +//! The panel itself is a pure widget: the shell reads the threads here and +//! hands them over, and every command runs after the frame, never while the +//! panel paints. + +use serde_json::{Value, json}; + +use crate::comment_panel::{CommentData, CommentResponse, Selection, parse_list}; +use crate::right_view::RightTab; +use crate::shell::app::NotesApp; + +/// The command that lists the threads of a note. +const LIST: &str = "comment.list"; + +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) { + return; + } + let path = self.tabs.active_path().filter(|path| crate::tabs::TabKind::of(path) == crate::tabs::TabKind::Note).map(str::to_owned); + let Some(path) = path else { + self.right.comments = CommentData { loaded: true, read_only: self.session.is_read_only(), ..CommentData::default() }; + return; + }; + if self.right.comments.path.as_deref() != Some(path.as_str()) || !self.right.comments.loaded { + self.read_comments(&path); + } + self.right.comments.read_only = self.session.is_read_only(); + self.right.comments.selection = self.selected_quote(ctx); + } + + /// Reads the threads of a note with `comment.list`. + fn read_comments(&mut self, path: &str) { + let threads = self.engine_command(LIST, &json!({ "path": path })).map_or_else(Vec::new, |result| parse_list(&result)); + self.right.comments = CommentData { + path: Some(path.to_owned()), + threads, + loaded: true, + read_only: self.session.is_read_only(), + selection: self.right.comments.selection.clone(), + }; + } + + /// Carries out what the user pressed in the Comments tab and reads the + /// threads again. + pub(super) fn handle_comments(&mut self, response: &CommentResponse) { + let Some(path) = self.right.comments.path.clone() else { return }; + if let Some((quote, body)) = &response.add { + let mut params = json!({ "path": path, "quote": quote, "body": body }); + if let (Some(range), Some(fields)) = (self.right.comments.selection.as_ref().and_then(|selection| selection.range.clone()), params.as_object_mut()) + { + fields.insert("start".to_owned(), json!(range.start)); + fields.insert("end".to_owned(), json!(range.end)); + } + self.run_comment("comment.add", ¶ms); + } + if let Some((id, body)) = &response.reply { + self.run_comment("comment.reply", &json!({ "path": path, "id": id, "body": body })); + } + if let Some((id, resolved)) = &response.resolve { + self.run_comment("comment.resolve", &json!({ "path": path, "id": id, "resolved": resolved })); + } + if let Some(id) = &response.reanchor { + let Some(selection) = self.right.comments.selection.clone() else { return }; + let Some(range) = selection.range else { return }; + self.run_comment("comment.reanchor", &json!({ "path": path, "id": id, "quote": selection.text, "start": range.start, "end": range.end })); + } + } + + /// Runs a `comment.*` command; a command that worked is followed by a new + /// read of the threads. + fn run_comment(&mut self, id: &str, params: &Value) { + if self.engine_command(id, params).is_some() + && let Some(path) = self.right.comments.path.clone() + { + self.read_comments(&path); + } + } + + /// The text the editor has selected: the anchor of a new comment. + /// + /// Only the Source editor has a selection the shell can read; the Visual + /// view keeps its own, out of reach of this crate. + fn selected_quote(&self, ctx: &egui::Context) -> Option { + let path = self.tabs.active_path()?; + let tab = self.editing.tabs.get(path)?; + if tab.view != crate::center::ViewMode::Markdown { + return None; + } + let id = tab.widget_id?; + let text = tab.editor.text.as_str(); + let (first, second) = notescraft_editor::editor::byte_cursor_range(ctx, id, text); + let (start, end) = (first.min(second), first.max(second)); + let quote = text.get(start..end)?; + if quote.trim().is_empty() { + return None; + } + Some(Selection { text: quote.to_owned(), range: Some(start..end) }) + } +} diff --git a/crates/ui-egui/src/shell/app/window/confirm.rs b/crates/ui-egui/src/shell/app/window/confirm.rs new file mode 100644 index 0000000..1cff898 --- /dev/null +++ b/crates/ui-egui/src/shell/app/window/confirm.rs @@ -0,0 +1,67 @@ +//! The "are you sure?" prompt of the destructive actions that need no name. +//! +//! The prompt is the app's one dialog (see [`crate::dialog`]) with the shared question of +//! [`crate::widgets::confirm`]; the action runs only when the user confirms it. + +use notescraft_engine::Bookmark; +use serde_json::json; + +use crate::shell::app::NotesApp; + +/// A destructive action waiting for the user's answer. +pub(in crate::shell::app) enum Confirm { + /// Remove a bookmark. + Bookmark(Bookmark), + /// Remove a frontmatter property from a note. + Property { + /// The note the property belongs to. + path: String, + /// The property key. + key: String, + }, +} + +impl Confirm { + /// What the dialog names as the thing that is about to go. + fn subject(&self) -> String { + match self { + Confirm::Bookmark(bookmark) => crate::panels::bookmarks_view::title(bookmark), + Confirm::Property { key, .. } => key.clone(), + } + } +} + +impl NotesApp { + /// Asks before a bookmark is removed; the command runs when the user confirms. + pub(crate) fn confirm_bookmark_remove(&mut self, bookmark: Bookmark) { + self.menus.confirm = Some(Confirm::Bookmark(bookmark)); + } + + /// Draws the shared question and runs the action when the user confirms it. + pub(in crate::shell::app) fn draw_confirm(&mut self, ctx: &egui::Context) { + let Some(confirm) = &self.menus.confirm else { return }; + let subject = confirm.subject(); + let answer = crate::widgets::confirm::ask(ctx, &self.tokens, "confirm-prompt", Some(&subject)); + if answer == Some(crate::dialog::Answer::Cancel) { + self.menus.confirm = None; + } else if answer == Some(crate::dialog::Answer::Confirm) + && let Some(confirm) = self.menus.confirm.take() + { + self.run_confirm(confirm, ctx); + } + } + + /// Runs the destructive action the user confirmed. + fn run_confirm(&mut self, confirm: Confirm, ctx: &egui::Context) { + match confirm { + Confirm::Bookmark(bookmark) => { + let params = bookmark.heading.map_or_else(|| json!({"path":bookmark.path}), |heading| json!({"path":bookmark.path, "heading":heading})); + self.run_command("bookmarks.remove", ¶ms); + } + Confirm::Property { path, key } => { + let now = ctx.input(|input| input.time); + self.remove_property(&path, &key, now, ctx); + } + } + } +} diff --git a/crates/ui-egui/src/shell/app/window/frame.rs b/crates/ui-egui/src/shell/app/window/frame.rs index 5f1f3c9..e4a98eb 100644 --- a/crates/ui-egui/src/shell/app/window/frame.rs +++ b/crates/ui-egui/src/shell/app/window/frame.rs @@ -191,6 +191,8 @@ impl NotesApp { let _ = self.step_indexing(ctx); self.step_explorer(ctx); self.ensure_active_loaded(); + self.hibernate_step(ctx); + crate::fonts::apply_pending(ctx); self.prepare_note_tools(); self.tick_blocks(ctx); self.control_pump(ctx); @@ -198,6 +200,8 @@ impl NotesApp { self.tick_assistant(ctx); self.tick_reminders(ctx); self.tick_feeds(ctx); + crate::menu_keys::set_vim(ctx, self.vim_enabled()); + self.run_hotkeys(ctx); self.vim_editing(ctx); self.vim_navigation(ctx); self.shortcuts(ctx); @@ -209,15 +213,18 @@ impl NotesApp { self.sidebar_stamp = stamp; } if !self.right_valid { - let (related, lint) = (std::mem::take(&mut self.right.related), std::mem::take(&mut self.right.lint)); + 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)); 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.mark_related_stale(ctx); self.right_valid = true; } self.tick_related(ctx); self.tick_lint(ctx); + self.tick_comments(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); @@ -313,12 +320,20 @@ impl NotesApp { self.batch_review_ui(ctx); } + /// The lightbox over the note, and the tab it was asked to open. + fn show_lightbox(&mut self, ctx: &egui::Context) { + let _ = self.views.images.show_lightbox(ctx, &self.tokens); + if let Some(path) = crate::lightbox::take_open(ctx) { + self.open_note(&path); + } + } + /// The overlays after the cards: the command palette (and its chosen command), the /// unsaved-changes prompt, and the queued UI sounds. fn overlays(&mut self, ctx: &egui::Context, rows: &[CommandInfo], now: f64) { self.poll_pdf_export(); self.lint_popup(ctx, now); - let _ = self.views.images.show_lightbox(ctx, &self.tokens); + self.show_lightbox(ctx); let picked = crate::palette::show_palette(ctx, &self.tokens, &mut self.palette, rows); if let Some(id) = picked { if self.palette.kind == PaletteKind::Workspaces { @@ -415,6 +430,7 @@ impl NotesApp { }); } self.draw_file_prompt(ctx); + self.draw_confirm(ctx); self.draw_popouts(ctx); self.advance_menu_closes(); self.flush_cues(now); @@ -472,6 +488,10 @@ impl NotesApp { if let Some(action) = response.action { self.handle_menu(action, ctx); } + // The row's trash button asks for the same delete prompt as the context menu. + if let Some(action) = response.delete { + self.handle_menu(action, ctx); + } if response.toggled.is_some() { self.cue(crate::sounds::Cue::Tick); } @@ -719,6 +739,7 @@ impl NotesApp { /// jump to a heading from the outline (Source mode only). fn handle_right(&mut self, response: RightResponse, now: f64, ctx: &egui::Context) { self.handle_assistant(response.assistant); + self.handle_comments(&response.comments); if let Some(path) = response.open_note { self.open_note(&path); } diff --git a/crates/ui-egui/src/shell/app/window/menus.rs b/crates/ui-egui/src/shell/app/window/menus.rs index 0b6b9c7..8fd0934 100644 --- a/crates/ui-egui/src/shell/app/window/menus.rs +++ b/crates/ui-egui/src/shell/app/window/menus.rs @@ -25,6 +25,8 @@ const EXTRACT_NAME_CHARS: usize = 40; pub(in crate::shell::app) struct Menus { /// Name or delete confirmation currently shown. pub(in crate::shell::app) prompt: Option<(MenuAction, String)>, + /// A destructive action waiting for the user's answer. + pub(in crate::shell::app) confirm: Option, /// Remaining paths to close, stopping at each unsaved prompt. pub(in crate::shell::app) closes: VecDeque, /// Deletion to run after every affected buffer was resolved. diff --git a/crates/ui-egui/src/shell/app/window/mod.rs b/crates/ui-egui/src/shell/app/window/mod.rs index 92b5ede..4139763 100644 --- a/crates/ui-egui/src/shell/app/window/mod.rs +++ b/crates/ui-egui/src/shell/app/window/mod.rs @@ -4,6 +4,8 @@ pub(super) mod assistant; pub(super) mod cards; pub(super) mod chrome; pub(super) mod commands; +pub(super) mod comments; +pub(super) mod confirm; pub(super) mod frame; pub(super) mod lint; #[cfg(test)] diff --git a/crates/ui-egui/src/shell/hotkeys/commands.rs b/crates/ui-egui/src/shell/hotkeys/commands.rs new file mode 100644 index 0000000..f95654f --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/commands.rs @@ -0,0 +1,214 @@ +//! The hotkey commands as `NotesApp` methods: tab stepping, focus moves and the right panel tab openers. +//! `run_hotkey_command` is called first by `run_command`; the focus effects that need the egui context +//! wait in [`VimNav::hotkey_focus`] until [`NotesApp::run_hotkeys`] applies them. + +use notescraft_engine::CommandSpec; +use serde_json::{Value, json}; + +use super::{focus, registry, tabs}; +use crate::center::ViewMode; +use crate::right_view::RightTab; +use crate::shell::app::NotesApp; +use crate::state::UiState; +use crate::text::hotkeys as t; +use crate::vim_nav::NavFocus; + +/// The palette menu the hotkey commands list under. +const MENU: &str = "View"; + +/// Opens the right panel on `tab`: `(command id, tab)`. +const SHOW: [(&str, RightTab); 6] = [ + ("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), +]; + +/// The palette label of a hotkey command. +fn label(id: &str) -> Option<&'static str> { + Some(match id { + "ui.next_tab" => t::next_tab(), + "ui.prev_tab" => t::prev_tab(), + "ui.goto_tab" => t::goto_tab(), + "ui.reopen_closed_tab" => t::reopen_tab(), + "ui.focus_sidebar" => t::focus_sidebar(), + "ui.focus_editor" => t::focus_editor(), + "ui.focus_right_panel" => t::focus_right(), + "ui.cycle_focus" => t::cycle_focus(), + "ui.show_outline" => t::show_outline(), + "ui.show_related" => t::show_related(), + "ui.show_backlinks" => t::show_backlinks(), + "ui.show_assistant" => t::show_assistant(), + "ui.show_lint" => t::show_lint(), + "ui.show_comments" => t::show_comments(), + _ => return None, + }) +} + +/// Every command this module runs. +const IDS: [&str; 14] = [ + "ui.next_tab", + "ui.prev_tab", + "ui.goto_tab", + "ui.reopen_closed_tab", + "ui.focus_sidebar", + "ui.focus_editor", + "ui.focus_right_panel", + "ui.cycle_focus", + "ui.show_outline", + "ui.show_related", + "ui.show_backlinks", + "ui.show_assistant", + "ui.show_lint", + "ui.show_comments", +]; + +#[expect(clippy::unnecessary_wraps, reason = "the registry signature demands CommandResult")] +fn noop(_state: &mut UiState, _params: &Value) -> notescraft_engine::CommandResult { + Ok(Value::Null) +} + +/// The `n` parameter of a hotkey command. +fn arg(params: &Value) -> Option { + params.get("n").and_then(Value::as_i64).and_then(|n| i32::try_from(n).ok()) +} + +impl NotesApp { + /// Lists the hotkey commands in the command palette, with the chord now in force. + pub(crate) fn register_hotkey_commands(&mut self) { + let registry = registry_now(); + for id in IDS { + let Some(text) = label(id) else { continue }; + let mut spec = CommandSpec::new(id, text, t::doc(), |_: &UiState| true, noop); + spec.menu_path = vec![String::from(MENU)]; + spec.shortcut = registry.binding(id).map(|binding| binding.palette_syntax()); + if let Err(error) = self.ui_registry.register(spec) { + self.toast = Some(error.to_string()); + } + } + } + + /// Runs `id` when it is one of the hotkey commands; `false` for any other command. + pub(crate) fn run_hotkey_command(&mut self, id: &str, params: &Value) -> bool { + if let Some((_, tab)) = SHOW.iter().find(|(known, _)| *known == id) { + self.state.right_visible = true; + self.right_tab = *tab; + self.right_valid = false; + self.move_focus(NavFocus::Right); + return true; + } + match id { + "ui.next_tab" => self.step_tab(1), + "ui.prev_tab" => self.step_tab(-1), + "ui.goto_tab" => self.goto_tab(arg(params)), + "ui.reopen_closed_tab" => self.reopen_closed_tab(), + "ui.focus_sidebar" => { + self.state.sidebar_visible = true; + self.move_focus(NavFocus::Sidebar); + } + "ui.focus_editor" => self.move_focus(NavFocus::Editor), + "ui.focus_right_panel" => { + self.state.right_visible = true; + self.move_focus(NavFocus::Right); + } + "ui.cycle_focus" => { + let delta = if arg(params).is_some_and(|n| n < 0) { -1 } else { 1 }; + self.move_focus(focus::cycle(self.vim_nav.focus, self.state.sidebar_visible, self.state.right_visible, delta)); + } + _ => return false, + } + true + } + + /// Records that focus should move to `card`; the context part happens in [`Self::run_hotkeys`]. + fn move_focus(&mut self, card: NavFocus) { + self.vim_nav.hotkeys.focus = Some(card); + } + + fn step_tab(&mut self, delta: isize) { + let target = tabs::step(self.tabs.active, self.tabs.open.len(), delta).filter(|index| *index != self.tabs.active); + self.activate_tab(target); + } + + fn goto_tab(&mut self, n: Option) { + let target = n.and_then(|n| tabs::goto(n, self.tabs.open.len())).filter(|index| *index != self.tabs.active); + self.activate_tab(target); + } + + fn activate_tab(&mut self, index: Option) { + if let Some(path) = index.and_then(|index| self.tabs.open.get(index)).cloned() { + self.open_note(&path); + } + } + + fn reopen_closed_tab(&mut self) { + while let Some(path) = self.vim_nav.hotkeys.closed.pop(&self.tabs.open) { + if crate::tabs::TabKind::of(&path).is_file() && !self.session.vault().resolve(&path).is_ok_and(|real| real.exists()) { + continue; + } + self.open_note(&path); + return; + } + } + + /// Per frame, before the other shortcuts: tracks closed tabs, consumes the registry's chords, runs + /// the commands they ask for and applies a pending focus move. + pub(crate) fn run_hotkeys(&mut self, ctx: &egui::Context) { + let vim = self.vim_enabled(); + focus::sync(ctx, &mut self.vim_nav, vim); + let vault = self.session.vault().root().to_path_buf(); + self.vim_nav.hotkeys.closed.observe(&self.tabs.open, &vault); + if !(self.palette.open || self.quit_prompt.is_some()) { + for fired in super::dispatch(ctx, ®istry_now()) { + let params = fired.arg.map_or(Value::Null, |n| json!({ "n": n })); + self.run_command(fired.id, ¶ms); + } + } + self.apply_hotkey_focus(ctx, vim); + } + + /// Moves keyboard focus where a command asked: into the editor, the Explorer filter, the Search field + /// or a panel's landmark. Vim mode only moves its own card focus: a focused widget would stop its keys. + fn apply_hotkey_focus(&mut self, ctx: &egui::Context, vim: bool) { + let Some(card) = self.vim_nav.hotkeys.focus.take() else { return }; + self.vim_nav.focus = card; + self.vim_nav.hotkeys.ring = true; + ctx.request_repaint(); + if vim { + if let Some(focused) = ctx.memory(egui::Memory::focused) { + ctx.memory_mut(|memory| memory.surrender_focus(focused)); + } + if card == NavFocus::Editor { + self.request_vim_editor_focus(ctx); + } + return; + } + match card { + NavFocus::Editor => self.focus_editor_widget(ctx), + NavFocus::Sidebar if self.state.sidebar_tab == crate::sidebar_tabs::SidebarTab::Explorer => { + ctx.data_mut(|data| data.insert_temp(crate::app::explorer_focus_filter_id(), true)); + } + NavFocus::Sidebar if self.state.sidebar_tab == crate::sidebar_tabs::SidebarTab::Search => self.search.focus = true, + NavFocus::Sidebar | NavFocus::Right => focus::request(ctx, card), + } + } + + /// The Source view hands focus to its text widget; every other view takes it on the editor landmark. + fn focus_editor_widget(&mut self, ctx: &egui::Context) { + let source = self.tabs.active_path().map(str::to_owned).filter(|path| self.editing.view_of(Some(path)) == ViewMode::Markdown); + match source.and_then(|path| self.editing.editor_of(&path).map(|edit| edit.widget_id)) { + Some(widget) => { + if let Some(id) = widget { + ctx.memory_mut(|memory| memory.request_focus(id)); + } + } + None => focus::request(ctx, NavFocus::Editor), + } + } +} + +fn registry_now() -> std::sync::Arc { + super::registry() +} diff --git a/crates/ui-egui/src/shell/hotkeys/defaults.rs b/crates/ui-egui/src/shell/hotkeys/defaults.rs new file mode 100644 index 0000000..ea0e0b5 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/defaults.rs @@ -0,0 +1,85 @@ +//! The shipped bindings: one table, read by dispatch, tooltips, the palette and the conflict test. +//! macOS notation in the comments; Ctrl replaces Cmd elsewhere. + +use egui::Key; + +use super::registry::{Binding, Hotkey, Owner, Scope, VimMode}; + +const fn dispatch(id: &'static str, binding: Binding, scope: Scope) -> Hotkey { + Hotkey { id, arg: None, default_binding: binding, scope, owner: Owner::Dispatch, vim: VimMode::Any } +} + +const fn with_arg(id: &'static str, arg: i32, binding: Binding) -> Hotkey { + Hotkey { id, arg: Some(arg), default_binding: binding, scope: Scope::Global, owner: Owner::Dispatch, vim: VimMode::Any } +} + +const fn held_by(id: &'static str, binding: Binding, scope: Scope, owner: Owner) -> Hotkey { + Hotkey { id, arg: None, default_binding: binding, scope, owner, vim: VimMode::Any } +} + +/// Every hotkey the shell knows, new ones first, then the ones older code or the native menu still owns. +pub const DEFAULTS: &[Hotkey] = &[ + // Tabs. + dispatch("ui.next_tab", Binding::key(Key::Tab).ctrl(), Scope::Global), // ⌃⇥ + dispatch("ui.next_tab", Binding::key(Key::ArrowRight).cmd().alt(), Scope::Global), // ⌘⌥→ + dispatch("ui.prev_tab", Binding::key(Key::Tab).ctrl().shift(), Scope::Global), // ⌃⇧⇥ + dispatch("ui.prev_tab", Binding::key(Key::ArrowLeft).cmd().alt(), Scope::Global), // ⌘⌥← + with_arg("ui.goto_tab", 1, Binding::key(Key::Num1).cmd()), + with_arg("ui.goto_tab", 2, Binding::key(Key::Num2).cmd()), + with_arg("ui.goto_tab", 3, Binding::key(Key::Num3).cmd()), + with_arg("ui.goto_tab", 4, Binding::key(Key::Num4).cmd()), + with_arg("ui.goto_tab", 5, Binding::key(Key::Num5).cmd()), + with_arg("ui.goto_tab", 6, Binding::key(Key::Num6).cmd()), + with_arg("ui.goto_tab", 7, Binding::key(Key::Num7).cmd()), + with_arg("ui.goto_tab", 8, Binding::key(Key::Num8).cmd()), + // Tab 9 is the last tab, whatever its number. + with_arg("ui.goto_tab", 9, Binding::key(Key::Num9).cmd()), + dispatch("ui.reopen_closed_tab", Binding::key(Key::T).cmd().shift(), Scope::Global), // ⌘⇧T + // Focus. + dispatch("ui.focus_sidebar", Binding::key(Key::E).cmd().alt(), Scope::Global), // ⌘⌥E + dispatch("ui.focus_editor", Binding::key(Key::Enter).cmd().shift(), Scope::Global), // ⌘⇧↩ + Hotkey { + id: "ui.focus_editor", + arg: None, + default_binding: Binding::key(Key::Escape), + scope: Scope::Panel, + owner: Owner::Dispatch, + // Vim keeps Esc for its own pending keys. + vim: VimMode::Off, + }, + dispatch("ui.focus_right_panel", Binding::key(Key::R).cmd().shift(), Scope::Global), // ⌘⇧R + with_arg("ui.cycle_focus", 1, Binding::key(Key::F6)), + with_arg("ui.cycle_focus", -1, Binding::key(Key::F6).shift()), + // Right panel tabs. + dispatch("ui.show_outline", Binding::key(Key::O).cmd().alt(), Scope::Global), // ⌘⌥O + dispatch("ui.show_related", Binding::key(Key::R).cmd().alt(), Scope::Global), // ⌘⌥R + dispatch("ui.show_backlinks", Binding::key(Key::K).cmd().alt(), Scope::Global), // ⌘⌥K + 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.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) + dispatch("ui.toggle_sidebar", Binding::key(Key::Backslash).cmd(), Scope::Global), // ⌘\ + held_by("ui.command_palette", Binding::key(Key::K).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.quick_switcher", Binding::key(Key::O).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.save", Binding::key(Key::S).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.toggle_view", Binding::key(Key::E).cmd(), Scope::Editor, Owner::Legacy), + held_by("ui.export_pdf", Binding::key(Key::E).cmd().shift(), Scope::Global, Owner::Legacy), + held_by("ui.open_graph", Binding::key(Key::G).cmd().alt(), Scope::Global, Owner::Legacy), + held_by("editor.block_menu", Binding::key(Key::B).cmd().alt(), Scope::Editor, Owner::Legacy), + held_by("nav.back", Binding::key(Key::OpenBracket).cmd(), Scope::Global, Owner::Legacy), + held_by("nav.forward", Binding::key(Key::CloseBracket).cmd(), Scope::Global, Owner::Legacy), + held_by("find.open", Binding::key(Key::F).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.zoom_in", Binding::key(Key::Equals).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.zoom_out", Binding::key(Key::Minus).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.zoom_reset", Binding::key(Key::Num0).cmd(), Scope::Global, Owner::Legacy), + held_by("ui.toggle_assistant", Binding::key(Key::A).cmd().shift(), Scope::Global, Owner::Legacy), + // The native menu bar (`menubar.rs`). + held_by("ui.open_settings", Binding::key(Key::Comma).cmd(), Scope::Global, Owner::Menu), + held_by("ui.quit", Binding::key(Key::Q).cmd(), Scope::Global, Owner::Menu), + held_by("ui.new_note", Binding::key(Key::N).cmd(), Scope::Global, Owner::Menu), + held_by("ui.close_tab", Binding::key(Key::W).cmd(), Scope::Global, Owner::Menu), + held_by("ui.sidebar.search", Binding::key(Key::F).cmd().shift(), Scope::Global, Owner::Menu), + held_by("ui.open_calendar", Binding::key(Key::C).cmd().alt(), Scope::Global, Owner::Menu), +]; diff --git a/crates/ui-egui/src/shell/hotkeys/dispatch.rs b/crates/ui-egui/src/shell/hotkeys/dispatch.rs new file mode 100644 index 0000000..e1b0789 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/dispatch.rs @@ -0,0 +1,62 @@ +//! Consumes this frame's key events against the registry, once, and names the commands to run. + +use egui::{Context, Event}; + +use super::focus::{self, Snapshot}; +use super::registry::{Binding, Entry, Owner, Registry, Scope, VimMode}; +use crate::vim_nav::NavFocus; + +/// A command a key chord asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Fired { + /// The command id, run through [`crate::app::NotesApp::run_command`]. + pub id: &'static str, + /// Its `n` parameter, when the hotkey carries one. + pub arg: Option, +} + +/// Removes the chord's key events from this frame's input; `true` when it was pressed. +/// Modifiers must match exactly, so Cmd+Shift+1 is not Cmd+1. +fn take(ctx: &Context, binding: Binding) -> bool { + let pattern = binding.modifiers(); + ctx.input_mut(|input| { + let before = input.events.len(); + input + .events + .retain(|event| !matches!(event, Event::Key { key, pressed: true, modifiers, .. } if *key == binding.key && modifiers.matches_exact(pattern))); + input.events.len() != before + }) +} + +/// Whether `scope` is active for the keyboard as `snapshot` describes it. +fn in_scope(scope: Scope, snapshot: Snapshot) -> bool { + match scope { + Scope::Global => true, + Scope::Editor => snapshot.card == NavFocus::Editor, + Scope::Sidebar => snapshot.card == NavFocus::Sidebar && snapshot.on_region, + Scope::Panel => snapshot.card != NavFocus::Editor && snapshot.on_region, + } +} + +fn applies(entry: &Entry, snapshot: Snapshot) -> bool { + entry.hotkey.owner == Owner::Dispatch && in_scope(entry.hotkey.scope, snapshot) && !(snapshot.vim && entry.hotkey.vim == VimMode::Off) +} + +/// Consumes every [`Owner::Dispatch`] chord pressed this frame and returns the commands they ask for. +/// Cmd and Ctrl chords work while a text field has focus (the field never sees them); a bare key such as +/// a letter or Esc only fires when no text field wants the keyboard and no popup is open. +pub fn dispatch(ctx: &Context, registry: &Registry) -> Vec { + let snapshot = focus::snapshot(ctx); + // A panel landmark holding focus leaves the keyboard free; any other focused widget keeps it. + let keyboard_free = (ctx.memory(egui::Memory::focused).is_none() || snapshot.on_region) && !ctx.any_popup_open(); + let mut fired = Vec::new(); + for entry in registry.entries().iter().filter(|entry| applies(entry, snapshot)) { + if entry.binding.is_bare() && !keyboard_free { + continue; + } + if take(ctx, entry.binding) { + fired.push(Fired { id: entry.hotkey.id, arg: entry.hotkey.arg }); + } + } + fired +} diff --git a/crates/ui-egui/src/shell/hotkeys/focus.rs b/crates/ui-egui/src/shell/hotkeys/focus.rs new file mode 100644 index 0000000..e6c61a7 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/focus.rs @@ -0,0 +1,104 @@ +//! Panel focus: one labelled, focusable region per card (sidebar, editor, right panel), the pending +//! focus request that survives a panel that is still opening, and the snapshot dispatch reads. + +use egui::{Context, EventFilter, Id, Rect, Sense, Ui, accesskit}; + +use crate::vim_nav::{NavFocus, VimNav}; + +const SNAPSHOT: &str = "notescraft-hotkeys-snapshot"; +const PENDING: &str = "notescraft-hotkeys-pending"; +const REGION: &str = "notescraft-hotkeys-region"; +const RECT: &str = "notescraft-hotkeys-region-rect"; + +/// What dispatch needs to know about where the keyboard is. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct Snapshot { + /// The card that holds navigation focus. + pub card: NavFocus, + /// Keyboard focus rests on a card's region, not on a field inside it. + pub on_region: bool, + /// Vim mode is on. + pub vim: bool, +} + +/// The card `delta` steps from `current` among the visible cards (sidebar, editor, right), wrapping at both ends. +#[must_use] +pub fn cycle(current: NavFocus, sidebar: bool, right: bool, delta: isize) -> NavFocus { + let cards: Vec = [(NavFocus::Sidebar, sidebar), (NavFocus::Editor, true), (NavFocus::Right, right)] + .into_iter() + .filter_map(|(card, visible)| visible.then_some(card)) + .collect(); + let at = cards.iter().position(|card| *card == current).unwrap_or(0); + super::tabs::step(at, cards.len(), delta).and_then(|next| cards.get(next)).copied().unwrap_or(current) +} + +/// The stable id of a card's focus region. +#[must_use] +pub fn region_id(card: NavFocus) -> Id { + Id::new((REGION, card)) +} + +/// The card whose region holds keyboard focus now, if any. +#[must_use] +pub fn focused_region(ctx: &Context) -> Option { + let focused = ctx.memory(egui::Memory::focused)?; + [NavFocus::Sidebar, NavFocus::Editor, NavFocus::Right].into_iter().find(|card| region_id(*card) == focused) +} + +/// Declares `ui`'s card as a labelled landmark that can take keyboard focus. Call it first in the card. +/// A pending request from [`request`] is honoured here, on the frame the card is actually drawn. +pub fn region(ui: &Ui, card: NavFocus, label: &str) { + let ctx = ui.ctx(); + let id = region_id(card); + let rect = ui.max_rect(); + let response = ui.interact(rect, id, Sense::focusable_noninteractive()); + ctx.data_mut(|data| data.insert_temp(Id::new((RECT, card)), rect)); + let role = match card { + NavFocus::Sidebar => accesskit::Role::Navigation, + NavFocus::Editor => accesskit::Role::Main, + NavFocus::Right => accesskit::Role::Complementary, + }; + ctx.accesskit_node_builder(id, |node| { + node.set_role(role); + node.set_label(label); + }); + if ctx.data(|data| data.get_temp::(Id::new(PENDING))) == Some(card) { + ctx.data_mut(|data| data.remove_temp::(Id::new(PENDING))); + response.request_focus(); + } + if response.has_focus() { + // Esc is a hotkey here ("back to the editor"), so egui must not spend it dropping focus. + ctx.memory_mut(|memory| memory.set_focus_lock_filter(id, EventFilter { escape: true, ..EventFilter::default() })); + } +} + +/// Asks for keyboard focus on `card`'s region the next time it is drawn. +pub fn request(ctx: &Context, card: NavFocus) { + ctx.data_mut(|data| data.insert_temp(Id::new(PENDING), card)); + ctx.request_repaint(); +} + +/// The snapshot dispatch reads this frame. +#[must_use] +pub fn snapshot(ctx: &Context) -> Snapshot { + ctx.data(|data| data.get_temp::(Id::new(SNAPSHOT))).unwrap_or_default() +} + +/// Refreshes the snapshot and lets a click move navigation focus to the card under the pointer +/// (outside Vim mode, where the keyboard alone moves it). A click also hides the keyboard focus ring. +pub fn sync(ctx: &Context, nav: &mut VimNav, vim: bool) { + let press = ctx.input(|input| input.pointer.primary_pressed().then(|| input.pointer.interact_pos()).flatten()); + if let Some(at) = press { + if !vim { + let hit = [NavFocus::Sidebar, NavFocus::Editor, NavFocus::Right] + .into_iter() + .find(|card| ctx.data(|data| data.get_temp::(Id::new((RECT, *card)))).is_some_and(|rect| rect.contains(at))); + if let Some(card) = hit { + nav.focus = card; + } + } + nav.hotkeys.ring = false; + } + let snapshot = Snapshot { card: nav.focus, on_region: focused_region(ctx).is_some(), vim }; + ctx.data_mut(|data| data.insert_temp(Id::new(SNAPSHOT), snapshot)); +} diff --git a/crates/ui-egui/src/shell/hotkeys/mod.rs b/crates/ui-egui/src/shell/hotkeys/mod.rs new file mode 100644 index 0000000..51d47b1 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/mod.rs @@ -0,0 +1,108 @@ +//! The central hotkey registry: every app-wide key chord in one table ([`defaults::DEFAULTS`]), one +//! per-frame [`dispatch`] that consumes them, the labels that show them in tooltips, and the panel +//! focus landmarks they move between. +//! +//! Chords owned by older code (`frame.rs`, the native menu) are listed too, so tooltips print them and the +//! conflict test sees them; only [`Owner::Dispatch`] entries are consumed here. User overrides are applied +//! with [`set_overrides`]; no settings key feeds it yet. + +mod commands; +mod defaults; +mod dispatch; +mod focus; +mod registry; +mod tabs; +#[cfg(test)] +mod tests; + +use std::collections::BTreeMap; +use std::sync::{Arc, OnceLock, RwLock}; + +pub use defaults::DEFAULTS; +pub use dispatch::{Fired, dispatch}; +pub use focus::{Snapshot, cycle as cycle_focus, focused_region, region, region_id, request as request_focus}; +pub use registry::{Binding, Conflict, Entry, Hotkey, Owner, Platform, Registry, Scope, VimMode}; +pub use tabs::{ClosedTabs, LAST_TAB_SLOT, MAX_CLOSED, goto as goto_index, step as step_index}; + +use crate::vim_nav::NavFocus; + +/// Keyboard state that lives next to the Vim card focus: a pending focus move, the focus ring and the +/// closed-tab stack. +#[derive(Debug, Default)] +pub struct State { + /// A focus move a command asked for, applied at the next [`dispatch`] pass. + pub(crate) focus: Option, + /// Whether the focused card shows its keyboard focus wash (a hotkey moved focus; a click hides it). + pub(crate) ring: bool, + /// Closed tabs, for Reopen. + pub(crate) closed: ClosedTabs, +} + +static GLOBAL: OnceLock>> = OnceLock::new(); + +fn cell() -> &'static RwLock> { + GLOBAL.get_or_init(|| RwLock::new(Arc::new(Registry::defaults()))) +} + +/// The registry in force: the defaults plus any [`set_overrides`]. +#[must_use] +pub fn registry() -> Arc { + match cell().read() { + Ok(guard) => Arc::clone(&guard), + Err(poisoned) => Arc::clone(&poisoned.into_inner()), + } +} + +/// Replaces the user overrides (command id to `Mod-Alt-O` text); returns the ids that were rejected. +/// An empty map restores the defaults. +#[must_use] +pub fn set_overrides(overrides: &BTreeMap) -> Vec { + let (next, rejected) = Registry::with_overrides(overrides); + match cell().write() { + Ok(mut guard) => *guard = Arc::new(next), + Err(poisoned) => *poisoned.into_inner() = Arc::new(next), + } + rejected +} + +/// The chord of `id` as this platform prints it, when `id` has one. +#[must_use] +pub fn format(id: &str) -> Option { + registry().format(id, Platform::current()) +} + +/// `label` followed by the chord of `id`, as plain text (`Outline ⌘⌥O`); just `label` when unbound. +#[must_use] +pub fn tooltip(label: &str, id: &str) -> String { + match format(id) { + Some(chord) => format!("{label} {chord}"), + None => label.to_owned(), + } +} + +/// Shows `label` and the chord of `id` (muted, monospace) as the hover tooltip of `response`. +#[must_use] +pub fn on_hover_hotkey(response: egui::Response, label: &str, id: &str) -> egui::Response { + let Some(chord) = format(id) else { return response.on_hover_text(label) }; + response.on_hover_ui(|ui| { + ui.horizontal(|ui| { + ui.label(label); + ui.label(egui::RichText::new(chord).monospace().weak()); + }); + }) +} + +/// Like [`on_hover_hotkey`] for a control that stands for several commands: one `label chord` row each. +#[must_use] +pub fn on_hover_hotkeys(response: egui::Response, rows: &[(&str, &str)]) -> egui::Response { + response.on_hover_ui(|ui| { + for (label, id) in rows { + ui.horizontal(|ui| { + ui.label(*label); + if let Some(chord) = format(id) { + ui.label(egui::RichText::new(chord).monospace().weak()); + } + }); + } + }) +} diff --git a/crates/ui-egui/src/shell/hotkeys/registry.rs b/crates/ui-egui/src/shell/hotkeys/registry.rs new file mode 100644 index 0000000..e2d732a --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/registry.rs @@ -0,0 +1,387 @@ +//! Bindings, scopes and the resolved registry: what a key chord is, how it prints on each platform, +//! which ones collide, and how user overrides replace the defaults. + +use std::collections::BTreeMap; + +use egui::{Key, Modifiers}; + +/// Which keyboard family a chord is printed for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Platform { + /// macOS: glyphs such as `⌘⌥O`. + Mac, + /// Windows and Linux: words such as `Ctrl+Alt+O`. + Other, +} + +impl Platform { + /// The platform this build runs on. + #[must_use] + pub const fn current() -> Self { + if cfg!(target_os = "macos") { Self::Mac } else { Self::Other } + } +} + +/// A chord: modifiers plus one key. `command` is Cmd on macOS and Ctrl elsewhere; `control` is the +/// physical Ctrl key (the same key as `command` outside macOS). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[expect(clippy::struct_excessive_bools, reason = "four independent modifier keys")] +pub struct Binding { + /// Cmd on macOS, Ctrl elsewhere. + pub command: bool, + /// The physical Ctrl key. + pub control: bool, + /// Alt or Option. + pub alt: bool, + /// Shift. + pub shift: bool, + /// The key. + pub key: Key, +} + +impl Binding { + /// A chord with no modifier yet. + #[must_use] + pub const fn key(key: Key) -> Self { + Self { command: false, control: false, alt: false, shift: false, key } + } + + /// Adds Cmd (Ctrl outside macOS). + #[must_use] + pub const fn cmd(mut self) -> Self { + self.command = true; + self + } + + /// Adds the physical Ctrl key. + #[must_use] + pub const fn ctrl(mut self) -> Self { + self.control = true; + self + } + + /// Adds Alt or Option. + #[must_use] + pub const fn alt(mut self) -> Self { + self.alt = true; + self + } + + /// Adds Shift. + #[must_use] + pub const fn shift(mut self) -> Self { + self.shift = true; + self + } + + /// The egui modifier pattern of this chord. + #[must_use] + pub fn modifiers(&self) -> Modifiers { + let mut modifiers = Modifiers { alt: self.alt, shift: self.shift, ..Modifiers::NONE }; + if self.command { + modifiers |= Modifiers::COMMAND; + } + if self.control { + modifiers |= Modifiers::CTRL; + } + modifiers + } + + /// Whether the chord is a bare key (or Shift plus a key): such a chord would type text, so it only + /// applies when no text field wants the keyboard. Function keys are never bare. + #[must_use] + pub fn is_bare(&self) -> bool { + let function_key = self.key.name().strip_prefix('F').is_some_and(|rest| rest.chars().all(|c| c.is_ascii_digit()) && !rest.is_empty()); + !self.command && !self.control && !self.alt && !function_key + } + + /// The chord with Cmd and Ctrl folded together where they are one key. + fn canon(self, platform: Platform) -> (bool, bool, bool, bool, Key) { + match platform { + Platform::Mac => (self.command, self.control, self.alt, self.shift, self.key), + Platform::Other => (false, self.command || self.control, self.alt, self.shift, self.key), + } + } + + /// The chord as printed for `platform`: `⌘⌥O` on macOS, `Ctrl+Alt+O` elsewhere. + #[must_use] + pub fn format(&self, platform: Platform) -> String { + let key = key_label(self.key, platform); + match platform { + Platform::Mac => { + let mut text = String::new(); + for (on, glyph) in [(self.control, '⌃'), (self.command, '⌘'), (self.alt, '⌥'), (self.shift, '⇧')] { + if on { + text.push(glyph); + } + } + text + &key + } + Platform::Other => { + let mut parts = Vec::new(); + for (on, word) in [(self.command || self.control, "Ctrl"), (self.alt, "Alt"), (self.shift, "Shift")] { + if on { + parts.push(word.to_owned()); + } + } + parts.push(key); + parts.join("+") + } + } + } + + /// The native menu accelerator string, such as `CmdOrCtrl+Alt+C`. + #[must_use] + pub fn accelerator(&self) -> String { + let mut parts = Vec::new(); + if self.command { + parts.push("CmdOrCtrl".to_owned()); + } + if self.control { + parts.push("Ctrl".to_owned()); + } + if self.alt { + parts.push("Alt".to_owned()); + } + if self.shift { + parts.push("Shift".to_owned()); + } + parts.push(accelerator_key(self.key)); + parts.join("+") + } + + /// The command palette's own syntax, such as `Mod-Alt-C`. + #[must_use] + pub fn palette_syntax(&self) -> String { + let mut parts = Vec::new(); + if self.command { + parts.push("Mod".to_owned()); + } + if self.control { + parts.push("Ctrl".to_owned()); + } + if self.alt { + parts.push("Alt".to_owned()); + } + if self.shift { + parts.push("Shift".to_owned()); + } + parts.push(key_label(self.key, Platform::Other)); + parts.join("-") + } + + /// Reads `Mod-Alt-O` style text: `Mod`/`Cmd` is the command key, `Ctrl` the physical Ctrl key, then + /// `Alt`/`Option`, `Shift`, and a key name egui knows as the last part. `None` for anything else. + #[must_use] + pub fn parse(text: &str) -> Option { + let mut parts: Vec<&str> = text.split('-').collect(); + let key = parts.pop()?; + let mut binding = Self::key(Key::from_name(key).or_else(|| Key::from_name(&key.to_ascii_uppercase()))?); + for part in parts { + match part.to_ascii_lowercase().as_str() { + "mod" | "cmd" => binding.command = true, + "ctrl" | "control" => binding.control = true, + "alt" | "option" => binding.alt = true, + "shift" => binding.shift = true, + _ => return None, + } + } + Some(binding) + } +} + +/// The printed name of one key. +fn key_label(key: Key, platform: Platform) -> String { + let mac = platform == Platform::Mac; + let text = match key { + Key::ArrowLeft if mac => "←", + Key::ArrowRight if mac => "→", + Key::ArrowUp if mac => "↑", + Key::ArrowDown if mac => "↓", + Key::ArrowLeft => "Left", + Key::ArrowRight => "Right", + Key::ArrowUp => "Up", + Key::ArrowDown => "Down", + Key::Tab if mac => "⇥", + Key::Enter if mac => "↩", + Key::Escape if mac => "⎋", + Key::Escape => "Esc", + Key::Backspace if mac => "⌫", + Key::Space if mac => "␣", + Key::Backslash => "\\", + Key::Comma => ",", + Key::Minus => "-", + Key::Equals => "=", + Key::OpenBracket => "[", + Key::CloseBracket => "]", + other => { + let name = other.name(); + return name.strip_prefix("Num").filter(|digits| digits.len() == 1).unwrap_or(name).to_owned(); + } + }; + text.to_owned() +} + +/// The key part of a native accelerator string. +fn accelerator_key(key: Key) -> String { + match key { + Key::ArrowLeft => "Left".to_owned(), + Key::ArrowRight => "Right".to_owned(), + Key::ArrowUp => "Up".to_owned(), + Key::ArrowDown => "Down".to_owned(), + Key::Escape => "Escape".to_owned(), + Key::Enter => "Enter".to_owned(), + other => key_label(other, Platform::Other), + } +} + +/// Where a binding applies. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scope { + /// Everywhere. + Global, + /// While the editor card holds the keyboard. + Editor, + /// While the sidebar holds the keyboard. + Sidebar, + /// While any side panel holds the keyboard. + Panel, +} + +impl Scope { + /// Whether two scopes can be active at once, so equal chords in them would collide. + #[must_use] + pub fn overlaps(self, other: Self) -> bool { + match (self, other) { + (Self::Global, _) | (_, Self::Global) | (Self::Panel, Self::Sidebar) | (Self::Sidebar, Self::Panel) => true, + (a, b) => a == b, + } + } +} + +/// Who handles the chord. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Owner { + /// [`super::dispatch`] consumes it and the shell runs the command. + Dispatch, + /// The native menu bar owns it as an accelerator; listed for tooltips and conflicts. + Menu, + /// Older code (`frame.rs`, the editor, panel resize) handles it; listed for tooltips and conflicts. + Legacy, +} + +/// Whether a binding follows the app's Vim mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VimMode { + /// Always active. + Any, + /// Only while Vim mode is off: Vim has its own use of the key. + Off, +} + +/// One default binding. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Hotkey { + /// The command it runs, such as `ui.show_outline`. + pub id: &'static str, + /// The command's `n` parameter, when it takes one (tab number, focus direction). + pub arg: Option, + /// The chord before any user override. + pub default_binding: Binding, + /// Where it applies. + pub scope: Scope, + /// Who handles it. + pub owner: Owner, + /// Vim mode rule. + pub vim: VimMode, +} + +/// A hotkey with the chord now in force. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Entry { + /// The default row. + pub hotkey: Hotkey, + /// The chord now in force (the default unless overridden). + pub binding: Binding, +} + +/// Two entries on one chord in scopes that can be active together. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Conflict { + /// The first command id. + pub first: &'static str, + /// The second command id. + pub second: &'static str, + /// The shared chord. + pub binding: Binding, +} + +/// Every hotkey with its binding in force. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Registry { + entries: Vec, +} + +impl Registry { + /// The registry of the shipped defaults. + #[must_use] + pub fn defaults() -> Self { + Self::from_hotkeys(super::defaults::DEFAULTS) + } + + /// A registry over `hotkeys`, each on its default chord. + #[must_use] + pub fn from_hotkeys(hotkeys: &[Hotkey]) -> Self { + Self { entries: hotkeys.iter().map(|hotkey| Entry { hotkey: *hotkey, binding: hotkey.default_binding }).collect() } + } + + /// The defaults with `overrides` (command id to `Mod-Alt-O` text) applied to the first chord of + /// each id. Returns the registry and the ids whose text was not a chord or not a known command. + #[must_use] + pub fn with_overrides(overrides: &BTreeMap) -> (Self, Vec) { + let mut registry = Self::defaults(); + let mut rejected = Vec::new(); + for (id, text) in overrides { + let binding = Binding::parse(text); + match (binding, registry.entries.iter_mut().find(|entry| entry.hotkey.id == id)) { + (Some(binding), Some(entry)) => entry.binding = binding, + _ => rejected.push(id.clone()), + } + } + (registry, rejected) + } + + /// Every entry, defaults first in declaration order. + #[must_use] + pub fn entries(&self) -> &[Entry] { + &self.entries + } + + /// The chord of the first entry of `id`. + #[must_use] + pub fn binding(&self, id: &str) -> Option { + self.entries.iter().find(|entry| entry.hotkey.id == id).map(|entry| entry.binding) + } + + /// The chord of `id` printed for `platform`. + #[must_use] + pub fn format(&self, id: &str, platform: Platform) -> Option { + self.binding(id).map(|binding| binding.format(platform)) + } + + /// Entries that share a chord in overlapping scopes, checked as macOS and as other platforms print them. + #[must_use] + pub fn conflicts(&self) -> Vec { + let mut found: Vec = Vec::new(); + for (index, a) in self.entries.iter().enumerate() { + for b in self.entries.iter().skip(index + 1) { + let same_command = a.hotkey.id == b.hotkey.id && a.hotkey.arg == b.hotkey.arg; + let same_chord = [Platform::Mac, Platform::Other].into_iter().any(|p| a.binding.canon(p) == b.binding.canon(p)); + if !same_command && same_chord && a.hotkey.scope.overlaps(b.hotkey.scope) { + found.push(Conflict { first: a.hotkey.id, second: b.hotkey.id, binding: a.binding }); + } + } + } + found + } +} diff --git a/crates/ui-egui/src/shell/hotkeys/tabs.rs b/crates/ui-egui/src/shell/hotkeys/tabs.rs new file mode 100644 index 0000000..36d96a7 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/tabs.rs @@ -0,0 +1,80 @@ +//! Tab index maths for the tab hotkeys, and the bounded stack of closed tabs. + +use std::path::{Path, PathBuf}; + +/// The Cmd+9 slot: it always means the last tab. +pub const LAST_TAB_SLOT: i32 = 9; +/// How many closed tabs Reopen can bring back. +pub const MAX_CLOSED: usize = 20; + +/// The tab `delta` steps from `active`, wrapping at both ends; `None` when there are no tabs. +#[must_use] +pub fn step(active: usize, len: usize, delta: isize) -> Option { + let last = len.checked_sub(1)?; + let from = isize::try_from(active.min(last)).ok()?; + let count = isize::try_from(len).ok()?; + usize::try_from(from.checked_add(delta)?.rem_euclid(count)).ok() +} + +/// The index for "go to tab `n`": 1..=8 are those tabs, [`LAST_TAB_SLOT`] is the last one, anything +/// else, or a number past the open tabs, is ignored. +#[must_use] +pub fn goto(n: i32, len: usize) -> Option { + let last = len.checked_sub(1)?; + if n == LAST_TAB_SLOT { + return Some(last); + } + if !(1..LAST_TAB_SLOT).contains(&n) { + return None; + } + let index = usize::try_from(n.checked_sub(1)?).ok()?; + (index <= last).then_some(index) +} + +/// The tabs that were open last frame and the ones closed since, newest last. +#[derive(Debug, Clone, Default)] +pub struct ClosedTabs { + vault: Option, + seen: Vec, + stack: Vec, +} + +impl ClosedTabs { + /// Compares `open` with the previous frame and remembers what went missing. A different vault starts over. + pub fn observe(&mut self, open: &[String], vault: &Path) { + if self.vault.as_deref() == Some(vault) { + for path in self.seen.iter().filter(|path| !open.contains(path)) { + self.stack.retain(|known| known != path); + self.stack.push(path.clone()); + } + let extra = self.stack.len().saturating_sub(MAX_CLOSED); + self.stack.drain(..extra); + } else { + self.vault = Some(vault.to_path_buf()); + self.stack.clear(); + } + self.seen = open.to_vec(); + } + + /// The most recently closed tab that is not open again. + pub fn pop(&mut self, open: &[String]) -> Option { + while let Some(path) = self.stack.pop() { + if !open.contains(&path) { + return Some(path); + } + } + None + } + + /// How many tabs can be reopened. + #[must_use] + pub fn len(&self) -> usize { + self.stack.len() + } + + /// Whether no tab can be reopened. + #[must_use] + pub fn is_empty(&self) -> bool { + self.stack.is_empty() + } +} diff --git a/crates/ui-egui/src/shell/hotkeys/tests.rs b/crates/ui-egui/src/shell/hotkeys/tests.rs new file mode 100644 index 0000000..364f451 --- /dev/null +++ b/crates/ui-egui/src/shell/hotkeys/tests.rs @@ -0,0 +1,260 @@ +//! Unit tests: chord printing, conflicts over the defaults, tab maths, the closed-tab stack and dispatch. + +use std::collections::BTreeMap; +use std::path::Path; + +use egui::{Context, Event, Key, Modifiers, RawInput}; + +use super::*; + +fn press(key: Key, modifiers: Modifiers) -> Event { + Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers } +} + +fn cmd_alt() -> Modifiers { + Modifiers { command: true, alt: true, ..Modifiers::NONE } +} + +/// Runs one frame with `events` and returns what dispatch fired against `registry`. +fn fire(ctx: &Context, registry: &Registry, events: Vec) -> Vec { + let mut fired = Vec::new(); + let mut out = ctx.run_ui(RawInput { events, ..RawInput::default() }, |ui| { + fired = dispatch(ui.ctx(), registry); + }); + out.textures_delta.clear(); + fired +} + +#[test] +fn chords_print_per_platform() { + let outline = Binding::key(Key::O).cmd().alt(); + assert_eq!(outline.format(Platform::Mac), "⌘⌥O"); + assert_eq!(outline.format(Platform::Other), "Ctrl+Alt+O"); + assert_eq!(Binding::key(Key::Tab).ctrl().shift().format(Platform::Mac), "⌃⇧⇥"); + assert_eq!(Binding::key(Key::Tab).ctrl().shift().format(Platform::Other), "Ctrl+Shift+Tab"); + assert_eq!(Binding::key(Key::Enter).cmd().shift().format(Platform::Mac), "⌘⇧↩"); + assert_eq!(Binding::key(Key::F6).shift().format(Platform::Other), "Shift+F6"); + assert_eq!(Binding::key(Key::Num3).cmd().format(Platform::Mac), "⌘3"); + assert_eq!(Binding::key(Key::Backslash).cmd().alt().format(Platform::Mac), "⌘⌥\\"); + assert_eq!(Binding::key(Key::ArrowRight).cmd().alt().format(Platform::Mac), "⌘⌥→"); + assert_eq!(Binding::key(Key::ArrowRight).cmd().alt().format(Platform::Other), "Ctrl+Alt+Right"); + assert_eq!(Binding::key(Key::Escape).format(Platform::Other), "Esc"); +} + +#[test] +fn accelerators_and_palette_syntax() { + let calendar = Binding::key(Key::C).cmd().alt(); + assert_eq!(calendar.accelerator(), "CmdOrCtrl+Alt+C"); + assert_eq!(calendar.palette_syntax(), "Mod-Alt-C"); + assert_eq!(Binding::key(Key::Comma).cmd().accelerator(), "CmdOrCtrl+,"); +} + +#[test] +fn chords_parse_and_round_trip() { + assert_eq!(Binding::parse("Mod-Alt-O"), Some(Binding::key(Key::O).cmd().alt())); + assert_eq!(Binding::parse("Ctrl-Shift-Tab"), Some(Binding::key(Key::Tab).ctrl().shift())); + assert_eq!(Binding::parse("F6"), Some(Binding::key(Key::F6))); + assert_eq!(Binding::parse("Hyper-O"), None); + assert_eq!(Binding::parse(""), None); + for entry in Registry::defaults().entries() { + let binding = entry.binding; + if binding.key != Key::Comma && binding.key != Key::Backslash && binding.key != Key::Minus { + assert_eq!(Binding::parse(&binding.palette_syntax()), Some(binding), "{}", entry.hotkey.id); + } + } +} + +#[test] +fn defaults_have_no_conflicts() { + assert_eq!(Registry::defaults().conflicts(), Vec::new()); +} + +#[test] +fn a_clashing_override_is_reported() { + let overrides = BTreeMap::from([("ui.show_outline".to_owned(), "Mod-Alt-R".to_owned())]); + let (registry, rejected) = Registry::with_overrides(&overrides); + assert_eq!(rejected, Vec::::new()); + let conflicts = registry.conflicts(); + assert_eq!(conflicts.len(), 1); + assert_eq!((conflicts[0].first, conflicts[0].second), ("ui.show_outline", "ui.show_related")); +} + +#[test] +fn overrides_replace_the_first_chord_and_unknown_ids_are_rejected() { + let overrides = BTreeMap::from([ + ("ui.show_outline".to_owned(), "Mod-Alt-J".to_owned()), + ("ui.nope".to_owned(), "Mod-J".to_owned()), + ("ui.show_lint".to_owned(), "garbage-".to_owned()), + ]); + let (registry, rejected) = Registry::with_overrides(&overrides); + assert_eq!(registry.binding("ui.show_outline"), Some(Binding::key(Key::J).cmd().alt())); + assert_eq!(registry.format("ui.show_outline", Platform::Other).as_deref(), Some("Ctrl+Alt+J")); + assert_eq!(rejected, ["ui.nope", "ui.show_lint"]); + assert_eq!(registry.binding("ui.show_lint"), Registry::defaults().binding("ui.show_lint")); +} + +#[test] +fn every_command_the_shell_runs_has_a_default() { + let registry = Registry::defaults(); + for id in [ + "ui.next_tab", + "ui.prev_tab", + "ui.goto_tab", + "ui.reopen_closed_tab", + "ui.focus_sidebar", + "ui.focus_editor", + "ui.focus_right_panel", + "ui.cycle_focus", + "ui.show_outline", + "ui.show_related", + "ui.show_backlinks", + "ui.show_assistant", + "ui.show_lint", + "ui.show_comments", + "ui.toggle_right_panel", + "ui.toggle_sidebar", + ] { + assert!(registry.binding(id).is_some(), "{id}"); + } +} + +#[test] +fn tooltip_appends_the_chord() { + let chord = format("ui.show_outline").expect("outline has a chord"); + assert_eq!(tooltip("Outline", "ui.show_outline"), format!("Outline {chord}")); + assert_eq!(tooltip("Tags", "ui.sidebar.tags"), "Tags"); +} + +#[test] +fn native_accelerators_match_the_registry() { + use crate::shell::app::{MenuEntry, MenuTarget, menu_model}; + let registry = Registry::defaults(); + let id_of = |target: MenuTarget| match target { + MenuTarget::Command(id) => id, + MenuTarget::CloseTab => "ui.close_tab", + MenuTarget::FindInNotes => "ui.sidebar.search", + MenuTarget::Quit => "ui.quit", + _ => "", + }; + let mut checked = 0; + for menu in menu_model() { + for entry in menu.entries { + let MenuEntry::Item(item) = entry else { continue }; + let Some(accelerator) = item.accelerator else { continue }; + let binding = registry.binding(id_of(item.target)).unwrap_or_else(|| panic!("no hotkey for {}", item.id)); + assert_eq!(binding.accelerator(), accelerator, "{}", item.id); + checked += 1; + } + } + assert!(checked >= 6, "checked {checked}"); +} + +#[test] +fn cycle_follows_sidebar_editor_right_and_wraps() { + let mut card = NavFocus::Sidebar; + let mut seen = Vec::new(); + for _ in 0..4 { + card = cycle_focus(card, true, true, 1); + seen.push(card); + } + assert_eq!(seen, [NavFocus::Editor, NavFocus::Right, NavFocus::Sidebar, NavFocus::Editor]); + assert_eq!(cycle_focus(NavFocus::Sidebar, true, true, -1), NavFocus::Right); + assert_eq!(cycle_focus(NavFocus::Editor, false, true, 1), NavFocus::Right, "a hidden sidebar is skipped"); + assert_eq!(cycle_focus(NavFocus::Right, false, true, 1), NavFocus::Editor); + assert_eq!(cycle_focus(NavFocus::Editor, false, false, 1), NavFocus::Editor, "only the editor is left"); +} + +#[test] +fn step_wraps_and_handles_empty_and_single() { + assert_eq!(step_index(0, 0, 1), None); + assert_eq!(step_index(0, 1, 1), Some(0)); + assert_eq!(step_index(0, 1, -1), Some(0)); + assert_eq!(step_index(2, 3, 1), Some(0)); + assert_eq!(step_index(0, 3, -1), Some(2)); + assert_eq!(step_index(1, 3, 1), Some(2)); + assert_eq!(step_index(9, 3, 1), Some(0), "a stale active index is clamped"); + assert_eq!(step_index(0, 3, isize::MIN), Some(1), "no overflow"); +} + +#[test] +fn goto_picks_numbered_tabs_and_ignores_out_of_range() { + assert_eq!(goto_index(1, 3), Some(0)); + assert_eq!(goto_index(3, 3), Some(2)); + assert_eq!(goto_index(4, 3), None); + assert_eq!(goto_index(8, 20), Some(7)); + assert_eq!(goto_index(9, 20), Some(19), "9 is the last tab"); + assert_eq!(goto_index(9, 1), Some(0)); + assert_eq!(goto_index(9, 0), None); + assert_eq!(goto_index(1, 0), None); + assert_eq!(goto_index(0, 5), None); + assert_eq!(goto_index(-3, 5), None); + assert_eq!(goto_index(10, 50), None); +} + +fn tabs(paths: &[&str]) -> Vec { + paths.iter().map(|path| (*path).to_owned()).collect() +} + +#[test] +fn closed_tabs_come_back_newest_first_and_skip_open_ones() { + let vault = Path::new("/vault"); + let mut closed = ClosedTabs::default(); + closed.observe(&tabs(&["a", "b", "c"]), vault); + closed.observe(&tabs(&["a"]), vault); + assert_eq!(closed.len(), 2); + closed.observe(&tabs(&["a", "c"]), vault); + assert_eq!(closed.pop(&tabs(&["a", "c"])), Some("b".to_owned())); + assert_eq!(closed.pop(&tabs(&["a"])), None); + assert!(closed.is_empty()); +} + +#[test] +fn closed_tabs_are_bounded_and_a_new_vault_starts_over() { + let vault = Path::new("/vault"); + let mut closed = ClosedTabs::default(); + let many: Vec = (0..MAX_CLOSED + 5).map(|n| format!("n{n}.md")).collect(); + closed.observe(&many, vault); + closed.observe(&[], vault); + assert_eq!(closed.len(), MAX_CLOSED); + assert_eq!(closed.pop(&[]), Some(format!("n{}.md", MAX_CLOSED + 4))); + closed.observe(&tabs(&["x"]), Path::new("/other")); + assert!(closed.is_empty()); +} + +#[test] +fn dispatch_consumes_a_chord_once() { + let ctx = Context::default(); + let registry = Registry::defaults(); + let fired = fire(&ctx, ®istry, vec![press(Key::O, cmd_alt())]); + assert_eq!(fired, [Fired { id: "ui.show_outline", arg: None }]); + assert_eq!(fire(&ctx, ®istry, Vec::new()), Vec::new()); +} + +#[test] +fn dispatch_matches_modifiers_exactly_and_carries_the_argument() { + let ctx = Context::default(); + let registry = Registry::defaults(); + let cmd = Modifiers { command: true, ..Modifiers::NONE }; + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Num3, cmd)]), [Fired { id: "ui.goto_tab", arg: Some(3) }]); + let shifted = Modifiers { command: true, shift: true, ..Modifiers::NONE }; + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Num3, shifted)]), Vec::new(), "Cmd+Shift+3 is not Cmd+3"); + assert_eq!(fire(&ctx, ®istry, vec![press(Key::F6, Modifiers::NONE)]), [Fired { id: "ui.cycle_focus", arg: Some(1) }]); + assert_eq!(fire(&ctx, ®istry, vec![press(Key::F6, Modifiers::SHIFT)]), [Fired { id: "ui.cycle_focus", arg: Some(-1) }]); + let ctrl = Modifiers { ctrl: true, ..Modifiers::NONE }; + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Tab, ctrl)]), [Fired { id: "ui.next_tab", arg: None }]); +} + +#[test] +fn escape_only_leaves_a_panel_that_holds_the_keyboard() { + let ctx = Context::default(); + let registry = Registry::defaults(); + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Escape, Modifiers::NONE)]), Vec::new(), "no panel focused"); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("notescraft-hotkeys-snapshot"), Snapshot { card: NavFocus::Right, on_region: true, vim: false }); + }); + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Escape, Modifiers::NONE)]), [Fired { id: "ui.focus_editor", arg: None }]); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("notescraft-hotkeys-snapshot"), Snapshot { card: NavFocus::Right, on_region: true, vim: true }); + }); + assert_eq!(fire(&ctx, ®istry, vec![press(Key::Escape, Modifiers::NONE)]), Vec::new(), "Vim keeps Esc"); +} diff --git a/crates/ui-egui/src/shell/mod.rs b/crates/ui-egui/src/shell/mod.rs index 73bbab6..7ce51a6 100644 --- a/crates/ui-egui/src/shell/mod.rs +++ b/crates/ui-egui/src/shell/mod.rs @@ -6,6 +6,7 @@ mod app_tests; pub mod attachment_drop; pub mod commands_ui; pub mod compact; +pub mod hotkeys; pub mod layout; pub mod panel_resize; pub mod recovery; diff --git a/crates/ui-egui/src/shell/panel_resize/controls.rs b/crates/ui-egui/src/shell/panel_resize/controls.rs index 7753076..76216a4 100644 --- a/crates/ui-egui/src/shell/panel_resize/controls.rs +++ b/crates/ui-egui/src/shell/panel_resize/controls.rs @@ -16,7 +16,21 @@ const RAIL_STEP: f32 = 36.0; /// Paints a 28-point rounded icon control inside the supplied rectangle. /// `expanded` supplies the actual panel state; panel icons share playback across header and rail. pub fn icon_button(ui: &mut Ui, tokens: &Tokens, id: Id, rect: Rect, icon: &str, label: &str, expanded: Option) -> bool { - let response = ui.interact(rect, id, Sense::click()).on_hover_text(label); + let hotkey = match icon { + "panel-left" => Some("ui.toggle_sidebar"), + "panel-right" => Some("ui.toggle_right_panel"), + _ => None, + }; + icon_button_keyed(ui, tokens, id, rect, (icon, label, hotkey), expanded) +} + +/// [`icon_button`] whose tooltip also shows the chord of the hotkey `(icon, label, hotkey)` names. +pub fn icon_button_keyed(ui: &mut Ui, tokens: &Tokens, id: Id, rect: Rect, (icon, label, hotkey): (&str, &str, Option<&str>), expanded: Option) -> bool { + let response = ui.interact(rect, id, Sense::click()); + let response = match hotkey { + Some(hotkey) => crate::hotkeys::on_hover_hotkey(response, label, hotkey), + None => response.on_hover_text(label), + }; response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label)); let glow = motion::hover(ui.ctx(), id, &response); let glow = if response.has_focus() { 1.0 } else { glow }; @@ -34,18 +48,18 @@ pub fn icon_button(ui: &mut Ui, tokens: &Tokens, id: Id, rect: Rect, icon: &str, pub(crate) fn sidebar_rail(ui: &mut Ui, tokens: &Tokens, state: &mut crate::state::UiState) { use crate::sidebar_tabs::SidebarTab; - const ENTRIES: [(Option, &str, &str); 6] = [ - (None, "panel-left", EXPAND), - (Some(SidebarTab::Explorer), "folder", "Explorer"), - (Some(SidebarTab::Search), "search", "Search"), - (Some(SidebarTab::Tags), "tag", "Tags"), - (Some(SidebarTab::Buffers), "layers", "Buffers"), - (Some(SidebarTab::Source), "git-branch", "Source"), + const ENTRIES: [(Option, &str, &str, Option<&str>); 6] = [ + (None, "panel-left", EXPAND, Some("ui.toggle_sidebar")), + (Some(SidebarTab::Explorer), "folder", "Explorer", Some("ui.focus_sidebar")), + (Some(SidebarTab::Search), "search", "Search", Some("ui.sidebar.search")), + (Some(SidebarTab::Tags), "tag", "Tags", None), + (Some(SidebarTab::Buffers), "layers", "Buffers", None), + (Some(SidebarTab::Source), "git-branch", "Source", None), ]; crate::titlebar::traffic_light_space(ui); - for (tab, icon, label) in ENTRIES { + for (tab, icon, label, hotkey) in ENTRIES { let (rect, _) = ui.allocate_exact_size(vec2(super::RAIL_WIDTH, super::RAIL_WIDTH), Sense::hover()); - if icon_button(ui, tokens, ui.id().with(icon), rect, icon, label, tab.is_none().then_some(state.sidebar_visible)) { + if icon_button_keyed(ui, tokens, ui.id().with(icon), rect, (icon, label, hotkey), tab.is_none().then_some(state.sidebar_visible)) { state.sidebar_visible = true; super::workspace::request_expansion(ui.ctx(), super::Edge::End); if let Some(tab) = tab { @@ -58,17 +72,18 @@ 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); 6] = [ - (None, "panel-right", EXPAND), - (Some(RightTab::Backlinks), "link", "Backlinks"), - (Some(RightTab::Related), "sparkles", "Related"), - (Some(RightTab::Lint), "list-checks", "Lint"), - (Some(RightTab::Outline), "list", "Outline"), - (Some(RightTab::Assistant), "bot", "Assistant"), + const ENTRIES: [(Option, &str, &str, &str); 7] = [ + (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::Assistant), "bot", "Assistant", "ui.show_assistant"), ]; - for (choice, icon, label) in ENTRIES { + for (choice, icon, label, hotkey) in ENTRIES { let (rect, _) = ui.allocate_exact_size(vec2(super::RAIL_WIDTH, super::RAIL_WIDTH), Sense::hover()); - if icon_button(ui, tokens, ui.id().with(icon), rect, icon, label, choice.is_none().then_some(*expanded)) { + if icon_button_keyed(ui, tokens, ui.id().with(icon), rect, (icon, label, Some(hotkey)), choice.is_none().then_some(*expanded)) { *expanded = true; super::workspace::request_expansion(ui.ctx(), super::Edge::Start); if let Some(choice) = choice { diff --git a/crates/ui-egui/src/shell/panel_resize/workspace.rs b/crates/ui-egui/src/shell/panel_resize/workspace.rs index c2ff909..b7a38c1 100644 --- a/crates/ui-egui/src/shell/panel_resize/workspace.rs +++ b/crates/ui-egui/src/shell/panel_resize/workspace.rs @@ -13,7 +13,6 @@ const CONTENT_CUTOFF: f32 = 0.4; const LEFT_ID: &str = "notescraft-resize-sidebar"; const RIGHT_ID: &str = "notescraft-resize-right"; const SIDEBAR_COMMAND: &str = "ui.toggle_sidebar"; -const RIGHT_COMMAND: &str = "ui.toggle_right_panel"; const EXPANSION_ID: &str = "manual-panel-expansion"; /// Card geometry and content visibility for the current animation frame. @@ -36,10 +35,11 @@ pub(crate) fn shortcut(ctx: &Context) -> Option<&'static str> { let mut command = None; input.events.retain(|event| { let egui::Event::Key { key: egui::Key::B, modifiers, pressed: true, .. } = event else { return true }; - if !(modifiers.command || modifiers.ctrl) || modifiers.shift { + // Cmd+Alt+B belongs to the block menu; the right panel toggles on Cmd+Alt+\ (hotkeys registry). + if !(modifiers.command || modifiers.ctrl) || modifiers.shift || modifiers.alt { return true; } - command = Some(if modifiers.alt { RIGHT_COMMAND } else { SIDEBAR_COMMAND }); + command = Some(SIDEBAR_COMMAND); false }); command diff --git a/crates/ui-egui/src/shell/vim_nav.rs b/crates/ui-egui/src/shell/vim_nav.rs index 51f9dd2..8f24bc3 100644 --- a/crates/ui-egui/src/shell/vim_nav.rs +++ b/crates/ui-egui/src/shell/vim_nav.rs @@ -47,6 +47,8 @@ pub struct VimNav { pub(crate) command_history: Vec, /// How far back in the history the draft is walked. pub(crate) commands_back: usize, + /// Hotkey focus moves, the focus wash and the closed-tab stack. + pub(crate) hotkeys: crate::hotkeys::State, } impl VimNav { diff --git a/crates/ui-egui/src/shell/vim_nav/events.rs b/crates/ui-egui/src/shell/vim_nav/events.rs index 6064b4f..013db23 100644 --- a/crates/ui-egui/src/shell/vim_nav/events.rs +++ b/crates/ui-egui/src/shell/vim_nav/events.rs @@ -69,6 +69,11 @@ impl NotesApp { self.vim_nav.pending = WINDOW_PREFIX.into(); return true; } + if modifiers.ctrl && !modifiers.alt && !modifiers.mac_cmd && !modifiers.shift && [Key::H, Key::L].contains(&key) { + self.vim_nav.pending.clear(); + self.vim_move_focus(ctx, if key == Key::H { -1 } else { 1 }); + return true; + } if modifiers.ctrl || modifiers.alt || modifiers.mac_cmd { return false; } @@ -79,12 +84,9 @@ impl NotesApp { match pending.as_str() { WINDOW_PREFIX => { match key { - Key::H => self.vim_nav.shift_focus(-1, self.state.sidebar_visible, self.state.right_visible), - Key::L => self.vim_nav.shift_focus(1, self.state.sidebar_visible, self.state.right_visible), - _ => return true, - } - if self.vim_nav.focus == NavFocus::Editor { - self.request_vim_editor_focus(ctx); + Key::H => self.vim_move_focus(ctx, -1), + Key::L => self.vim_move_focus(ctx, 1), + _ => {} } return true; } @@ -133,6 +135,14 @@ impl NotesApp { true } + /// Moves card focus one step (Ctrl-w h/l, Ctrl-h/l) and puts the caret in the editor when it lands there. + fn vim_move_focus(&mut self, ctx: &Context, delta: isize) { + self.vim_nav.shift_focus(delta, self.state.sidebar_visible, self.state.right_visible); + if self.vim_nav.focus == NavFocus::Editor { + self.request_vim_editor_focus(ctx); + } + } + fn leader_key(&mut self, key: Key) -> bool { self.vim_nav.leader = false; self.cue(crate::sounds::Cue::Pop); diff --git a/crates/ui-egui/src/shell/vim_nav/tests.rs b/crates/ui-egui/src/shell/vim_nav/tests.rs index 6655a48..1670d29 100644 --- a/crates/ui-egui/src/shell/vim_nav/tests.rs +++ b/crates/ui-egui/src/shell/vim_nav/tests.rs @@ -51,6 +51,17 @@ fn sequences_choose_tabs_focus_and_leader_panels() { assert!(!app.vim_nav.leader); } +#[test] +fn ctrl_h_and_ctrl_l_move_card_focus_without_the_window_prefix() { + let Some(mut app) = app("vim_global_ctrl_hl") else { return }; + app.vim_nav.focus = NavFocus::Editor; + let ctx = egui::Context::default(); + frame(&mut app, &ctx, vec![key(Key::H, Modifiers::CTRL)]); + assert_eq!(app.vim_nav.focus, NavFocus::Sidebar); + frame(&mut app, &ctx, vec![key(Key::L, Modifiers::CTRL), key(Key::L, Modifiers::CTRL)]); + assert_eq!(app.vim_nav.focus, NavFocus::Right); +} + #[test] fn disabled_mode_and_text_focus_preserve_input() { let Some(mut app) = app("vim_global_disabled") else { return }; diff --git a/crates/ui-egui/src/text.rs b/crates/ui-egui/src/text.rs index c6a19e5..17c310f 100644 --- a/crates/ui-egui/src/text.rs +++ b/crates/ui-egui/src/text.rs @@ -32,13 +32,14 @@ fn lookup(key: &'static str) -> &'static str { pub mod app; mod features; -pub use features::{attribution, compact, launcher, live, sync_queue, sync_sheet}; +pub use features::{attribution, compact, hotkeys, launcher, live, menu_keys, paste, sync_queue, sync_sheet}; mod panels; mod settings; mod views; pub use panels::*; pub use settings::*; pub use views::*; +pub mod confirm; pub mod editing_prompt; pub mod find_bar; pub mod grammar_dialog; diff --git a/crates/ui-egui/src/text/confirm.rs b/crates/ui-egui/src/text/confirm.rs new file mode 100644 index 0000000..789c943 --- /dev/null +++ b/crates/ui-egui/src/text/confirm.rs @@ -0,0 +1,9 @@ +//! Copy for the shared "are you sure?" prompts of destructive actions. + +table! { + delete_question = "ui.confirm.delete-question"; + delete = "ui.confirm.delete"; + cancel = "ui.confirm.cancel"; + element = "ui.confirm.element"; + elements = "ui.confirm.elements"; +} diff --git a/crates/ui-egui/src/text/features/hotkeys.rs b/crates/ui-egui/src/text/features/hotkeys.rs new file mode 100644 index 0000000..29211b6 --- /dev/null +++ b/crates/ui-egui/src/text/features/hotkeys.rs @@ -0,0 +1,23 @@ +//! Copy for the hotkey commands (palette labels), the panel landmarks and the tab bar tooltips. + +table! { + next_tab = "ui.hotkeys.next-tab"; + prev_tab = "ui.hotkeys.prev-tab"; + goto_tab = "ui.hotkeys.goto-tab"; + reopen_tab = "ui.hotkeys.reopen-tab"; + focus_sidebar = "ui.hotkeys.focus-sidebar"; + focus_editor = "ui.hotkeys.focus-editor"; + focus_right = "ui.hotkeys.focus-right"; + cycle_focus = "ui.hotkeys.cycle-focus"; + show_outline = "ui.hotkeys.show-outline"; + show_related = "ui.hotkeys.show-related"; + show_backlinks = "ui.hotkeys.show-backlinks"; + show_assistant = "ui.hotkeys.show-assistant"; + show_lint = "ui.hotkeys.show-lint"; + show_comments = "ui.hotkeys.show-comments"; + region_sidebar = "ui.hotkeys.region-sidebar"; + region_editor = "ui.hotkeys.region-editor"; + region_right = "ui.hotkeys.region-right"; + new_tab = "ui.hotkeys.new-tab"; + doc = "ui.hotkeys.doc"; +} diff --git a/crates/ui-egui/src/text/features/menu_keys.rs b/crates/ui-egui/src/text/features/menu_keys.rs new file mode 100644 index 0000000..49e1218 --- /dev/null +++ b/crates/ui-egui/src/text/features/menu_keys.rs @@ -0,0 +1,8 @@ +//! Copy for menu keys: accessible names of menus and the word for a mnemonic key. + +table! { + key = "ui.menu-keys.key"; + file = "ui.menu-keys.file"; + folder = "ui.menu-keys.folder"; + tab = "ui.menu-keys.tab"; +} diff --git a/crates/ui-egui/src/text/features/mod.rs b/crates/ui-egui/src/text/features/mod.rs index 8b9d25d..b891db2 100644 --- a/crates/ui-egui/src/text/features/mod.rs +++ b/crates/ui-egui/src/text/features/mod.rs @@ -1,9 +1,12 @@ //! Copy for the newer feature screens: compact layout, vault launcher, live editing, the sync sheet, the sync -//! queue and the last-edit line of the status bar. +//! queue, the last-edit line of the status bar and the paste path. pub mod attribution; pub mod compact; +pub mod hotkeys; pub mod launcher; pub mod live; +pub mod menu_keys; +pub mod paste; pub mod sync_queue; pub mod sync_sheet; diff --git a/crates/ui-egui/src/text/panels/mod.rs b/crates/ui-egui/src/text/panels/mod.rs index f3fbeeb..666a831 100644 --- a/crates/ui-egui/src/text/panels/mod.rs +++ b/crates/ui-egui/src/text/panels/mod.rs @@ -6,6 +6,7 @@ pub mod knowledge_related; pub mod knowledge_sources; pub mod lint_panel; pub mod menubar; +pub mod review; pub mod right_view; pub mod search_panel_controls; pub mod search_panel_results; diff --git a/crates/ui-egui/src/text/panels/review/comment_panel.rs b/crates/ui-egui/src/text/panels/review/comment_panel.rs new file mode 100644 index 0000000..23ababb --- /dev/null +++ b/crates/ui-egui/src/text/panels/review/comment_panel.rs @@ -0,0 +1,31 @@ +//! Copy for the comments tab of the right card. + +table! { + title = "ui.comment-panel.title"; + all = "ui.comment-panel.all"; + open = "ui.comment-panel.open"; + resolved = "ui.comment-panel.resolved"; + orphaned = "ui.comment-panel.orphaned"; + compose = "ui.comment-panel.compose"; + compose_placeholder = "ui.comment-panel.compose-placeholder"; + send = "ui.comment-panel.send"; + reply = "ui.comment-panel.reply"; + reply_placeholder = "ui.comment-panel.reply-placeholder"; + resolve = "ui.comment-panel.resolve"; + reopen = "ui.comment-panel.reopen"; + reanchor = "ui.comment-panel.reanchor"; + reanchor_hint = "ui.comment-panel.reanchor-hint"; + no_selection = "ui.comment-panel.no-selection"; + no_threads = "ui.comment-panel.no-threads"; + no_match = "ui.comment-panel.no-match"; + read_only = "ui.comment-panel.read-only"; + loading = "ui.comment-panel.loading"; + moved = "ui.comment-panel.moved"; + orphan_badge = "ui.comment-panel.orphan-badge"; + replies_one = "ui.comment-panel.replies-one"; + replies_many = "ui.comment-panel.replies-many"; + now = "ui.comment-panel.now"; + minutes = "ui.comment-panel.minutes"; + hours = "ui.comment-panel.hours"; + days = "ui.comment-panel.days"; +} diff --git a/crates/ui-egui/src/text/panels/review/mod.rs b/crates/ui-egui/src/text/panels/review/mod.rs new file mode 100644 index 0000000..01ae858 --- /dev/null +++ b/crates/ui-egui/src/text/panels/review/mod.rs @@ -0,0 +1,3 @@ +//! Copy for the review panels: comments and suggestions. + +pub mod comment_panel; diff --git a/crates/ui-egui/src/text/panels/sidebar/explorer.rs b/crates/ui-egui/src/text/panels/sidebar/explorer.rs index e81466d..3bf33e6 100644 --- a/crates/ui-egui/src/text/panels/sidebar/explorer.rs +++ b/crates/ui-egui/src/text/panels/sidebar/explorer.rs @@ -8,4 +8,5 @@ table! { follow = "ui.explorer.follow"; pinned = "ui.explorer.pinned"; clear_filter = "ui.explorer.clear-filter"; + delete = "ui.explorer.delete"; } diff --git a/crates/ui-egui/src/text/tabs.rs b/crates/ui-egui/src/text/tabs.rs index 5c058db..f94e372 100644 --- a/crates/ui-egui/src/text/tabs.rs +++ b/crates/ui-egui/src/text/tabs.rs @@ -5,4 +5,6 @@ table! { close_others = "ui.tabs.close-others"; close_all = "ui.tabs.close-all"; close_tab = "ui.tabs.close-tab"; + search_tabs = "ui.tabs.search"; + no_match = "ui.tabs.no-match"; } diff --git a/crates/ui-egui/src/text/views/mod.rs b/crates/ui-egui/src/text/views/mod.rs index 819c651..9476aa3 100644 --- a/crates/ui-egui/src/text/views/mod.rs +++ b/crates/ui-egui/src/text/views/mod.rs @@ -12,6 +12,7 @@ pub use files::{file_card, folder_view}; pub mod graph_view; pub mod image_annotate; pub mod image_view; +pub mod lightbox; pub mod media_view; pub mod note_view; pub mod slash_popup; diff --git a/crates/ui-egui/src/text/views/note_view.rs b/crates/ui-egui/src/text/views/note_view.rs index b1d342f..861ee99 100644 --- a/crates/ui-egui/src/text/views/note_view.rs +++ b/crates/ui-egui/src/text/views/note_view.rs @@ -14,6 +14,7 @@ table! { media_no_player = "ui.note-view.media-no-player"; media_open = "ui.note-view.media-open"; embed_open = "ui.note-view.embed-open"; + details = "ui.note-view.details"; embed_section_missing = "ui.note-view.embed-section-missing"; image_failed = "ui.note-view.image-failed"; copy = "ui.note-view.copy"; diff --git a/crates/ui-egui/src/views/center.rs b/crates/ui-egui/src/views/center.rs index 9aae5c1..f5a0700 100644 --- a/crates/ui-egui/src/views/center.rs +++ b/crates/ui-egui/src/views/center.rs @@ -286,8 +286,9 @@ pub fn draw_custom( } /// The paths of the open tabs whose buffer is dirty, for the tab dots. -fn dirty_paths(session: &Session, tabs: &Tabs) -> Vec { - tabs.open.iter().filter(|path| session.buffer(path).is_some_and(notescraft_engine::Buffer::is_dirty)).cloned().collect() +fn dirty_paths(session: &Session, _tabs: &Tabs) -> Vec { + // Only open buffers can be dirty, and hibernation keeps those few: cost does not grow with the tab count. + session.buffers().iter().filter(|(_, buffer)| buffer.is_dirty()).map(|(path, _)| path.to_owned()).collect() } /// The toast overlay; `true` when it was closed or timed out this frame. diff --git a/crates/ui-egui/src/views/mod.rs b/crates/ui-egui/src/views/mod.rs index 1e40b46..a6f2777 100644 --- a/crates/ui-egui/src/views/mod.rs +++ b/crates/ui-egui/src/views/mod.rs @@ -11,6 +11,7 @@ pub mod file_card; pub mod folder_view; pub mod graph_view; pub mod image_view; +pub mod lightbox; pub mod math_face; pub mod math_view; pub mod media_view; diff --git a/crates/ui-egui/src/views/whiteboard_view/chrome/panels_actions.rs b/crates/ui-egui/src/views/whiteboard_view/chrome/panels_actions.rs index fd8f044..5da914f 100644 --- a/crates/ui-egui/src/views/whiteboard_view/chrome/panels_actions.rs +++ b/crates/ui-egui/src/views/whiteboard_view/chrome/panels_actions.rs @@ -29,7 +29,7 @@ pub(in crate::views::whiteboard_view) fn draw_actions_row(ui: &mut egui::Ui, tok strings::FORWARD => reorder_selection(view, board, true), strings::BACK => reorder_selection(view, board, false), strings::DUPLICATE => duplicate_selected(view, board), - strings::DELETE => delete_selected(view, board), + strings::DELETE => crate::views::whiteboard_view::edit::request_delete(view), strings::LOCK => { let lock = board.elements.iter().any(|element| view.selected_ids.contains(&element.id) && !element.locked); for element in board.elements.iter_mut().filter(|element| view.selected_ids.contains(&element.id) && !element.is_deleted) { @@ -114,20 +114,6 @@ fn duplicate_selected(view: &mut WhiteboardView, board: &mut Whiteboard) { view.changed = true; } -fn delete_selected(view: &mut WhiteboardView, board: &mut Whiteboard) { - let ids: Vec = view.selected_ids.clone(); - let mut deleted = false; - for id in &ids { - if board.delete(id).is_some() { - deleted = true; - } - } - if deleted { - view.selected_ids.clear(); - view.changed = true; - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/views/whiteboard_view/scene/arrange.rs b/crates/ui-egui/src/views/whiteboard_view/scene/arrange.rs index c468217..345e8f2 100644 --- a/crates/ui-egui/src/views/whiteboard_view/scene/arrange.rs +++ b/crates/ui-egui/src/views/whiteboard_view/scene/arrange.rs @@ -4,7 +4,7 @@ use egui::Ui; use notescraft_whiteboard::{ElementKind, Whiteboard}; use crate::views::whiteboard_view::{ - edit::{delete_selected, duplicate_selected, fit_board, group_selected, reorder_selected}, + edit::{duplicate_selected, fit_board, group_selected, reorder_selected, request_delete}, state::WhiteboardView, }; use crate::{context_menu::menu_row, text::whiteboard_view_arrange as strings, theme::Tokens}; @@ -164,7 +164,7 @@ fn perform(action: Action, view: &mut WhiteboardView, board: &mut Whiteboard) { Action::Group => group_selected(view, board), Action::Ungroup => ungroup_selected(view, board), Action::Lock => toggle_lock_selected(view, board), - Action::Delete => delete_selected(view, board), + Action::Delete => request_delete(view), } view.cue(crate::sounds::Cue::Tick); } diff --git a/crates/ui-egui/src/views/whiteboard_view/scene/edit.rs b/crates/ui-egui/src/views/whiteboard_view/scene/edit.rs index 77f86a9..5dccb2a 100644 --- a/crates/ui-egui/src/views/whiteboard_view/scene/edit.rs +++ b/crates/ui-egui/src/views/whiteboard_view/scene/edit.rs @@ -131,7 +131,7 @@ pub(in crate::views::whiteboard_view) fn handle_keys(ctx: &egui::Context, view: view.layers_open = !view.layers_open; } if input.consume_key(Modifiers::NONE, Key::Delete) || input.consume_key(Modifiers::NONE, Key::Backspace) { - delete_selected(view, board); + request_delete(view); } }); } @@ -294,6 +294,15 @@ pub(in crate::views::whiteboard_view) fn top_hit(board: &Whiteboard, point: [f64 .map(|element| element.id.clone()) } +/// Asks before the selection is deleted: the question is up until the user answers it, and the +/// elements go only when they confirm. +pub(in crate::views::whiteboard_view) fn request_delete(view: &mut WhiteboardView) { + if view.selected_ids.is_empty() { + return; + } + view.pending_delete = true; +} + pub(in crate::views::whiteboard_view) fn delete_selected(view: &mut WhiteboardView, board: &mut Whiteboard) { let mut erased = false; for id in &view.selected_ids { diff --git a/crates/ui-egui/src/views/whiteboard_view/scene/interaction.rs b/crates/ui-egui/src/views/whiteboard_view/scene/interaction.rs index a639805..84ada8c 100644 --- a/crates/ui-egui/src/views/whiteboard_view/scene/interaction.rs +++ b/crates/ui-egui/src/views/whiteboard_view/scene/interaction.rs @@ -38,7 +38,9 @@ pub fn show_whiteboard(ui: &mut egui::Ui, tokens: &Tokens, view: &mut Whiteboard // in `edit.rs handle_keys`. `popover_handle_escape` only acts when the // popover is open, so it is safe to call every frame. let _ = popover_handle_escape(ui.ctx(), view); - if !palette_was_open && !ui.ctx().egui_wants_keyboard_input() { + // A delete waits for the user's answer: the canvas and the keys rest while the question is up. + let pending_delete = view.pending_delete; + if !pending_delete && !palette_was_open && !ui.ctx().egui_wants_keyboard_input() { handle_keys(ui.ctx(), view, board); } @@ -89,7 +91,7 @@ pub fn show_whiteboard(ui: &mut egui::Ui, tokens: &Tokens, view: &mut Whiteboard } response.context_menu(|ui| crate::views::whiteboard_view::arrange::canvas_menu(ui, tokens, view, board)); let panning = pan_gesture(ui.ctx(), &response, view); - if !panning && !palette_was_open && view.palette_open.is_none() && !view.block_canvas_until_release { + if !panning && !palette_was_open && view.palette_open.is_none() && !view.block_canvas_until_release && !pending_delete { handle_pointer(&response, view, board); } if ui.ctx().input(|input| input.pointer.primary_released()) { @@ -109,6 +111,11 @@ pub fn show_whiteboard(ui: &mut egui::Ui, tokens: &Tokens, view: &mut Whiteboard } view.last_selection = view.selected_ids.clone(); } + // The delete question comes before the snapshot: the elements go only after the user confirmed + // them, and the undo entry then holds the board without them. + if view.pending_delete { + confirm_delete(ui.ctx(), tokens, view, board); + } if view.changed { view.snapshot(board); } @@ -120,6 +127,22 @@ pub fn show_whiteboard(ui: &mut egui::Ui, tokens: &Tokens, view: &mut Whiteboard } } +/// Asks the shared question before the selection is deleted; the elements go when the user +/// confirms, and the question closes when they cancel. +fn confirm_delete(ctx: &Context, tokens: &Tokens, view: &mut WhiteboardView, board: &mut Whiteboard) { + let count = view.selected_ids.len(); + let noun = if count == 1 { crate::text::confirm::element() } else { crate::text::confirm::elements() }; + let subject = format!("{count} {noun}"); + match crate::widgets::confirm::ask(ctx, tokens, "whiteboard-delete", Some(&subject)) { + Some(crate::dialog::Answer::Confirm) => { + view.pending_delete = false; + crate::views::whiteboard_view::edit::delete_selected(view, board); + } + Some(crate::dialog::Answer::Cancel) => view.pending_delete = false, + _ => {} + } +} + fn show_toolbar(ctx: &Context, tokens: &Tokens, view: &mut WhiteboardView, canvas: Rect) { Area::new(Id::new("notescraft-whiteboard-tools")) .order(Order::Foreground) diff --git a/crates/ui-egui/src/views/whiteboard_view/scene/state.rs b/crates/ui-egui/src/views/whiteboard_view/scene/state.rs index 406e3af..990b69e 100644 --- a/crates/ui-egui/src/views/whiteboard_view/scene/state.rs +++ b/crates/ui-egui/src/views/whiteboard_view/scene/state.rs @@ -224,6 +224,8 @@ pub struct WhiteboardView { pub(in crate::views::whiteboard_view) palette_rect: Option, pub(in crate::views::whiteboard_view) custom_color: String, pub(in crate::views::whiteboard_view) block_canvas_until_release: bool, + /// A delete is waiting for the user's answer; the elements go only when it is confirmed. + pub(in crate::views::whiteboard_view) pending_delete: bool, pub(in crate::views::whiteboard_view) property_controls: crate::views::whiteboard_view::controls::ControlSet, } @@ -298,6 +300,7 @@ impl Default for WhiteboardView { palette_rect: None, custom_color: String::new(), block_canvas_until_release: false, + pending_delete: false, property_controls: crate::views::whiteboard_view::controls::ControlSet::default(), } } diff --git a/crates/ui-egui/src/widgets/confirm.rs b/crates/ui-egui/src/widgets/confirm.rs new file mode 100644 index 0000000..921e1bb --- /dev/null +++ b/crates/ui-egui/src/widgets/confirm.rs @@ -0,0 +1,33 @@ +//! The shared "are you sure?" question of the destructive actions: one dialog, one wording. +//! +//! The app has exactly one modal (see [`crate::dialog`]); this module only fills it in with the +//! shared question, the thing that is about to go and the danger tone. + +use egui::Context; + +use crate::{ + dialog::{Answer, Dialog, Tone}, + theme::Tokens, +}; + +/// Asks the shared question about `subject` (what is about to be deleted) and returns the answer, +/// if the user gave one this frame. `Enter` confirms, `Escape` cancels. +#[must_use] +pub fn ask(ctx: &Context, tokens: &Tokens, id: &'static str, subject: Option<&str>) -> Option { + crate::dialog::show( + ctx, + tokens, + Dialog { + id, + title: crate::text::confirm::delete_question(), + subject, + body: None, + input: None, + confirm: crate::text::confirm::delete(), + extra: None, + cancel: crate::text::confirm::cancel(), + tone: Tone::Danger, + enabled: true, + }, + ) +} diff --git a/crates/ui-egui/src/widgets/context_menu.rs b/crates/ui-egui/src/widgets/context_menu.rs index c9fb53b..abb3d20 100644 --- a/crates/ui-egui/src/widgets/context_menu.rs +++ b/crates/ui-egui/src/widgets/context_menu.rs @@ -1,11 +1,11 @@ //! Shared popup rows and file/tab actions; painting never mutates the vault. use crate::theme::{Tokens, color}; -use egui::{Align2, FontId, Rect, Sense, Ui, Vec2}; +use egui::Ui; /// Height of each context-menu row. -pub const ROW_HEIGHT: f32 = 32.0; -const WIDTH: f32 = 216.0; -const ICON_SIZE: f32 = 16.0; +pub const ROW_HEIGHT: f32 = crate::menu_keys::ROW_HEIGHT; +/// Wide enough for the longest label plus its keycap. +const WIDTH: f32 = 236.0; const PADDING: f32 = 8.0; const NEW_NOTE: &str = "New note"; @@ -128,7 +128,7 @@ pub(crate) fn tree_menu(ui: &mut Ui, tokens: &Tokens, path: &str, folder: bool, choices.push((MenuKind::MergeInto, MERGE_INTO, "arrow-down")); choices.push((MenuKind::Bookmark, if bookmarked { UNBOOKMARK } else { BOOKMARK }, "star")); } - choices.extend([(MenuKind::Delete, DELETE, "x"), (MenuKind::CopyPath, COPY_PATH, "clipboard")]); + choices.extend([(MenuKind::Delete, DELETE, "trash-2"), (MenuKind::CopyPath, COPY_PATH, "clipboard")]); if !folder { choices.extend([ (MenuKind::CopyLink, COPY_LINK, "link"), @@ -137,7 +137,15 @@ pub(crate) fn tree_menu(ui: &mut Ui, tokens: &Tokens, path: &str, folder: bool, ]); } } - menu(ui, tokens, path, folder, &choices, writable) + menu( + ui, + tokens, + if folder || path.is_empty() { crate::text::menu_keys::folder() } else { crate::text::menu_keys::file() }, + path, + folder, + &choices, + writable, + ) } /// Draws tab choices; virtual tabs only offer close operations. @@ -161,12 +169,33 @@ pub(crate) fn tab_menu(ui: &mut Ui, tokens: &Tokens, path: &str, marks: (bool, b (MenuKind::Bookmark, if bookmarked { UNBOOKMARK } else { BOOKMARK }, "star"), ]); } - menu(ui, tokens, path, false, &choices, writable) + menu(ui, tokens, crate::text::menu_keys::tab(), path, false, &choices, writable) } -fn menu(ui: &mut Ui, tokens: &Tokens, path: &str, folder: bool, choices: &[(MenuKind, &str, &str)], writable: bool) -> Option { +/// Conventional keys; the rest follow `menu_keys::assign`. Delete takes X so D stays with Duplicate. +fn preferred_key(kind: MenuKind) -> Option { + Some(match kind { + MenuKind::Open | MenuKind::OpenFolder => 'o', + MenuKind::OpenNewTab => 't', + MenuKind::Rename => 'r', + MenuKind::MoveTo => 'm', + MenuKind::Duplicate => 'd', + MenuKind::Bookmark => 'b', + MenuKind::Delete => 'x', + MenuKind::Close | MenuKind::CopyPath => 'c', + MenuKind::Pin | MenuKind::PinTab => 'p', + MenuKind::NewNote => 'n', + MenuKind::NewFolder => 'f', + _ => return None, + }) +} + +fn menu(ui: &mut Ui, tokens: &Tokens, name: &str, path: &str, folder: bool, choices: &[(MenuKind, &str, &str)], writable: bool) -> Option { ui.set_width(WIDTH); ui.spacing_mut().item_spacing.y = 0.0; + crate::menu_keys::name_menu(ui, name); + let items: Vec<(&str, Option)> = choices.iter().map(|(kind, label, _)| (*label, preferred_key(*kind))).collect(); + let letters = crate::menu_keys::assign(&items, crate::menu_keys::reserved(ui.ctx())); let mut action = None; for (index, (kind, label, icon)) in choices.iter().enumerate() { let enabled = writable @@ -181,8 +210,10 @@ fn menu(ui: &mut Ui, tokens: &Tokens, path: &str, folder: bool, choices: &[(Menu | MenuKind::MoveTo | MenuKind::Bookmark ); + let spec = + crate::menu_keys::Row { letter: letters.get(index).copied().flatten(), ..crate::menu_keys::Row::new(label, icon, *kind == MenuKind::Delete) }; let clicked = crate::motion::pop_in(ui, ("context-row", path, index), index, |ui| { - ui.add_enabled_ui(enabled, |ui| menu_row(ui, tokens, label, icon, *kind == MenuKind::Delete)).inner + ui.add_enabled_ui(enabled, |ui| crate::menu_keys::row(ui, tokens, &spec)).inner }); if clicked { action = Some(MenuAction { kind: *kind, path: path.to_owned(), folder }); @@ -192,16 +223,7 @@ fn menu(ui: &mut Ui, tokens: &Tokens, path: &str, folder: bool, choices: &[(Menu action } -/// One row of a popup menu: icon, label, hover glow; `true` when clicked. +/// One row of a popup menu: icon, label, keycap, hover glow; `true` when chosen. pub(crate) fn menu_row(ui: &mut Ui, tokens: &Tokens, label: &str, icon: &str, danger: bool) -> bool { - let (rect, hit) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::click()); - hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), label)); - let c = &tokens.theme.colors; - let tint = color(if danger { c.danger } else { c.text }); - let glow = crate::motion::hover(ui.ctx(), ui.id().with(label), &hit); - ui.painter().rect_filled(rect, tokens.radius_medium(), color(c.selection).gamma_multiply(glow)); - let slot = Rect::from_center_size(egui::pos2(rect.left() + PADDING + ICON_SIZE / 2.0, rect.center().y), Vec2::splat(ICON_SIZE)); - crate::lottie_icon::paint_hover(ui, hit.id, icon, slot, tint, ui.rect_contains_pointer(rect)); - ui.painter().text(egui::pos2(slot.right() + PADDING, rect.center().y), Align2::LEFT_CENTER, label, FontId::proportional(crate::style::TEXT_BODY), tint); - hit.clicked() + crate::menu_keys::row(ui, tokens, &crate::menu_keys::Row::new(label, icon, danger)) } diff --git a/crates/ui-egui/src/widgets/menu_keys/assign.rs b/crates/ui-egui/src/widgets/menu_keys/assign.rs new file mode 100644 index 0000000..dffeef7 --- /dev/null +++ b/crates/ui-egui/src/widgets/menu_keys/assign.rs @@ -0,0 +1,135 @@ +//! Pure mnemonic assignment: which key runs which menu row. No egui state, so it unit-tests directly. + +use egui::Key; + +/// Letters Vim motions use inside a menu (`j k` move, `h` closes, `l` opens, `g`/`G` jump); never assigned in Vim mode. +pub const VIM_RESERVED: [char; 5] = ['j', 'k', 'h', 'l', 'g']; + +/// Digits tried last, so a label without Latin letters still gets a key. +const DIGITS: &str = "1234567890"; + +/// Candidate keys for `label` in preference order: first letter of each word, then every other letter, then digits. +fn candidates(label: &str) -> impl Iterator + '_ { + let firsts = label.split(|c: char| !c.is_alphanumeric()).filter_map(|word| word.chars().next()); + firsts.chain(label.chars()).filter(char::is_ascii_alphabetic).map(|c| c.to_ascii_lowercase()).chain(DIGITS.chars()) +} + +/// The first candidate for `label` that is neither in `used` nor in `reserved`; `None` when all 36 keys are taken. +#[must_use] +pub fn pick(label: &str, used: &[char], reserved: &[char]) -> Option { + candidates(label).find(|c| !used.contains(c) && !reserved.contains(c)) +} + +/// Letters for a whole menu. `items` is `(label, preferred)`: a preferred key is honoured unless reserved or already taken, +/// then the rest are picked in order. Output has one entry per item, no duplicates, same input gives same output. +#[must_use] +pub fn assign(items: &[(&str, Option)], reserved: &[char]) -> Vec> { + let mut out: Vec> = vec![None; items.len()]; + let mut used: Vec = Vec::new(); + for (slot, (_, preferred)) in out.iter_mut().zip(items) { + let Some(key) = preferred.map(|c| c.to_ascii_lowercase()) else { continue }; + if key.is_ascii_alphanumeric() && !reserved.contains(&key) && !used.contains(&key) { + used.push(key); + *slot = Some(key); + } + } + for (slot, (label, _)) in out.iter_mut().zip(items) { + if slot.is_none() { + *slot = pick(label, &used, reserved); + used.extend(*slot); + } + } + out +} + +/// The egui key for a mnemonic letter or digit. +#[must_use] +pub fn key_of(letter: char) -> Option { + Key::from_name(&letter.to_ascii_uppercase().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + const FILE_MENU: [&str; 12] = [ + "Open", + "Open in new tab", + "Rename", + "Move to…", + "Duplicate", + "Merge into…", + "Bookmark", + "Delete", + "Copy path", + "Copy link", + "Copy Obsidian URL", + "Open in default app", + ]; + + fn plain<'a>(labels: &[&'a str]) -> Vec<(&'a str, Option)> { + labels.iter().map(|label| (*label, None)).collect() + } + + #[test] + fn first_letter_wins_then_later_words_then_any_letter() { + let letters = assign(&plain(&["Close", "Close others", "Close all", "Close"]), &[]); + assert_eq!(letters, vec![Some('c'), Some('o'), Some('a'), Some('l')]); + } + + #[test] + fn no_duplicates_and_deterministic() { + let items = plain(&FILE_MENU); + let first = assign(&items, &[]); + assert_eq!(first, assign(&items, &[])); + let mut keys: Vec = first.iter().flatten().copied().collect(); + assert_eq!(keys.len(), FILE_MENU.len(), "every row gets a key"); + keys.sort_unstable(); + keys.dedup(); + assert_eq!(keys.len(), FILE_MENU.len(), "no key twice"); + } + + #[test] + fn vim_mode_never_hands_out_motion_letters() { + let letters = assign(&plain(&["Hello", "Link", "Gamma", "Kilo", "Jump"]), &VIM_RESERVED); + for key in letters.iter().flatten() { + assert!(!VIM_RESERVED.contains(key), "{key} is a Vim motion"); + } + assert_eq!(letters.iter().flatten().count(), 5); + } + + #[test] + fn preferred_keys_win_unless_reserved_or_taken() { + let items = [("Open", Some('o')), ("Open in new tab", Some('t')), ("Link", Some('l')), ("Delete", Some('o'))]; + assert_eq!(assign(&items, &[]), vec![Some('o'), Some('t'), Some('l'), Some('d')]); + let vim = assign(&items, &VIM_RESERVED); + assert_eq!(vim.get(2), Some(&Some('i')), "reserved l falls back to the label's other letters"); + } + + #[test] + fn ellipsis_and_punctuation_are_word_breaks() { + assert_eq!(pick("…Move to", &[], &[]), Some('m')); + assert_eq!(pick("Merge into…", &['m'], &[]), Some('i')); + } + + #[test] + fn non_latin_labels_fall_back_to_digits() { + let letters = assign(&plain(&["重命名", "删除", "移动"]), &[]); + assert_eq!(letters, vec![Some('1'), Some('2'), Some('3')]); + } + + #[test] + fn runs_out_after_thirty_six_keys() { + let labels = vec!["abcdefghijklmnopqrstuvwxyz"; 40]; + let letters = assign(&plain(&labels), &[]); + assert_eq!(letters.iter().flatten().count(), 36); + assert_eq!(letters.last(), Some(&None)); + } + + #[test] + fn every_assignable_key_has_an_egui_key() { + for c in ('a'..='z').chain('0'..='9') { + assert!(key_of(c).is_some(), "{c}"); + } + } +} diff --git a/crates/ui-egui/src/widgets/menu_keys/keycap.rs b/crates/ui-egui/src/widgets/menu_keys/keycap.rs new file mode 100644 index 0000000..9190aab --- /dev/null +++ b/crates/ui-egui/src/widgets/menu_keys/keycap.rs @@ -0,0 +1,30 @@ +//! The keycap chip drawn at the right of a menu row. + +use crate::theme::{Tokens, color}; +use egui::{Align2, FontId, Pos2, Rect, Stroke, StrokeKind, Ui, Vec2}; + +/// Chip height in points. +const HEIGHT: f32 = 20.0; +/// Chip width for a single character, in points. +const MIN_WIDTH: f32 = 20.0; +/// Horizontal text padding inside a wider chip. +const PAD: f32 = 5.0; +/// Fill alpha of an active chip's accent wash. +const ACTIVE_WASH: f32 = 0.16; +/// Fill mix toward the text colour so the chip reads on a `surface_2` menu. +const LIFT: f32 = 0.08; + +/// Paints `text` as a keycap whose right edge is `right`; returns its left edge. Muted by default, accent when `active`. +pub(super) fn paint(ui: &Ui, tokens: &Tokens, right: f32, center_y: f32, text: &str, active: bool) -> f32 { + let c = &tokens.theme.colors; + let ink = color(if active { c.accent } else { c.text_muted }); + let galley = ui.painter().layout_no_wrap(text.to_owned(), FontId::monospace(crate::style::TEXT_TINY), ink); + let size = Vec2::new((galley.size().x + 2.0 * PAD).max(MIN_WIDTH), HEIGHT); + let rect = Rect::from_min_size(Pos2::new(right - size.x, center_y - size.y / 2.0), size); + let base = color(c.surface_2).lerp_to_gamma(color(c.text), LIFT); + let fill = if active { color(c.accent).gamma_multiply(ACTIVE_WASH) } else { base }; + let edge = if active { color(c.accent) } else { color(c.border) }; + ui.painter().rect(rect, tokens.radius_small(), fill, Stroke::new(1.0, edge), StrokeKind::Inside); + ui.painter().text(rect.center(), Align2::CENTER_CENTER, text, FontId::monospace(crate::style::TEXT_TINY), ink); + rect.left() +} diff --git a/crates/ui-egui/src/widgets/menu_keys/mod.rs b/crates/ui-egui/src/widgets/menu_keys/mod.rs new file mode 100644 index 0000000..2db5672 --- /dev/null +++ b/crates/ui-egui/src/widgets/menu_keys/mod.rs @@ -0,0 +1,197 @@ +//! Menu keys: a mnemonic keycap on every menu row, keyboard focus inside an open menu, Vim motions and AccessKit roles. +//! +//! Every popup row goes through [`row`]. Rows of one popup share its layer id; the state kept per layer is the rows drawn +//! last pass, so keys are resolved at the first row of a pass (before any row paints) and act on the same frame. +//! Esc itself is closed by the egui popup; we only hand focus back to the control that opened the menu. + +mod assign; +mod keycap; +mod nav; + +use crate::theme::{Tokens, color}; +pub use assign::{VIM_RESERVED, assign, key_of, pick}; +use egui::{Align2, Context, FontId, Id, Rect, Sense, Ui, Vec2}; + +/// Height of each menu row. +pub const ROW_HEIGHT: f32 = 32.0; +const ICON_SIZE: f32 = 16.0; +const PADDING: f32 = 8.0; +/// Gap between the keycap and the shortcut text after it. +const CHIP_GAP: f32 = 6.0; +/// Stroke width of the keyboard focus ring. +const FOCUS_RING: f32 = 1.5; + +fn vim_flag() -> Id { + Id::new("notescraft-menu-keys-vim") +} + +/// Tells menus whether Vim mode is on (the app sets it every frame from `editor.vim`). +pub fn set_vim(ctx: &Context, enabled: bool) { + ctx.data_mut(|d| d.insert_temp(vim_flag(), enabled)); +} + +/// Whether Vim motions are active in menus. +#[must_use] +pub fn vim(ctx: &Context) -> bool { + ctx.data(|d| d.get_temp::(vim_flag())).unwrap_or(false) +} + +/// Letters that must not be assigned while Vim mode is `vim`. +#[must_use] +pub fn reserved_for(vim: bool) -> &'static [char] { + if vim { &VIM_RESERVED } else { &[] } +} + +/// Letters that must not be assigned right now. +#[must_use] +pub fn reserved(ctx: &Context) -> &'static [char] { + reserved_for(vim(ctx)) +} + +/// What one menu row needs to draw. +#[derive(Debug, Clone, Copy)] +pub struct Row<'a> { + /// Visible, localised label. + pub label: &'a str, + /// Icon name from the icon catalogue. + pub icon: &'a str, + /// Destructive tone. + pub danger: bool, + /// Key chosen by [`assign`] for the whole menu; `None` picks the next free letter of this label. + pub letter: Option, + /// A real accelerator such as `Cmd+N`, shown muted after the keycap. + pub shortcut: Option<&'a str>, +} + +impl<'a> Row<'a> { + /// A plain row with an automatic key and no accelerator. + #[must_use] + pub fn new(label: &'a str, icon: &'a str, danger: bool) -> Self { + Self { label, icon, danger, letter: None, shortcut: None } + } +} + +/// A row seen in a pass. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Seen { + pub(crate) id: Id, + pub(crate) enabled: bool, + pub(crate) letter: Option, +} + +/// Per-menu state, keyed by the popup's layer id. +#[derive(Debug, Clone, Default)] +pub(crate) struct State { + /// Pass number + 1 of the last pass that drew a row; 0 for never. + pub(crate) seen: u64, + pub(crate) prev: Vec, + pub(crate) cur: Vec, + pub(crate) used: Vec, + /// Row a mnemonic or `l` just activated. + pub(crate) activate: Option, + /// Widget that had focus when the menu opened; Esc gives it back. + pub(crate) opener: Option, + /// Opened this pass: the first enabled row takes focus. + pub(crate) fresh: bool, + /// `g` was pressed and waits for a second one. + pub(crate) g: bool, + /// Close the menu (Vim `h`). + pub(crate) close: bool, +} + +pub(crate) fn with(ctx: &Context, key: Id, f: impl FnOnce(&mut State) -> R) -> R { + ctx.data_mut(|d| f(d.get_temp_mut_or_default::(key))) +} + +/// Gives the menu an AccessKit `Menu` node named `name`; call before the first [`row`]. +pub fn name_menu(ui: &Ui, name: &str) { + ui.ctx().accesskit_node_builder(ui.unique_id(), |node| { + node.set_role(egui::accesskit::Role::Menu); + node.set_label(name); + }); +} + +/// One row of a popup menu: icon, label, keycap, optional accelerator. `true` when chosen by click, Enter or its key. +pub fn row(ui: &mut Ui, tokens: &Tokens, spec: &Row) -> bool { + let ctx = ui.ctx().clone(); + let key = ui.layer_id().id; + nav::begin(&ctx, key); + if with(&ctx, key, |st| std::mem::take(&mut st.close)) { + ui.close(); + } + let (rect, hit) = ui.allocate_exact_size(Vec2::new(ui.available_width(), ROW_HEIGHT), Sense::click()); + let enabled = ui.is_enabled(); + let reserved = reserved(&ctx); + let (letter, activated, take_focus) = with(&ctx, key, |st| { + let letter = spec.letter.or_else(|| pick(spec.label, &st.used, reserved)); + st.used.extend(letter); + st.cur.push(Seen { id: hit.id, enabled, letter }); + let activated = enabled && st.activate == Some(hit.id); + if activated { + st.activate = None; + } + let take_focus = enabled && std::mem::take(&mut st.fresh); + (letter, activated, take_focus) + }); + if take_focus { + hit.request_focus(); + } + if hit.has_focus() { + ctx.memory_mut(|m| m.set_focus_lock_filter(hit.id, nav::FOCUS_FILTER)); + } + let cap = letter.map(|c| c.to_ascii_uppercase().to_string()); + describe(&hit, spec, enabled, cap.as_deref()); + paint(ui, tokens, rect, &hit, spec, cap.as_deref()); + hit.clicked() || activated +} + +/// `AccessKit`: role `MenuItem`, label, the mnemonic as keyboard shortcut and description, disabled state. +fn describe(hit: &egui::Response, spec: &Row, enabled: bool, cap: Option<&str>) { + hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, spec.label)); + let description = + [cap.map(|c| format!("{} {c}", crate::text::menu_keys::key())), spec.shortcut.map(str::to_owned)].into_iter().flatten().collect::>().join(", "); + hit.ctx.accesskit_node_builder(hit.id, |node| { + node.set_role(egui::accesskit::Role::MenuItem); + if let Some(c) = cap { + node.set_keyboard_shortcut(c); + } + if !description.is_empty() { + node.set_description(description); + } + }); +} + +fn paint(ui: &Ui, tokens: &Tokens, rect: Rect, hit: &egui::Response, spec: &Row, cap: Option<&str>) { + let c = &tokens.theme.colors; + let tint = color(if spec.danger { c.danger } else { c.text }); + let focused = hit.has_focus(); + let glow = crate::motion::hover(ui.ctx(), hit.id, hit).max(f32::from(u8::from(focused))); + ui.painter().rect_filled(rect, tokens.radius_medium(), color(c.selection).gamma_multiply(glow)); + if focused { + ui.painter().rect_stroke( + rect.shrink(FOCUS_RING / 2.0), + tokens.radius_medium(), + egui::Stroke::new(FOCUS_RING, color(c.accent)), + egui::StrokeKind::Inside, + ); + } + let slot = Rect::from_center_size(egui::pos2(rect.left() + PADDING + ICON_SIZE / 2.0, rect.center().y), Vec2::splat(ICON_SIZE)); + crate::lottie_icon::paint_hover(ui, hit.id, spec.icon, slot, tint, ui.rect_contains_pointer(rect)); + ui.painter().text( + egui::pos2(slot.right() + PADDING, rect.center().y), + Align2::LEFT_CENTER, + spec.label, + FontId::proportional(crate::style::TEXT_BODY), + tint, + ); + let mut right = rect.right() - PADDING; + if let Some(shortcut) = spec.shortcut { + let galley = ui.painter().layout_no_wrap(shortcut.to_owned(), FontId::proportional(crate::style::TEXT_TINY), color(c.text_muted)); + right -= galley.size().x; + ui.painter().galley(egui::pos2(right, rect.center().y - galley.size().y / 2.0), galley, color(c.text_muted)); + right -= CHIP_GAP; + } + if let Some(cap) = cap { + keycap::paint(ui, tokens, right, rect.center().y, cap, focused || hit.hovered()); + } +} diff --git a/crates/ui-egui/src/widgets/menu_keys/nav.rs b/crates/ui-egui/src/widgets/menu_keys/nav.rs new file mode 100644 index 0000000..8e41afe --- /dev/null +++ b/crates/ui-egui/src/widgets/menu_keys/nav.rs @@ -0,0 +1,132 @@ +//! Keys inside an open menu: Tab/arrows/Home/End, Vim motions and mnemonics. Runs once per pass, before the first row paints. + +use super::{State, assign::key_of, vim, with}; +use egui::{Context, Event, EventFilter, Id, InputState, Key, Modifiers}; + +/// The focused row keeps Tab and the arrows, so focus cannot leave the menu. +pub(super) const FOCUS_FILTER: EventFilter = EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: false }; + +#[derive(Clone, Copy)] +enum Step { + Next, + Prev, + First, + Last, + /// Activate the focused row (Vim `l`; submenus would open here). + Open, + /// Close the menu (Vim `h`). + Close, +} + +/// Starts a pass for the menu `key`: detects a fresh open and resolves keys against last pass's rows. +pub(super) fn begin(ctx: &Context, key: Id) { + let stamp = ctx.cumulative_pass_nr().saturating_add(1); + let mut state = with(ctx, key, |st| st.clone()); + if state.seen == stamp { + return; + } + if state.seen == 0 || state.seen.saturating_add(1) < stamp { + state = State { opener: ctx.memory(egui::Memory::focused), fresh: true, ..State::default() }; + } else { + state.prev = std::mem::take(&mut state.cur); + state.used.clear(); + keys(ctx, &mut state); + } + state.seen = stamp; + with(ctx, key, |st| *st = state); +} + +fn keys(ctx: &Context, st: &mut State) { + let focused = ctx.memory(egui::Memory::focused); + let at = focused.and_then(|f| st.prev.iter().position(|row| row.id == f)); + if focused.is_some() && at.is_none() { + return; // focus is on something else (a text field): not our keys + } + if ctx.input(|i| i.key_pressed(Key::Escape)) { + give_back(ctx, st.opener, focused); + return; + } + let vim = vim(ctx); + let step = ctx.input_mut(|i| read_step(i, vim, &mut st.g)); + let enabled: Vec = st.prev.iter().filter(|row| row.enabled).map(|row| row.id).collect(); + let current = at.and_then(|a| st.prev.get(a)).and_then(|row| enabled.iter().position(|id| *id == row.id)); + let Some(step) = step else { + let pressed = ctx.input_mut(|i| st.prev.iter().find(|row| row.letter.and_then(key_of).is_some_and(|k| i.consume_key(Modifiers::NONE, k))).copied()); + st.activate = pressed.filter(|row| row.enabled).map(|row| row.id); + return; + }; + let count = enabled.len(); + let target = match step { + Step::Next => current.map_or(0, |c| c.saturating_add(1)), + Step::Prev => current.map_or(count.saturating_sub(1), |c| c.checked_sub(1).unwrap_or(count.saturating_sub(1))), + Step::First => 0, + Step::Last => count.saturating_sub(1), + Step::Open => { + st.activate = current.and_then(|c| enabled.get(c)).copied(); + return; + } + Step::Close => { + st.close = true; + give_back(ctx, st.opener, focused); + return; + } + }; + if let Some(id) = enabled.get(target.checked_rem(count).unwrap_or(0)) { + ctx.memory_mut(|m| m.request_focus(*id)); + ctx.request_repaint(); + } +} + +fn read_step(i: &mut InputState, vim: bool, g: &mut bool) -> Option { + let none = Modifiers::NONE; + // A pending g survives idle frames and dies on any other key. + if i.events.iter().any(|e| matches!(e, Event::Key { pressed: true, key, .. } if *key != Key::G)) { + *g = false; + } + let had_g = *g; + let mut step = None; + if i.consume_key(Modifiers::SHIFT, Key::Tab) { + step = Some(Step::Prev); + } else if i.consume_key(none, Key::Tab) || i.consume_key(none, Key::ArrowDown) || i.consume_key(Modifiers::CTRL, Key::N) { + step = Some(Step::Next); + } else if i.consume_key(none, Key::ArrowUp) || i.consume_key(Modifiers::CTRL, Key::P) { + step = Some(Step::Prev); + } else if i.consume_key(none, Key::Home) { + step = Some(Step::First); + } else if i.consume_key(none, Key::End) { + step = Some(Step::Last); + } else if vim { + if i.consume_key(Modifiers::SHIFT, Key::G) { + step = Some(Step::Last); + } else if i.consume_key(none, Key::G) { + if had_g { + *g = false; + step = Some(Step::First); + } else { + *g = true; + return None; + } + } else if i.consume_key(none, Key::J) { + step = Some(Step::Next); + } else if i.consume_key(none, Key::K) { + step = Some(Step::Prev); + } else if i.consume_key(none, Key::L) { + step = Some(Step::Open); + } else if i.consume_key(none, Key::H) { + step = Some(Step::Close); + } + } + if step.is_some() { + *g = false; + } + step +} + +/// Esc: focus goes back to the control that opened the menu, or is dropped when there was none. +fn give_back(ctx: &Context, opener: Option, focused: Option) { + ctx.memory_mut(|m| match (opener, focused) { + (Some(opener), _) => m.request_focus(opener), + (None, Some(focused)) => m.surrender_focus(focused), + (None, None) => {} + }); +} diff --git a/crates/ui-egui/src/widgets/mod.rs b/crates/ui-egui/src/widgets/mod.rs index 6d1b8f5..e1878c6 100644 --- a/crates/ui-egui/src/widgets/mod.rs +++ b/crates/ui-egui/src/widgets/mod.rs @@ -4,9 +4,11 @@ pub mod autocomplete; pub mod block_menu; pub mod bottom_sheet; pub mod color_menu; +pub mod confirm; pub mod context_menu; pub mod dialog; pub mod export_dialog; +pub mod menu_keys; pub mod palette; pub mod previews; pub mod slash_popup; diff --git a/crates/ui-egui/src/widgets/palette.rs b/crates/ui-egui/src/widgets/palette.rs index 89e65db..4bab9b5 100644 --- a/crates/ui-egui/src/widgets/palette.rs +++ b/crates/ui-egui/src/widgets/palette.rs @@ -158,7 +158,7 @@ pub fn move_selection(selected: usize, delta: isize, count: usize) -> usize { /// Draws the palette over `ctx` and returns the chosen command id, if any. /// -/// Handles `Esc` (close), `Up`/`Down` (move selection), `Enter` (choose) and +/// Handles `Esc` (close), `Up`/`Down` and `Ctrl-n`/`Ctrl-p` (move selection), `Enter` (choose) and /// clicks on rows. The state is owned by the caller so the selection /// survives frames. pub fn show_palette(ctx: &Context, tokens: &Tokens, state: &mut PaletteState, commands: &[CommandInfo]) -> Option { @@ -171,8 +171,14 @@ pub fn show_palette(ctx: &Context, tokens: &Tokens, state: &mut PaletteState, co } return None; } - let (escape, down, up, enter) = - ctx.input(|i| (i.key_pressed(Key::Escape), i.key_pressed(Key::ArrowDown), i.key_pressed(Key::ArrowUp), i.key_pressed(Key::Enter))); + // Ctrl-n/p walk the results like the arrows; in Vim mode Ctrl-j/k do too (plain j/k are typed into the field). + let vim = crate::menu_keys::vim(ctx); + let (escape, down, up, enter) = ctx.input_mut(|i| { + let ctrl = egui::Modifiers::CTRL; + let down = i.key_pressed(Key::ArrowDown) | i.consume_key(ctrl, Key::N) | (vim && i.consume_key(ctrl, Key::J)); + let up = i.key_pressed(Key::ArrowUp) | i.consume_key(ctrl, Key::P) | (vim && i.consume_key(ctrl, Key::K)); + (i.key_pressed(Key::Escape), down, up, i.key_pressed(Key::Enter)) + }); if escape { state.open = false; return None; diff --git a/crates/ui-egui/src/widgets/tabs.rs b/crates/ui-egui/src/widgets/tabs.rs index 317c0dc..644718e 100644 --- a/crates/ui-egui/src/widgets/tabs.rs +++ b/crates/ui-egui/src/widgets/tabs.rs @@ -14,8 +14,9 @@ use paint::paint_tab; /// Height of the tab strip, in points. pub const TAB_BAR_HEIGHT: f32 = 44.0; -/// Maximum number of open tabs. -pub const MAX_TABS: usize = 32; +/// Most tabs a persisted workspace list may hold. Open tabs are unbounded in memory (they are +/// cheap while cold); this only stops a corrupt state file from allocating without limit. +pub const MAX_PERSISTED_TABS: usize = 5_000; /// Narrow tab minimum width for the fitting algorithm, in points. const TAB_MIN_WIDTH: f32 = 96.0; /// Width reserved for the `×` close glyph, in points. @@ -35,8 +36,8 @@ pub enum OpenOutcome { Opened, /// An existing tab became active. Activated, - /// The list is full; the path was not opened. - Full, + /// The path is empty; nothing opened. + Empty, } /// The open-tab list and the active index. @@ -82,19 +83,16 @@ impl Tabs { self.open.iter().any(|p| p == path) } - /// Opens `path` as a tab or activates the existing one. At [`MAX_TABS`] the call refuses - /// (`OpenOutcome::Full`) instead of evicting tabs silently. + /// Opens `path` as a tab or activates the existing one. There is no cap: a tab that is not + /// in use is hibernated by the app, so open tabs cost almost nothing. pub fn open(&mut self, path: &str) -> OpenOutcome { if path.is_empty() { - return OpenOutcome::Full; + return OpenOutcome::Empty; } if let Some(index) = self.open.iter().position(|p| p == path) { self.activate(index); return OpenOutcome::Activated; } - if self.open.len() >= MAX_TABS { - return OpenOutcome::Full; - } self.open.push(path.to_owned()); self.active = self.open.len().saturating_sub(1); OpenOutcome::Opened @@ -190,15 +188,18 @@ pub fn show(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, dirty: &[String]) -> Tabs pub fn show_with_bookmarks(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, dirty: &[String], bookmarks: &[String], writable: bool) -> TabsResponse { let mut response = TabsResponse::default(); let (bar, _id) = ui.allocate_exact_size(Vec2::new(ui.available_width(), TAB_BAR_HEIGHT), Sense::hover()); + let window = layout::window(tabs.open.len(), tabs.active); let natural: Vec = tabs .open + .get(window.clone()) + .unwrap_or_default() .iter() .map(|path| { let galley = ui.painter().layout_no_wrap(label_of(path).to_owned(), egui::FontId::proportional(14.0), color(tokens.theme.colors.text)); galley.size().x + TAB_PADDING * 2.0 + TAB_CLOSE_WIDTH + 24.0 }) .collect(); - let layout = layout::arrange(bar, &natural, tabs.active); + let layout = layout::arrange(bar, &natural, window, tabs.open.len(), tabs.active); let mut closed = None; let ctx = ui.ctx().clone(); // The active-tab pill glides to its tab instead of jumping. @@ -283,170 +284,4 @@ fn drag_over(ui: &Ui, tokens: &Tokens, tabs: &Tabs, layout: &layout::Layout, pat } #[cfg(test)] -mod tests { - use super::*; - use crate::testsupport; - - fn opened(a: &str) -> OpenOutcome { - let mut tabs = Tabs::new(); - let _ = tabs.open(a); - tabs.open(a) - } - - #[test] - fn open_dedupe_and_activate() { - let mut tabs = Tabs::new(); - assert_eq!(tabs.open("notes/a.md"), OpenOutcome::Opened); - assert_eq!(tabs.open("notes/b.md"), OpenOutcome::Opened); - assert_eq!(tabs.active, 1); - assert_eq!(tabs.open("notes/a.md"), OpenOutcome::Activated); - assert_eq!(tabs.active, 0); - assert_eq!(tabs.active_path(), Some("notes/a.md")); - assert_eq!(opened("notes/a.md"), OpenOutcome::Activated); - } - - #[test] - fn close_repairs_active() { - let mut tabs = Tabs::new(); - assert_eq!(tabs.open("a.md"), OpenOutcome::Opened); - assert_eq!(tabs.open("b.md"), OpenOutcome::Opened); - assert_eq!(tabs.open("c.md"), OpenOutcome::Opened); - assert_eq!(tabs.close(1), Some("b.md".to_owned())); - assert_eq!(tabs.open, ["a.md".to_owned(), "c.md".to_owned()]); - assert_eq!(tabs.active, 1); // stayed on the tab that took b's place - assert_eq!(tabs.close(99), None); - assert_eq!(tabs.close(0), Some("a.md".to_owned())); - assert_eq!(tabs.active, 0); - assert_eq!(tabs.close(0), Some("c.md".to_owned())); - assert_eq!(tabs.active_path(), None); - } - - #[test] - fn cap_is_enforced_without_eviction() { - let mut tabs = Tabs::new(); - for i in 0..MAX_TABS { - assert_eq!(tabs.open(&format!("n{i}.md")), OpenOutcome::Opened); - } - assert_eq!(tabs.open.len(), MAX_TABS); - assert_eq!(tabs.open("new.md"), OpenOutcome::Full); - assert_eq!(tabs.open.len(), MAX_TABS); - assert_eq!(tabs.open("n0.md"), OpenOutcome::Activated); // existing still works - assert_eq!(tabs.open(""), OpenOutcome::Full); // empty path refused - } - - #[test] - fn pinned_tabs_stay_in_front_and_move_within_their_group() { - let mut tabs = Tabs::new(); - for name in ["a.md", "b.md", "c.md", "d.md"] { - let _ = tabs.open(name); - } - tabs.toggle_pin("c.md"); - assert_eq!(tabs.open, ["c.md", "a.md", "b.md", "d.md"]); - assert_eq!(tabs.active_path(), Some("d.md"), "the active tab stays active"); - tabs.toggle_pin("d.md"); - assert_eq!(tabs.open, ["c.md", "d.md", "a.md", "b.md"]); - tabs.move_tab("b.md", 0); - assert_eq!(tabs.open, ["c.md", "d.md", "b.md", "a.md"], "an unpinned tab cannot enter the pinned group"); - tabs.move_tab("c.md", 3); - assert_eq!(tabs.open, ["d.md", "c.md", "b.md", "a.md"], "a pinned tab cannot leave it"); - tabs.toggle_pin("c.md"); - assert_eq!(tabs.open, ["d.md", "c.md", "b.md", "a.md"]); - assert!(!tabs.is_pinned("c.md")); - } - - #[test] - fn labels_strip_folders() { - assert_eq!(label_of("notes/deep/a.md"), "a"); - assert_eq!(label_of("a.md"), "a"); - } - - #[test] - fn renders_two_frames_without_panic_and_paints() { - let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; - let tokens = Tokens::new(theme); - let mut tabs = Tabs::new(); - let _ = tabs.open("notes/alpha.md"); - let _ = tabs.open("notes/deep/beta.md"); - let dirty = vec![String::from("notes/alpha.md")]; - let shapes = testsupport::run_frames_ui(1440.0, 900.0, |root| { - let _ = show(root, &tokens, &tabs, &dirty); - }); - assert!(shapes > 0, "tab bar painted no shapes"); - // Empty list still reserves the 44 pt strip and paints nothing harmful. - let empty = Tabs::new(); - let _ = testsupport::run_frames_ui(800.0, 600.0, |root| { - let response = show(root, &tokens, &empty, &[]); - assert_eq!(response, TabsResponse::default()); - }); - } - - type Texts = Vec<(String, Pos2)>; - - /// Runs one frame of a 620 pt bar; returns the painted texts with positions and the response. - fn overflow_frame(ctx: &egui::Context, tokens: &Tokens, tabs: &Tabs, time: f64, events: Vec) -> (Texts, TabsResponse) { - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(620.0, 600.0))), - time: Some(time), - events, - ..egui::RawInput::default() - }; - let mut response = TabsResponse::default(); - let mut out = ctx.run_ui(input, |ui| response = show(ui, tokens, tabs, &[])); - out.textures_delta.clear(); - let texts = out - .shapes - .iter() - .filter_map(|shape| match &shape.shape { - egui::epaint::Shape::Text(text) => Some((text.galley.text().to_owned(), text.pos)), - _ => None, - }) - .collect(); - (texts, response) - } - - fn click_at(ctx: &egui::Context, tokens: &Tokens, tabs: &Tabs, time: &mut f64, at: Pos2) -> TabsResponse { - let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; - *time += 0.05; - overflow_frame(ctx, tokens, tabs, *time, vec![egui::Event::PointerMoved(at)]); - *time += 0.05; - overflow_frame(ctx, tokens, tabs, *time, vec![button(true)]); - *time += 0.05; - overflow_frame(ctx, tokens, tabs, *time, vec![button(false)]).1 - } - - #[test] - fn the_overflow_menu_lists_hidden_tabs_and_offers_to_close_others_and_all() { - let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; - let tokens = Tokens::new(theme); - let ctx = egui::Context::default(); - crate::motion::set_enabled(&ctx, false); - crate::fonts::install(&ctx); - let mut warm = ctx.run_ui(egui::RawInput::default(), |_| {}); - warm.textures_delta.clear(); - let mut tabs = Tabs::new(); - for n in 0..14 { - let _ = tabs.open(&format!("notes/note-{n:02}.md")); - } - let mut time = 1.0; - overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); - time += 0.05; - let (texts, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); - let Some((label, at)) = texts.iter().find(|(text, _)| text.ends_with('⌄') && text.contains("more")) else { - panic!("no overflow button in {texts:?}") - }; - assert!(label.starts_with(|c: char| c.is_ascii_digit()), "{label}"); - let button = *at + egui::vec2(8.0, 6.0); - click_at(&ctx, &tokens, &tabs, &mut time, button); - time += 0.1; - overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); - time += 0.1; - let (open, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); - let has = |name: &str| open.iter().any(|(text, _)| text == name); - assert!(has("Close other tabs") && has("Close all tabs"), "{open:?}"); - let rows = open.iter().filter(|(text, _)| text.starts_with("note-")).count(); - assert!(rows >= 2, "hidden tabs are listed: {open:?}"); - let (_, close_all) = open.iter().find(|(text, _)| text == "Close all tabs").expect("row"); - let chosen = click_at(&ctx, &tokens, &tabs, &mut time, *close_all + egui::vec2(8.0, 6.0)); - assert_eq!(chosen.action.map(|action| action.kind), Some(crate::context_menu::MenuKind::CloseAll)); - } -} +mod tests; diff --git a/crates/ui-egui/src/widgets/tabs/layout.rs b/crates/ui-egui/src/widgets/tabs/layout.rs index 31b9014..3b06660 100644 --- a/crates/ui-egui/src/widgets/tabs/layout.rs +++ b/crates/ui-egui/src/widgets/tabs/layout.rs @@ -8,25 +8,47 @@ const GAP: f32 = 2.0; /// Fixed trailing creation-button width, independent of tab fitting. pub(super) const PLUS_WIDTH: f32 = 36.0; -/// Disjoint final slots and the indices offered by the overflow menu. +/// The tabs measured per frame: a window around the active one, so cost does not grow with the +/// number of open tabs. +pub(super) const WINDOW: usize = 64; +/// How many windowed tabs sit before the active one. +const LEAD: usize = 8; + +/// The slice of `0..total` to lay out when `active` is the active tab. +pub(super) fn window(total: usize, active: usize) -> std::ops::Range { + let start = active.saturating_sub(LEAD).min(total.saturating_sub(WINDOW)); + start..total.min(start.saturating_add(WINDOW)) +} + +/// Disjoint final slots and the count behind the overflow list. pub(super) struct Layout { - /// Visible indices and their allocated tab rectangles. + /// Visible tab indices (into the whole list) and their allocated rectangles. pub shown: Vec<(usize, Rect)>, - /// Hidden indices in document order. - pub hidden: Vec, + /// How many tabs are not shown; the overflow list holds all of them. + pub hidden: usize, /// Overflow-chip slot; zero width when every tab fits. pub overflow: Rect, /// Creation-button slot anchored at the strip's right edge. pub plus: Rect, } -/// Fits equal tab slots, retaining the active tab before overflowing others. -pub(super) fn arrange(bar: Rect, natural: &[f32], active: usize) -> Layout { +/// Fits equal tab slots over `natural` (the widths of `window`, a range of the `total` tabs), +/// retaining the active tab before overflowing others. +pub(super) fn arrange(bar: Rect, natural: &[f32], window: std::ops::Range, total: usize, active: usize) -> Layout { + let offset = window.start; + let active = active.saturating_sub(offset); let plus = Rect::from_min_size(Pos2::new(bar.right() - PLUS_WIDTH, bar.top() + 4.0), Vec2::new(PLUS_WIDTH, TAB_BAR_HEIGHT - 8.0)); // Include spacing in each fitted slot; hidden tabs must not consume gaps. let common = natural.iter().copied().fold(TAB_MIN_WIDTH, f32::max); let slots = vec![common + GAP; natural.len()]; - let fit = craft_ui::tabs::fit(&slots, active, (plus.left() - bar.left() - TAB_PLUS_GAP).max(0.0), TAB_MIN_WIDTH + GAP, TAB_OVERFLOW_WIDTH); + let room = (plus.left() - bar.left() - TAB_PLUS_GAP).max(0.0); + let mut fit = craft_ui::tabs::fit(&slots, active, room, TAB_MIN_WIDTH + GAP, TAB_OVERFLOW_WIDTH); + if total > natural.len() && fit.overflow.is_empty() { + // Tabs outside the window are hidden even when the window fits: keep room for the chip. + fit = craft_ui::tabs::fit(&slots, active, (room - TAB_OVERFLOW_WIDTH).max(0.0), TAB_MIN_WIDTH + GAP, TAB_OVERFLOW_WIDTH); + fit.overflow_width = TAB_OVERFLOW_WIDTH; + } + let shown_count = fit.shown.len(); let mut x = bar.left(); let shown = fit .shown @@ -34,11 +56,12 @@ pub(super) fn arrange(bar: Rect, natural: &[f32], active: usize) -> Layout { .map(|(index, width)| { let rect = Rect::from_min_size(Pos2::new(x, plus.top()), Vec2::new((width - GAP).max(0.0), plus.height())); x += width; - (index, rect) + (index.saturating_add(offset), rect) }) .collect(); let overflow = Rect::from_min_size(Pos2::new(x, plus.top()), Vec2::new(fit.overflow_width, plus.height())); - Layout { shown, hidden: fit.overflow, overflow, plus } + let hidden = total.saturating_sub(shown_count); + Layout { shown, hidden, overflow, plus } } #[cfg(test)] @@ -52,7 +75,7 @@ mod tests { for active in 0..count { let bar = Rect::from_min_size(Pos2::ZERO, Vec2::new(width, TAB_BAR_HEIGHT)); let natural: Vec<_> = (0..count).map(|index| if index % 2 == 0 { 220.0 } else { 120.0 }).collect(); - let layout = arrange(bar, &natural, active); + let layout = arrange(bar, &natural, 0..count, count, active); assert!(layout.shown.iter().any(|(index, _)| *index == active)); assert_eq!(layout.plus.width(), 36.0); assert_eq!(layout.plus.right(), bar.right()); @@ -64,15 +87,32 @@ mod tests { assert!(!rect.intersects(*other)); assert!((rect.width() - other.width()).abs() < 0.01); } - if !layout.hidden.is_empty() { + if layout.hidden > 0 { assert!(!rect.intersects(layout.overflow)); assert!(bar.contains_rect(layout.overflow)); assert!(!layout.overflow.intersects(layout.plus)); } } - assert_eq!(layout.shown.len() + layout.hidden.len(), count); + assert_eq!(layout.shown.len() + layout.hidden, count); } } } } + + #[test] + fn five_thousand_tabs_lay_out_only_a_window() { + let total = 5_000; + for active in [0, 7, 2_500, 4_999] { + let range = window(total, active); + assert!(range.len() <= WINDOW && range.contains(&active), "{range:?}"); + let bar = Rect::from_min_size(Pos2::ZERO, Vec2::new(1400.0, TAB_BAR_HEIGHT)); + let natural = vec![140.0; range.len()]; + let layout = arrange(bar, &natural, range.clone(), total, active); + assert!(layout.shown.len() <= WINDOW); + assert!(layout.shown.iter().any(|(index, _)| *index == active)); + assert_eq!(layout.shown.len() + layout.hidden, total); + assert!(layout.overflow.width() > 0.0, "the chip shows because tabs are hidden"); + assert!(layout.shown.iter().all(|(_, rect)| !rect.intersects(layout.overflow) && !rect.intersects(layout.plus))); + } + } } diff --git a/crates/ui-egui/src/widgets/tabs/paint.rs b/crates/ui-egui/src/widgets/tabs/paint.rs index 566e40e..47fa384 100644 --- a/crates/ui-egui/src/widgets/tabs/paint.rs +++ b/crates/ui-egui/src/widgets/tabs/paint.rs @@ -23,19 +23,24 @@ enum RowChoice { Close, } -/// One hidden tab in the overflow menu: icon, name, and a close control while hovered. -fn overflow_row(ui: &mut Ui, tokens: &Tokens, path: &str) -> Option { +/// One tab in the overflow list: icon, name, and a close control while hovered. `selected` is the +/// keyboard cursor; `active` marks the tab that is open in the editor. +fn overflow_row(ui: &mut Ui, tokens: &Tokens, path: &str, (selected, active): (bool, bool)) -> Option { let c = &tokens.theme.colors; let (rect, hit) = ui.allocate_exact_size(Vec2::new(ui.available_width(), MENU_ROW), Sense::click()); hit.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label_of(path))); let glow = crate::motion::hover(ui.ctx(), ("overflow-row", path), &hit); - ui.painter().rect_filled(rect, tokens.radius_small(), color(c.selection).gamma_multiply(glow)); + ui.painter().rect_filled(rect, tokens.radius_small(), color(c.selection).gamma_multiply(if selected { 1.0 } else { glow })); let icon = Rect::from_center_size(Pos2::new(rect.left() + 18.0, rect.center().y), Vec2::splat(16.0)); crate::icons::paint(ui, TabKind::of(path).icon(), icon, color(c.text_muted)); let close = Rect::from_center_size(Pos2::new(rect.right() - 6.0 - MENU_CLOSE / 2.0, rect.center().y), Vec2::splat(MENU_CLOSE)); let close_hit = ui.interact(close, Id::new(path).with("overflow-close"), Sense::click()); let label_rect = Rect::from_min_max(Pos2::new(icon.right() + 10.0, rect.top()), Pos2::new(close.left() - 6.0, rect.bottom())); - let mut job = egui::text::LayoutJob::simple_singleline(label_of(path).to_owned(), egui::FontId::proportional(crate::style::TEXT_BODY), color(c.text)); + let mut job = egui::text::LayoutJob::simple_singleline( + label_of(path).to_owned(), + egui::FontId::proportional(crate::style::TEXT_BODY), + color(if active { c.accent } else { c.text }), + ); job.wrap = egui::text::TextWrapping { max_width: label_rect.width().max(0.0), max_rows: 1, break_anywhere: true, ..Default::default() }; let galley = ui.painter().layout_job(job); ui.painter().with_clip_rect(label_rect).galley(Pos2::new(label_rect.left(), rect.center().y - galley.size().y / 2.0), galley, color(c.text)); @@ -46,33 +51,108 @@ fn overflow_row(ui: &mut Ui, tokens: &Tokens, path: &str) -> Option { let tint = if close_hit.hovered() { c.text } else { c.text_muted }; crate::icons::paint(ui, "x", Rect::from_center_size(close.center(), Vec2::splat(14.0)), color(tint)); } - if close_hit.on_hover_text(strings::close_tab()).clicked() { + if crate::hotkeys::on_hover_hotkey(close_hit, strings::close_tab(), "ui.close_tab").clicked() { return Some(RowChoice::Close); } hit.clicked().then_some(RowChoice::Open) } -/// Registers the fixed creation button and the hidden-tab selector. -pub(super) fn menus(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, layout: &layout::Layout, response: &mut TabsResponse) { - if !layout.hidden.is_empty() { - let label = format!("{} {OVERFLOW_CHEVRON}", strings::more().replace("{{n}}", &layout.hidden.len().to_string())); - let hit = ui.put(layout.overflow, egui::Button::new(label).frame(false)); - egui::Popup::menu(&hit).frame(crate::context_menu::frame(tokens)).show(|ui| { - ui.set_width(MENU_WIDTH); - ui.spacing_mut().item_spacing.y = 2.0; - egui::ScrollArea::vertical().max_height(MENU_LIST_MAX).auto_shrink([true, true]).show(ui, |ui| { - for index in &layout.hidden { - let Some(path) = tabs.open.get(*index) else { continue }; - match overflow_row(ui, tokens, path) { - Some(RowChoice::Open) => { - response.selected = Some(path.clone()); - ui.close(); - } - Some(RowChoice::Close) => response.closed = Some(path.clone()), - None => {} - } +/// The overflow list's own state, kept in egui memory while its popup is open. +#[derive(Clone, Default)] +struct ListState { + filter: String, + /// Keyboard cursor: a position in the filtered rows. + selected: usize, + /// First frame: focus the filter and scroll to the cursor. + started: bool, +} + +/// Indices of the tabs whose name contains `filter` (case-insensitive); all of them when it is empty. +fn matching(tabs: &Tabs, filter: &str) -> Vec { + if filter.is_empty() { + return (0..tabs.open.len()).collect(); + } + let needle = filter.to_lowercase(); + tabs.open + .iter() + .enumerate() + .filter(|(_, path)| label_of(path).to_lowercase().contains(&needle) || path.to_lowercase().contains(&needle)) + .map(|(index, _)| index) + .collect() +} + +/// The searchable, virtualised list of every tab: only the visible rows are built, so thousands of tabs +/// stay cheap. Up/Down move the cursor, Enter opens it, a click opens a row, its `×` closes it. +fn overflow_list(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, id: Id, response: &mut TabsResponse) { + ui.set_width(MENU_WIDTH); + ui.spacing_mut().item_spacing.y = 0.0; + let mut state = + ui.data(|data| data.get_temp::(id)).unwrap_or_else(|| ListState { selected: tabs.active, started: true, ..ListState::default() }); + let (down, up, enter) = ui.input_mut(|input| { + let none = egui::Modifiers::NONE; + (input.consume_key(none, egui::Key::ArrowDown), input.consume_key(none, egui::Key::ArrowUp), input.consume_key(none, egui::Key::Enter)) + }); + let edit = ui.add(egui::TextEdit::singleline(&mut state.filter).hint_text(strings::search_tabs()).desired_width(f32::INFINITY)); + // The filter keeps the keyboard for the whole life of the popup (its first pass is a sizing pass that drops focus). + edit.request_focus(); + let rows = matching(tabs, &state.filter); + if state.started && state.filter.is_empty() { + state.selected = tabs.active; + } + if edit.changed() { + state.selected = 0; + } + let before = state.selected; + if down { + state.selected = state.selected.saturating_add(1); + } + if up { + state.selected = state.selected.saturating_sub(1); + } + state.selected = state.selected.min(rows.len().saturating_sub(1)); + let reveal = state.started || edit.changed() || state.selected != before; + if enter && let Some(path) = rows.get(state.selected).and_then(|index| tabs.open.get(*index)) { + response.selected = Some(path.clone()); + ui.close(); + } + ui.add_space(4.0); + let mut area = egui::ScrollArea::vertical().max_height(MENU_LIST_MAX).auto_shrink([true, true]); + if reveal { + #[expect(clippy::cast_precision_loss, reason = "a row position is far below 2^24")] + let top = (state.selected as f32 * MENU_ROW - MENU_LIST_MAX / 2.0 + MENU_ROW / 2.0).max(0.0); + area = area.vertical_scroll_offset(top); + } + if rows.is_empty() { + ui.label(egui::RichText::new(strings::no_match()).color(color(tokens.theme.colors.text_muted))); + } + area.show_rows(ui, MENU_ROW, rows.len(), |ui, range| { + for position in range { + let Some(path) = rows.get(position).and_then(|index| tabs.open.get(*index)) else { continue }; + let active = tabs.active_path() == Some(path.as_str()); + match overflow_row(ui, tokens, path, (position == state.selected, active)) { + Some(RowChoice::Open) => { + response.selected = Some(path.clone()); + ui.close(); } - }); + Some(RowChoice::Close) => response.closed = Some(path.clone()), + None => {} + } + } + }); + state.started = false; + ui.data_mut(|data| data.insert_temp(id, state)); +} + +/// Registers the fixed creation button and the all-tabs selector. +pub(super) fn menus(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, layout: &layout::Layout, response: &mut TabsResponse) { + if layout.hidden > 0 { + let label = format!("{} {OVERFLOW_CHEVRON}", strings::more().replace("{{n}}", &layout.hidden.to_string())); + let hit = ui.put(layout.overflow, egui::Button::new(label).frame(false)); + let hit = + crate::hotkeys::on_hover_hotkeys(hit, &[(crate::text::hotkeys::next_tab(), "ui.next_tab"), (crate::text::hotkeys::prev_tab(), "ui.prev_tab")]); + let list_id = hit.id.with("tab-list"); + let shown = egui::Popup::menu(&hit).frame(crate::context_menu::frame(tokens)).show(|ui| { + overflow_list(ui, tokens, tabs, list_id, response); let Some(active) = tabs.active_path() else { return }; ui.add_space(4.0); ui.separator(); @@ -86,8 +166,12 @@ pub(super) fn menus(ui: &mut Ui, tokens: &Tokens, tabs: &Tabs, layout: &layout:: } } }); + if shown.is_none() { + ui.data_mut(|data| data.remove_temp::(list_id)); + } } let hit = ui.put(layout.plus, egui::Button::new(egui::RichText::new(PLUS).color(egui::Color32::TRANSPARENT)).frame(false)); + let hit = crate::hotkeys::on_hover_hotkey(hit, crate::text::hotkeys::new_tab(), "ui.new_note"); crate::lottie_icon::paint_hover( ui, hit.id, @@ -155,5 +239,8 @@ pub(super) fn paint_tab(ui: &Ui, tokens: &Tokens, tab: &egui::Response, path: &s if tab.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } + if close.hovered() && (is_dirty || !pinned) { + let _ = crate::hotkeys::on_hover_hotkey(close.clone(), strings::close_tab(), "ui.close_tab"); + } close } diff --git a/crates/ui-egui/src/widgets/tabs/tests.rs b/crates/ui-egui/src/widgets/tabs/tests.rs new file mode 100644 index 0000000..bd7aeaa --- /dev/null +++ b/crates/ui-egui/src/widgets/tabs/tests.rs @@ -0,0 +1,207 @@ +//! Tab list, strip and overflow-list tests. +use super::*; +use crate::testsupport; + +fn opened(a: &str) -> OpenOutcome { + let mut tabs = Tabs::new(); + let _ = tabs.open(a); + tabs.open(a) +} + +#[test] +fn open_dedupe_and_activate() { + let mut tabs = Tabs::new(); + assert_eq!(tabs.open("notes/a.md"), OpenOutcome::Opened); + assert_eq!(tabs.open("notes/b.md"), OpenOutcome::Opened); + assert_eq!(tabs.active, 1); + assert_eq!(tabs.open("notes/a.md"), OpenOutcome::Activated); + assert_eq!(tabs.active, 0); + assert_eq!(tabs.active_path(), Some("notes/a.md")); + assert_eq!(opened("notes/a.md"), OpenOutcome::Activated); +} + +#[test] +fn close_repairs_active() { + let mut tabs = Tabs::new(); + assert_eq!(tabs.open("a.md"), OpenOutcome::Opened); + assert_eq!(tabs.open("b.md"), OpenOutcome::Opened); + assert_eq!(tabs.open("c.md"), OpenOutcome::Opened); + assert_eq!(tabs.close(1), Some("b.md".to_owned())); + assert_eq!(tabs.open, ["a.md".to_owned(), "c.md".to_owned()]); + assert_eq!(tabs.active, 1); // stayed on the tab that took b's place + assert_eq!(tabs.close(99), None); + assert_eq!(tabs.close(0), Some("a.md".to_owned())); + assert_eq!(tabs.active, 0); + assert_eq!(tabs.close(0), Some("c.md".to_owned())); + assert_eq!(tabs.active_path(), None); +} + +#[test] +fn there_is_no_tab_cap() { + let mut tabs = Tabs::new(); + for i in 0..2_000 { + assert_eq!(tabs.open(&format!("n{i}.md")), OpenOutcome::Opened); + } + assert_eq!(tabs.open.len(), 2_000); + assert_eq!(tabs.open("n0.md"), OpenOutcome::Activated); + assert_eq!(tabs.open(""), OpenOutcome::Empty); // empty path refused +} + +#[test] +fn pinned_tabs_stay_in_front_and_move_within_their_group() { + let mut tabs = Tabs::new(); + for name in ["a.md", "b.md", "c.md", "d.md"] { + let _ = tabs.open(name); + } + tabs.toggle_pin("c.md"); + assert_eq!(tabs.open, ["c.md", "a.md", "b.md", "d.md"]); + assert_eq!(tabs.active_path(), Some("d.md"), "the active tab stays active"); + tabs.toggle_pin("d.md"); + assert_eq!(tabs.open, ["c.md", "d.md", "a.md", "b.md"]); + tabs.move_tab("b.md", 0); + assert_eq!(tabs.open, ["c.md", "d.md", "b.md", "a.md"], "an unpinned tab cannot enter the pinned group"); + tabs.move_tab("c.md", 3); + assert_eq!(tabs.open, ["d.md", "c.md", "b.md", "a.md"], "a pinned tab cannot leave it"); + tabs.toggle_pin("c.md"); + assert_eq!(tabs.open, ["d.md", "c.md", "b.md", "a.md"]); + assert!(!tabs.is_pinned("c.md")); +} + +#[test] +fn labels_strip_folders() { + assert_eq!(label_of("notes/deep/a.md"), "a"); + assert_eq!(label_of("a.md"), "a"); +} + +#[test] +fn renders_two_frames_without_panic_and_paints() { + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let mut tabs = Tabs::new(); + let _ = tabs.open("notes/alpha.md"); + let _ = tabs.open("notes/deep/beta.md"); + let dirty = vec![String::from("notes/alpha.md")]; + let shapes = testsupport::run_frames_ui(1440.0, 900.0, |root| { + let _ = show(root, &tokens, &tabs, &dirty); + }); + assert!(shapes > 0, "tab bar painted no shapes"); + // Empty list still reserves the 44 pt strip and paints nothing harmful. + let empty = Tabs::new(); + let _ = testsupport::run_frames_ui(800.0, 600.0, |root| { + let response = show(root, &tokens, &empty, &[]); + assert_eq!(response, TabsResponse::default()); + }); +} + +type Texts = Vec<(String, Pos2)>; + +/// Runs one frame of a 620 pt bar; returns the painted texts with positions and the response. +fn overflow_frame(ctx: &egui::Context, tokens: &Tokens, tabs: &Tabs, time: f64, events: Vec) -> (Texts, TabsResponse) { + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(620.0, 600.0))), + time: Some(time), + events, + focused: true, + ..egui::RawInput::default() + }; + let mut response = TabsResponse::default(); + let mut out = ctx.run_ui(input, |ui| response = show(ui, tokens, tabs, &[])); + out.textures_delta.clear(); + let texts = out + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::epaint::Shape::Text(text) => Some((text.galley.text().to_owned(), text.pos)), + _ => None, + }) + .collect(); + (texts, response) +} + +fn click_at(ctx: &egui::Context, tokens: &Tokens, tabs: &Tabs, time: &mut f64, at: Pos2) -> TabsResponse { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + *time += 0.05; + overflow_frame(ctx, tokens, tabs, *time, vec![egui::Event::PointerMoved(at)]); + *time += 0.05; + overflow_frame(ctx, tokens, tabs, *time, vec![button(true)]); + *time += 0.05; + overflow_frame(ctx, tokens, tabs, *time, vec![button(false)]).1 +} + +#[test] +fn the_overflow_menu_lists_hidden_tabs_and_offers_to_close_others_and_all() { + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let ctx = egui::Context::default(); + crate::motion::set_enabled(&ctx, false); + crate::fonts::install(&ctx); + let mut warm = ctx.run_ui(egui::RawInput::default(), |_| {}); + warm.textures_delta.clear(); + let mut tabs = Tabs::new(); + for n in 0..14 { + let _ = tabs.open(&format!("notes/note-{n:02}.md")); + } + let mut time = 1.0; + overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + time += 0.05; + let (texts, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + let Some((label, at)) = texts.iter().find(|(text, _)| text.ends_with('⌄') && text.contains("more")) else { panic!("no overflow button in {texts:?}") }; + assert!(label.starts_with(|c: char| c.is_ascii_digit()), "{label}"); + let button = *at + egui::vec2(8.0, 6.0); + click_at(&ctx, &tokens, &tabs, &mut time, button); + time += 0.1; + overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + time += 0.1; + let (open, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + let has = |name: &str| open.iter().any(|(text, _)| text == name); + assert!(has("Close other tabs") && has("Close all tabs"), "{open:?}"); + let rows = open.iter().filter(|(text, _)| text.starts_with("note-")).count(); + assert!(rows >= 2, "hidden tabs are listed: {open:?}"); + let (_, close_all) = open.iter().find(|(text, _)| text == "Close all tabs").expect("row"); + let chosen = click_at(&ctx, &tokens, &tabs, &mut time, *close_all + egui::vec2(8.0, 6.0)); + assert_eq!(chosen.action.map(|action| action.kind), Some(crate::context_menu::MenuKind::CloseAll)); +} + +#[test] +fn five_thousand_tabs_paint_only_a_window_and_the_list_is_virtual() { + let Some(theme) = testsupport::theme("catppuccin-mocha") else { return }; + let tokens = Tokens::new(theme); + let ctx = egui::Context::default(); + crate::motion::set_enabled(&ctx, false); + crate::fonts::install(&ctx); + let mut warm = ctx.run_ui(egui::RawInput::default(), |_| {}); + warm.textures_delta.clear(); + let mut tabs = Tabs::new(); + for n in 0..5_000 { + let _ = tabs.open(&format!("notes/note-{n:04}.md")); + } + let mut time = 1.0; + overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + time += 0.05; + let (texts, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + let painted = texts.iter().filter(|(text, _)| text.starts_with("note-")).count(); + assert!(painted > 0 && painted <= layout::WINDOW, "strip painted {painted} tab labels"); + let Some((_, at)) = texts.iter().find(|(text, _)| text.ends_with('⌄') && text.contains("more")) else { panic!("no overflow chip in {texts:?}") }; + click_at(&ctx, &tokens, &tabs, &mut time, *at + egui::vec2(8.0, 6.0)); + for _ in 0..3 { + time += 0.1; + overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + } + time += 0.1; + let (open, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + let rows = open.iter().filter(|(text, _)| text.starts_with("note-")).count(); + assert!(rows > painted && rows <= painted + 20, "the list builds only visible rows of 5000: {rows} vs strip {painted}"); + // Typing filters the whole list, not just the rows on screen. + time += 0.1; + overflow_frame(&ctx, &tokens, &tabs, time, vec![egui::Event::Text("4321".to_owned())]); + time += 0.1; + overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + time += 0.1; + let (filtered, _) = overflow_frame(&ctx, &tokens, &tabs, time, Vec::new()); + assert!(filtered.iter().any(|(text, _)| text == "note-4321"), "{filtered:?}"); + // Enter opens the cursor row. + time += 0.1; + let enter = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }; + let (_, chosen) = overflow_frame(&ctx, &tokens, &tabs, time, vec![enter]); + assert_eq!(chosen.selected.as_deref(), Some("notes/note-4321.md")); +} diff --git a/crates/ui-egui/tests/comment_panel.rs b/crates/ui-egui/tests/comment_panel.rs new file mode 100644 index 0000000..33a91cd --- /dev/null +++ b/crates/ui-egui/tests/comment_panel.rs @@ -0,0 +1,164 @@ +//! The Comments tab of the right card, driven headless: the threads of a note +//! in document order, the filters, a reply that reaches the sidecar, the +//! resolve and the read-only vault. + +use std::path::PathBuf; + +use egui::accesskit::Role; +use egui_kittest::kittest::{By, Queryable}; +use notescraft_engine::{Registry, Session, register_core}; +use notescraft_themes::builtin; +use notescraft_ui_egui::comment_panel::{CommentData, CommentResponse, parse_list}; +use notescraft_ui_egui::theme::Tokens; +use serde_json::json; + +/// The panel over a vault, with the engine that answers its commands. +struct Panel { + /// What the panel draws. + data: CommentData, + /// The theme of the app. + tokens: Tokens, + /// The vault the note is in. + session: Session, + /// The commands of the engine. + registry: Registry, + /// The note the threads belong to. + path: String, + /// The root of the vault, to read the sidecar. + root: PathBuf, + /// Whether the fonts are in, which takes one pass. + warmed: bool, +} + +/// A vault with a note and two threads: the first one is closed, the second one +/// is orphaned because its quote left the note. +fn panel(name: &str, read_only: bool) -> egui_kittest::Harness<'static, Panel> { + let root = std::env::temp_dir().join(format!("notescraft-comment-panel-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).expect("the temp vault is made"); + std::fs::write(root.join("a.md"), "First line.\nSecond line.\n").expect("the note is written"); + let mut session = Session::open(&root).expect("the temp vault opens"); + let mut registry = Registry::new(); + register_core(&mut registry).expect("the core commands are registered"); + registry.run("comment.add", &mut session, &json!({ "path": "a.md", "quote": "First line.", "body": "Soften this claim." })).expect("the first thread"); + registry.run("comment.add", &mut session, &json!({ "path": "a.md", "quote": "Second line.", "body": "This line is gone." })).expect("the second thread"); + // The second quote leaves the note: its thread is orphaned. + std::fs::write(root.join("a.md"), "First line.\n").expect("the note is rewritten"); + let list = registry.run("comment.list", &mut session, &json!({ "path": "a.md" })).expect("the threads"); + let closed = parse_list(&list).iter().find(|thread| thread.body == "Soften this claim.").map(|thread| thread.id.clone()).expect("the first thread"); + registry.run("comment.resolve", &mut session, &json!({ "path": "a.md", "id": closed, "resolved": true })).expect("the resolve"); + let list = registry.run("comment.list", &mut session, &json!({ "path": "a.md" })).expect("the threads"); + let data = CommentData { path: Some("a.md".to_owned()), threads: parse_list(&list), loaded: true, read_only, ..CommentData::default() }; + let tokens = Tokens::new(builtin("catppuccin-mocha").expect("the theme is there")); + egui_kittest::Harness::builder().with_size(egui::Vec2::new(420.0, 900.0)).with_max_steps(200).build_ui_state( + |ui, state: &mut Panel| { + if !state.warmed { + // Fonts bind one pass after installing; the first pass warms only. + notescraft_ui_egui::fonts::install(ui.ctx()); + state.warmed = true; + return; + } + let response = notescraft_ui_egui::comment_panel::show(ui, &state.tokens, &state.data); + apply(state, response); + }, + Panel { data, tokens, session, registry, path: "a.md".to_owned(), root, warmed: false }, + ) +} + +/// Runs what the panel asked for and reads the threads again. +fn apply(state: &mut Panel, response: CommentResponse) { + let path = state.path.clone(); + if let Some((quote, body)) = response.add { + let _ = state.registry.run("comment.add", &mut state.session, &json!({ "path": path, "quote": quote, "body": body })); + } + if let Some((id, body)) = response.reply { + let _ = state.registry.run("comment.reply", &mut state.session, &json!({ "path": path, "id": id, "body": body })); + } + if let Some((id, resolved)) = response.resolve { + let _ = state.registry.run("comment.resolve", &mut state.session, &json!({ "path": path, "id": id, "resolved": resolved })); + } + if let Some(id) = response.reanchor { + let _ = state.registry.run( + "comment.reanchor", + &mut state.session, + &json!({ "path": path, "id": id, "quote": "First line.", "start": 0, "end": "First line.".len() }), + ); + } + if let Ok(list) = state.registry.run("comment.list", &mut state.session, &json!({ "path": path })) { + state.data.threads = parse_list(&list); + state.data.loaded = true; + } +} + +/// The node that sits lowest on the screen. +fn lowest(left: &egui_kittest::Node<'_>, right: &egui_kittest::Node<'_>) -> std::cmp::Ordering { + left.rect().min.y.total_cmp(&right.rect().min.y) +} + +/// The threads of the note are listed, the closed one first. +#[test] +fn the_threads_are_listed_in_document_order() { + let mut harness = panel("order", false); + harness.run(); + assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 1, "the closed thread is shown"); + assert_eq!(harness.query_all_by_label("This line is gone.").count(), 1, "the orphan is shown"); + let closed = harness.get_by_label("Soften this claim.").rect(); + let orphan = harness.get_by_label("This line is gone.").rect(); + assert!(closed.min.y < orphan.min.y, "the thread that still resolves comes first"); + assert!(harness.query_all_by_label("Orphaned").count() >= 1, "the orphan carries its badge"); +} + +/// The Open filter leaves out the closed thread. +#[test] +fn the_open_filter_hides_the_closed_thread() { + let mut harness = panel("filter", false); + harness.run(); + harness.get_by_label("Open").click(); + harness.run(); + assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 0, "the closed thread is gone"); + assert_eq!(harness.query_all_by_label("This line is gone.").count(), 1, "the open thread stays"); +} + +/// A reply typed in the panel reaches the sidecar of the note. +#[test] +fn a_reply_reaches_the_sidecar() { + let mut harness = panel("reply", false); + harness.run(); + // The first thread opens, and its reply box appears. + harness.get_all_by_label("Reply").next().expect("the thread opens").click(); + harness.run(); + harness.get_all(By::new().role(Role::TextInput)).max_by(lowest).expect("a text field").click(); + harness.run(); + harness.get_all(By::new().role(Role::TextInput)).max_by(lowest).expect("a text field").type_text("Agreed."); + harness.run(); + // The send button of the reply box is the lowest one: the composer sits above it. + harness.get_all_by_label("Send").max_by(lowest).expect("a send button").click(); + harness.run(); + let sidecar = std::fs::read_to_string(harness.state().root.join("a.md.comments.json")).expect("the sidecar is there"); + assert!(sidecar.contains("Agreed."), "the reply is in the sidecar: {sidecar}"); +} + +/// The resolve button closes the thread and becomes a reopen. +#[test] +fn resolve_closes_the_thread() { + let mut harness = panel("resolve", false); + harness.run(); + assert_eq!(harness.query_all_by_label("Reopen").count(), 1, "the closed thread offers to reopen"); + assert_eq!(harness.query_all_by_label("Resolve").count(), 1, "the open thread offers to close"); + harness.get_by_label("Resolve").click(); + harness.run(); + assert_eq!(harness.query_all_by_label("Reopen").count(), 2, "both threads are closed now"); + let sidecar = std::fs::read_to_string(harness.state().root.join("a.md.comments.json")).expect("the sidecar is there"); + assert_eq!(sidecar.matches("\"resolved\": true").count(), 2, "both threads are closed in the sidecar"); +} + +/// A read-only vault shows the threads without a single button. +#[test] +fn a_read_only_vault_has_no_buttons() { + let mut harness = panel("readonly", true); + harness.run(); + assert_eq!(harness.query_all_by_label("Soften this claim.").count(), 1, "the threads are still listed"); + for label in ["Resolve", "Reopen", "Re-anchor", "Send"] { + assert_eq!(harness.query_all_by_label(label).count(), 0, "no {label} button in a read-only vault"); + } +} diff --git a/crates/ui-egui/tests/hotkeys.rs b/crates/ui-egui/tests/hotkeys.rs new file mode 100644 index 0000000..5707594 --- /dev/null +++ b/crates/ui-egui/tests/hotkeys.rs @@ -0,0 +1,218 @@ +//! The hotkey registry driven the way a user does: real key events through whole frames. + +use egui::{Context, Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; +use notescraft_ui_egui::app::NotesApp; +use notescraft_ui_egui::hotkeys::{self, Binding, Hotkey, Owner, Registry, Scope, VimMode}; +use notescraft_ui_egui::right_view::RightTab; +use notescraft_ui_egui::vim_nav::NavFocus; + +struct Harness { + ctx: Context, + app: NotesApp, + time: f64, +} + +impl Harness { + fn new(name: &str, notes: &[&str]) -> Option { + let path = std::env::temp_dir().join(format!("notescraft-hotkeys-{name}-{}", std::process::id())); + assert!(std::fs::create_dir_all(&path).is_ok()); + for note in notes { + assert!(std::fs::write(path.join(note), "# Heading\n\nExample note.\n").is_ok()); + } + let mut session = notescraft_engine::Session::open(&path).ok()?; + session.use_settings_dir(path.join("config")); + let mut app = NotesApp::new(session, notescraft_themes::builtin("catppuccin-mocha")?); + app.state.animations = false; + for note in notes { + app.open_note(note); + } + let mut harness = Self { ctx: Context::default(), app, time: 0.0 }; + for _ in 0..4 { + harness.frame(Vec::new()); + } + Some(harness) + } + + fn frame(&mut self, events: Vec) -> Vec<(String, Rect)> { + self.time += 1.0 / 60.0; + let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..RawInput::default() }; + let mut out = self.ctx.run_ui(input, |ui| self.app.ui(ui.ctx())); + let texts = out + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::epaint::Shape::Text(text) => Some((text.galley.text().to_owned(), text.visual_bounding_rect())), + _ => None, + }) + .collect(); + out.textures_delta.clear(); + texts + } + + /// A key press, then two quiet frames so a pending focus move lands. + fn press(&mut self, key: Key, modifiers: Modifiers) { + self.frame(vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers }]); + self.frame(Vec::new()); + self.frame(Vec::new()); + } +} + +fn cmd() -> Modifiers { + Modifiers { command: true, ..Modifiers::NONE } +} + +fn cmd_alt() -> Modifiers { + Modifiers { command: true, alt: true, ..Modifiers::NONE } +} + +#[test] +fn cmd_alt_o_opens_the_outline_shows_the_panel_and_focuses_it() { + let Some(mut h) = Harness::new("outline", &["note.md"]) else { panic!("setup") }; + h.app.state.right_visible = false; + h.frame(Vec::new()); + h.press(Key::O, cmd_alt()); + assert!(h.app.state.right_visible); + assert_eq!(h.app.right_tab, RightTab::Outline); + assert_eq!(h.app.vim_nav.focus, NavFocus::Right); + assert_eq!(hotkeys::focused_region(&h.ctx), Some(NavFocus::Right), "keyboard focus is inside the right panel"); + assert!(h.app.tabs.open.len() == 1, "Cmd+Alt+O is not the quick switcher"); +} + +#[test] +fn cmd_alt_backslash_toggles_the_right_panel_and_cmd_backslash_the_sidebar() { + let Some(mut h) = Harness::new("toggles", &["note.md"]) else { panic!("setup") }; + assert!(h.app.state.right_visible && h.app.state.sidebar_visible); + h.press(Key::Backslash, cmd_alt()); + assert!(!h.app.state.right_visible); + h.press(Key::Backslash, cmd_alt()); + assert!(h.app.state.right_visible); + h.press(Key::Backslash, cmd()); + assert!(!h.app.state.sidebar_visible); +} + +#[test] +fn each_opener_selects_its_tab() { + let Some(mut h) = Harness::new("openers", &["note.md"]) else { panic!("setup") }; + for (key, tab) in [ + (Key::R, RightTab::Related), + (Key::K, RightTab::Backlinks), + (Key::L, RightTab::Lint), + (Key::M, RightTab::Comments), + (Key::A, RightTab::Assistant), + (Key::O, RightTab::Outline), + ] { + h.press(key, cmd_alt()); + assert_eq!(h.app.right_tab, tab, "{key:?}"); + } +} + +#[test] +fn f6_cycles_focus_between_the_panels_and_shift_f6_goes_back() { + let Some(mut h) = Harness::new("cycle", &["note.md"]) else { panic!("setup") }; + h.app.vim_nav.focus = NavFocus::Sidebar; + let mut seen = Vec::new(); + for _ in 0..4 { + h.press(Key::F6, Modifiers::NONE); + seen.push(h.app.vim_nav.focus); + } + assert_eq!(seen, [NavFocus::Editor, NavFocus::Right, NavFocus::Sidebar, NavFocus::Editor]); + h.press(Key::F6, Modifiers::SHIFT); + assert_eq!(h.app.vim_nav.focus, NavFocus::Sidebar); + h.press(Key::R, Modifiers { command: true, shift: true, ..Modifiers::NONE }); + assert_eq!(h.app.vim_nav.focus, NavFocus::Right); + assert_eq!(hotkeys::focused_region(&h.ctx), Some(NavFocus::Right)); +} + +#[test] +fn escape_from_a_panel_returns_to_the_editor() { + let Some(mut h) = Harness::new("escape", &["note.md"]) else { panic!("setup") }; + h.press(Key::O, cmd_alt()); + assert_eq!(hotkeys::focused_region(&h.ctx), Some(NavFocus::Right)); + h.press(Key::Escape, Modifiers::NONE); + assert_eq!(h.app.vim_nav.focus, NavFocus::Editor); + assert_eq!(hotkeys::focused_region(&h.ctx), Some(NavFocus::Editor)); +} + +#[test] +fn cmd_digits_pick_tabs_and_cmd_9_the_last() { + let Some(mut h) = Harness::new("goto", &["a.md", "b.md", "c.md", "d.md"]) else { panic!("setup") }; + h.app.open_note("a.md"); + h.press(Key::Num3, cmd()); + assert_eq!(h.app.tabs.active, 2); + h.press(Key::Num9, cmd()); + assert_eq!(h.app.tabs.active, 3); + h.press(Key::Num8, cmd()); + assert_eq!(h.app.tabs.active, 3, "tab 8 does not exist: ignored"); + h.press(Key::Num1, cmd()); + assert_eq!(h.app.tabs.active, 0); +} + +#[test] +fn next_and_previous_tab_wrap_and_reopen_brings_a_closed_tab_back() { + let Some(mut h) = Harness::new("step", &["a.md", "b.md", "c.md"]) else { panic!("setup") }; + h.app.open_note("c.md"); + h.press(Key::ArrowRight, cmd_alt()); + assert_eq!(h.app.tabs.active, 0, "next wraps to the first tab"); + h.press(Key::ArrowLeft, cmd_alt()); + assert_eq!(h.app.tabs.active, 2, "previous wraps to the last tab"); + h.press(Key::Tab, Modifiers { ctrl: true, ..Modifiers::NONE }); + assert_eq!(h.app.tabs.active, 0); + h.app.close_note("b.md"); + h.frame(Vec::new()); + assert!(!h.app.tabs.is_open("b.md")); + h.press(Key::T, Modifiers { command: true, shift: true, ..Modifiers::NONE }); + assert!(h.app.tabs.is_open("b.md"), "Cmd+Shift+T reopens the closed tab"); +} + +#[test] +fn a_bare_letter_is_ignored_while_a_text_field_has_focus() { + let registry = Registry::from_hotkeys(&[Hotkey { + id: "test.letter", + arg: None, + default_binding: Binding::key(Key::J), + scope: Scope::Global, + owner: Owner::Dispatch, + vim: VimMode::Any, + }]); + let ctx = Context::default(); + let mut text = String::new(); + let field = egui::Id::new("field"); + let mut run = |events: Vec, focus: bool| { + let mut fired = Vec::new(); + let mut out = ctx.run_ui(RawInput { events, ..RawInput::default() }, |ui| { + let response = ui.add(egui::TextEdit::singleline(&mut text).id(field)); + if focus { + response.request_focus(); + } + fired = hotkeys::dispatch(ui.ctx(), ®istry); + }); + out.textures_delta.clear(); + fired.len() + }; + let press = || vec![Event::Key { key: Key::J, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }]; + assert_eq!(run(press(), false), 1, "free keyboard: the letter fires"); + run(Vec::new(), true); + run(Vec::new(), true); + assert!(ctx.egui_wants_keyboard_input(), "the field holds the keyboard"); + assert_eq!(run(press(), true), 0, "the field keeps the letter"); +} + +#[test] +fn the_outline_tab_tooltip_shows_the_formatted_binding() { + let chord = Binding::key(Key::O).cmd().alt().format(hotkeys::Platform::current()); + assert!(hotkeys::tooltip("Outline", "ui.show_outline").contains(&chord)); + + let Some(mut h) = Harness::new("tooltip", &["note.md"]) else { panic!("setup") }; + h.app.right_tab = RightTab::Outline; + let texts = h.frame(Vec::new()); + let tab = texts.iter().filter(|(text, _)| text == "Outline").map(|(_, rect)| *rect).min_by(|a, b| a.top().total_cmp(&b.top())); + let Some(label) = tab else { panic!("no Outline tab label in {texts:?}") }; + // The pill's icon sits left of its label; the label may spill past a narrow pill. + let tab = Rect::from_center_size(Pos2::new(label.left() - 14.0, label.center().y), vec2(1.0, 1.0)); + let mut shown = Vec::new(); + for step in 0..90 { + let events = if step == 0 { vec![Event::PointerMoved(tab.center())] } else { Vec::new() }; + shown = h.frame(events); + } + assert!(shown.iter().any(|(text, _)| text.contains(&chord)), "tooltip with {chord} in {shown:?}"); +} diff --git a/crates/ui-egui/tests/menu_keys.rs b/crates/ui-egui/tests/menu_keys.rs new file mode 100644 index 0000000..4bac409 --- /dev/null +++ b/crates/ui-egui/tests/menu_keys.rs @@ -0,0 +1,282 @@ +//! Menu mnemonics, keyboard focus and Vim motions, driven with real key events through egui_kittest frames. +use egui::{Event, Key, Modifiers, PointerButton, Pos2}; +use egui_kittest::kittest::{NodeT, Queryable}; +use notescraft_ui_egui::{fonts, menu_keys, motion, tabs, theme::Tokens}; +use std::cell::Cell; + +/// Rows of the test menu; `Duplicate` is disabled. +const ITEMS: [(&str, bool); 4] = [("Open", true), ("Rename", true), ("Duplicate", false), ("Close", true)]; + +struct Fixture { + tokens: Tokens, + vim: bool, + warmed: bool, + chosen: Vec<&'static str>, + opener: Cell, +} + +fn tokens() -> Tokens { + Tokens::new(notescraft_themes::builtin("catppuccin-mocha").expect("theme")) +} + +/// The opener button plus its context menu, the way the app builds its popups. +fn draw(ui: &mut egui::Ui, fx: &mut Fixture) { + if !fx.warmed { + fonts::install(ui.ctx()); + fx.warmed = true; + return; + } + motion::set_enabled(ui.ctx(), false); + menu_keys::set_vim(ui.ctx(), fx.vim); + let opener = ui.button("Opener"); + fx.opener.set(opener.rect.center()); + egui::Popup::context_menu(&opener).show(|ui| { + menu_keys::name_menu(ui, "Test menu"); + for (label, enabled) in ITEMS { + let chosen = ui.add_enabled_ui(enabled, |ui| menu_keys::row(ui, &fx.tokens, &menu_keys::Row::new(label, "x", false))).inner; + if chosen { + fx.chosen.push(label); + ui.close(); + } + } + }); +} + +fn harness(vim: bool) -> egui_kittest::Harness<'static, Fixture> { + let mut harness = egui_kittest::Harness::builder() + .with_size(egui::vec2(480.0, 400.0)) + .build_ui_state(draw, Fixture { tokens: tokens(), vim, warmed: false, chosen: Vec::new(), opener: Cell::new(Pos2::ZERO) }); + harness.run(); + harness +} + +fn open_menu(harness: &mut egui_kittest::Harness<'static, Fixture>) { + harness.get_by_label("Opener").click_secondary(); + harness.run(); +} + +fn press(harness: &mut egui_kittest::Harness<'static, Fixture>, key: Key) { + harness.key_press(key); + harness.run(); +} + +fn focused(harness: &egui_kittest::Harness<'static, Fixture>) -> Option<&'static str> { + ITEMS.iter().map(|(label, _)| *label).find(|label| harness.query_by_label(label).is_some_and(|node| node.is_focused())) +} + +#[test] +fn opening_focuses_the_first_enabled_row_and_names_the_menu() { + let mut harness = harness(false); + open_menu(&mut harness); + assert_eq!(focused(&harness), Some("Open")); + let menu = harness.query_all_by_role(egui::accesskit::Role::Menu).next(); + assert!(menu.is_some_and(|menu| menu.accesskit_node().label().as_deref() == Some("Test menu")), "menu container has role Menu and a name"); +} + +#[test] +fn items_are_menu_items_with_their_key_as_description() { + let mut harness = harness(false); + open_menu(&mut harness); + let rename = harness.get_by_label("Rename"); + assert_eq!(rename.accesskit_node().role(), egui::accesskit::Role::MenuItem); + assert_eq!(rename.accesskit_node().description().as_deref(), Some("Key R")); + assert_eq!(rename.accesskit_node().data().keyboard_shortcut().as_deref(), Some("R")); + assert!(harness.get_by_label("Duplicate").accesskit_node().is_disabled(), "disabled rows are announced as disabled"); +} + +#[test] +fn pressing_the_letter_runs_the_row() { + let mut harness = harness(false); + open_menu(&mut harness); + press(&mut harness, Key::R); + assert_eq!(harness.state().chosen, vec!["Rename"]); + assert!(harness.query_by_label("Rename").is_none(), "the menu closed"); +} + +#[test] +fn a_disabled_row_ignores_its_letter() { + let mut harness = harness(false); + open_menu(&mut harness); + press(&mut harness, Key::D); + assert_eq!(harness.state().chosen, Vec::<&str>::new()); + assert!(harness.query_by_label("Rename").is_some(), "the menu stays open"); +} + +#[test] +fn tab_cycles_inside_the_menu_and_never_escapes() { + let mut harness = harness(false); + open_menu(&mut harness); + let mut seen = Vec::new(); + for _ in 0..4 { + press(&mut harness, Key::Tab); + seen.push(focused(&harness)); + } + assert_eq!(seen, vec![Some("Rename"), Some("Close"), Some("Open"), Some("Rename")], "disabled rows are skipped, focus wraps"); + harness.key_press_modifiers(Modifiers::SHIFT, Key::Tab); + harness.run(); + assert_eq!(focused(&harness), Some("Open")); +} + +#[test] +fn escape_closes_and_returns_focus_to_the_opener() { + let mut harness = harness(false); + harness.get_by_label("Opener").focus(); + harness.run(); + open_menu(&mut harness); + assert_eq!(focused(&harness), Some("Open")); + press(&mut harness, Key::Escape); + harness.run(); + assert!(harness.query_by_label("Rename").is_none(), "the menu closed"); + assert!(harness.get_by_label("Opener").is_focused(), "focus is back on the control that opened the menu"); +} + +#[test] +fn vim_keys_move_and_activate() { + let mut harness = harness(true); + open_menu(&mut harness); + press(&mut harness, Key::J); + assert_eq!(focused(&harness), Some("Rename")); + press(&mut harness, Key::J); + assert_eq!(focused(&harness), Some("Close"), "j skips the disabled row"); + press(&mut harness, Key::K); + assert_eq!(focused(&harness), Some("Rename")); + harness.key_press_modifiers(Modifiers::SHIFT, Key::G); + harness.run(); + assert_eq!(focused(&harness), Some("Close"), "G jumps to the last row"); + press(&mut harness, Key::G); + assert_eq!(focused(&harness), Some("Close"), "one g waits for the second"); + press(&mut harness, Key::G); + assert_eq!(focused(&harness), Some("Open"), "gg jumps to the first row"); + press(&mut harness, Key::J); + press(&mut harness, Key::L); + assert_eq!(harness.state().chosen, vec!["Rename"], "l activates the focused row"); +} + +#[test] +fn vim_h_closes_the_menu_and_ctrl_n_p_work_without_vim() { + let mut harness = harness(true); + open_menu(&mut harness); + press(&mut harness, Key::H); + assert!(harness.query_by_label("Rename").is_none(), "h closes like Esc"); + let mut plain = self::harness(false); + open_menu(&mut plain); + plain.key_press_modifiers(Modifiers::CTRL, Key::N); + plain.run(); + assert_eq!(focused(&plain), Some("Rename")); + plain.key_press_modifiers(Modifiers::CTRL, Key::P); + plain.run(); + assert_eq!(focused(&plain), Some("Open")); + press(&mut plain, Key::ArrowDown); + assert_eq!(focused(&plain), Some("Rename")); + press(&mut plain, Key::End); + assert_eq!(focused(&plain), Some("Close")); + press(&mut plain, Key::Home); + assert_eq!(focused(&plain), Some("Open")); +} + +#[test] +fn vim_mode_letters_j_k_h_l_g_are_never_mnemonics() { + // "Link" would be L in a plain menu; in Vim mode its key is another letter and l only moves. + let letters = menu_keys::assign(&[("Link", None), ("Gamma", None), ("Hello", None), ("Kilo", None)], menu_keys::reserved_for(true)); + for letter in letters.iter().flatten() { + assert!(!menu_keys::VIM_RESERVED.contains(letter)); + } +} + +fn texts(shape: &egui::Shape, out: &mut Vec) { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().for_each(|shape| texts(shape, out)), + egui::Shape::Text(text) => out.push(text.galley.text().to_owned()), + _ => {} + } +} + +/// Runs the fixture on a bare context and returns every text the last frame painted. +fn painted(vim: bool) -> Vec { + let ctx = egui::Context::default(); + let mut fx = Fixture { tokens: tokens(), vim, warmed: true, chosen: Vec::new(), opener: Cell::new(Pos2::ZERO) }; + fonts::install(&ctx); + let input = |events: Vec| egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(480.0, 400.0))), + events, + ..Default::default() + }; + let mut first = ctx.run_ui(input(Vec::new()), |ui| draw(ui, &mut fx)); + first.textures_delta.clear(); + let at = fx.opener.get(); + let click = |pressed| Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed, modifiers: Modifiers::NONE }; + let mut second = ctx.run_ui(input(vec![Event::PointerMoved(at), click(true), click(false)]), |ui| draw(ui, &mut fx)); + second.textures_delta.clear(); + let mut output = ctx.run_ui(input(Vec::new()), |ui| draw(ui, &mut fx)); + output.textures_delta.clear(); + let mut out = Vec::new(); + output.shapes.iter().for_each(|shape| texts(&shape.shape, &mut out)); + out +} + +#[test] +fn keycaps_are_drawn_beside_every_row() { + let painted = painted(false); + for cap in ["O", "R", "D", "C"] { + assert!(painted.iter().any(|text| text == cap), "keycap {cap} missing in {painted:?}"); + } +} + +#[test] +fn tab_menu_close_runs_from_its_key() { + let mut tab_set = tabs::Tabs::new(); + tab_set.open("a.md"); + let tokens = tokens(); + let closed: Cell> = Cell::new(None); + let mut warmed = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(800.0, 300.0)).build_ui(|ui| { + if !warmed { + fonts::install(ui.ctx()); + warmed = true; + return; + } + motion::set_enabled(ui.ctx(), false); + if let Some(path) = tabs::show(ui, &tokens, &tab_set, &[]).closed { + closed.set(Some(path)); + } + }); + harness.run(); + let at = Pos2::new(40.0, 24.0); + harness.event(Event::PointerMoved(at)); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + harness.run(); + assert!(harness.query_by_label("Close").is_some(), "the tab menu opened"); + harness.key_press(Key::C); + harness.run(); + assert_eq!(closed.take().as_deref(), Some("a.md")); +} + +/// Renders a tab context menu (the same rows code as the Explorer menu) to the PNG named by `MENU_KEYS_PNG`: +/// `MENU_KEYS_PNG=/tmp/menu.png cargo test --test menu_keys -- --ignored`. +#[test] +#[ignore = "writes a screenshot for the design review"] +fn tab_menu_screenshot() { + let Ok(out) = std::env::var("MENU_KEYS_PNG") else { return }; + let tokens = tokens(); + let mut tab_set = tabs::Tabs::new(); + tab_set.open("a.md"); + let mut warmed = false; + let mut harness = egui_kittest::Harness::builder().with_size(egui::vec2(420.0, 420.0)).build_ui(|ui| { + if !warmed { + fonts::install(ui.ctx()); + warmed = true; + return; + } + motion::set_enabled(ui.ctx(), false); + let _ = tabs::show(ui, &tokens, &tab_set, &[]); + }); + harness.run(); + let at = Pos2::new(40.0, 24.0); + harness.event(Event::PointerMoved(at)); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: true, modifiers: Modifiers::NONE }); + harness.event(Event::PointerButton { pos: at, button: PointerButton::Secondary, pressed: false, modifiers: Modifiers::NONE }); + harness.run(); + let image = harness.render().expect("render"); + image.save(out).expect("png saved"); +} diff --git a/crates/ui-egui/tests/resizable_cards.rs b/crates/ui-egui/tests/resizable_cards.rs index 412af1e..74ac5a6 100644 --- a/crates/ui-egui/tests/resizable_cards.rs +++ b/crates/ui-egui/tests/resizable_cards.rs @@ -75,7 +75,9 @@ fn pointer(pos: Pos2, pressed: bool) -> Vec { } fn key(alt: bool) -> Vec { - vec![Event::Key { key: Key::B, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers { command: true, alt, ..Modifiers::NONE } }] + // Cmd+B toggles the sidebar and Cmd+Alt+\ the right panel (Cmd+Alt+B is the editor's block menu). + let key = if alt { Key::Backslash } else { Key::B }; + vec![Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers { command: true, alt, ..Modifiers::NONE } }] } #[test]