feat: shell round: unlimited tabs with hibernation, hotkeys registry, menu keycaps, Explorer scroll and tooltips, Comments tab, delete confirm
Tabs: no cap (a 5000 bound on the saved list only); only the active and 5 recent tabs keep editor state, cold tabs keep caret, view and folds and reload on activation, dirty tabs are never dropped; the strip lays out at most 64 tabs and the overflow list is searchable and virtualised; restore loads only the active tab. Hotkeys: one registry with defaults for tab and panel navigation and opening Outline, Related, Backlinks, Assistant, Lint and Comments, shown in the tooltips; F6 cycles focus; Ctrl+h/l in vim mode; panels are accessibility landmarks. Menus: keycap letters that run the item, vim j/k/h/l/gg/G, focus into the menu and back on Esc, menu and menu-item roles. Explorer: horizontal scroll (Shift+wheel) and full-name tooltips. Comments tab in the right panel (MiniMax M3.1 Flash). Delete confirmation dialog from an earlier session, with its debug code and deadlock removed. Built by Claude Sonnet and MiniMax M3.1 Flash; the whole tree verified with cargo xtask ci (fmt, clippy -D warnings, all tests, layers, tree, i18n) green on a copy of the working tree. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
@@ -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<String, TabEdit>,
|
||||
/// What every COLD tab (hibernated or closed) remembers; a few bytes each.
|
||||
pub meta: BTreeMap<String, TabMeta>,
|
||||
/// Last-use tick per hot tab.
|
||||
used: BTreeMap<String, u64>,
|
||||
clock: u64,
|
||||
last_active: Option<String>,
|
||||
/// Widget ids of dropped editors whose egui undo memory is still to be cleared.
|
||||
pub(crate) released: Vec<egui::Id>,
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<usize>,
|
||||
/// 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<String>,
|
||||
/// 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<TabEdit> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Range<usize>>,
|
||||
}
|
||||
|
||||
/// 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<usize>,
|
||||
/// The replies of the thread, in order.
|
||||
pub replies: Vec<CommentReply>,
|
||||
}
|
||||
|
||||
/// What the tab shows.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct CommentData {
|
||||
/// The note the threads belong to.
|
||||
pub path: Option<String>,
|
||||
/// The threads, in document order.
|
||||
pub threads: Vec<CommentThread>,
|
||||
/// 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<Selection>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// The filter chip that is on.
|
||||
filter: Filter,
|
||||
/// The threads that are open.
|
||||
expanded: BTreeSet<String>,
|
||||
/// The text of the composer.
|
||||
compose: String,
|
||||
/// The reply drafts, by thread id.
|
||||
drafts: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
/// 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<CommentThread> {
|
||||
let Some(items) = result.get("comments").and_then(Value::as_array) else { return Vec::new() };
|
||||
let mut threads: Vec<CommentThread> = 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<CommentThread> {
|
||||
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<CommentReply> {
|
||||
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::<PanelState>(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::<Vec<_>>(), ["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::<Vec<_>>();
|
||||
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…");
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<St
|
||||
outline,
|
||||
related: crate::related_panel::RelatedData::default(),
|
||||
lint: crate::lint_panel::LintData::default(),
|
||||
comments: crate::comment_panel::CommentData::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +113,8 @@ pub enum RightTab {
|
||||
Lint,
|
||||
/// Headings of the active note.
|
||||
Outline,
|
||||
/// The comment threads of the active note.
|
||||
Comments,
|
||||
/// Conversation with the agent configured in Settings.
|
||||
Assistant,
|
||||
}
|
||||
@@ -126,6 +131,8 @@ pub struct RightInputs<'a> {
|
||||
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<String>,
|
||||
/// 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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -80,6 +80,17 @@ pub fn register(registry: &mut Registry<UiState>) -> 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::<SidebarTab>(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 {
|
||||
|
||||
@@ -109,5 +109,6 @@ fn row(ui: &mut Ui, tokens: &Tokens, node: &TagNode, collapsed: &mut BTreeSet<St
|
||||
painter.galley(pill.center() - count.size() / 2.0, count, color(c.text_muted));
|
||||
let text = Rect::from_min_max(Pos2::new(icon.right() + ICON_GAP, rect.top()), Pos2::new(pill.left() - ICON_GAP, rect.bottom()));
|
||||
painter.with_clip_rect(text.intersect(ui.clip_rect())).text(text.left_center(), Align2::LEFT_CENTER, &node.tag, FontId::proportional(14.0), color(c.text));
|
||||
crate::tree::name_tip(ui, tokens, &response, &node.tag, &node.tag);
|
||||
response.clicked() && !toggled
|
||||
}
|
||||
|
||||
@@ -15,9 +15,16 @@ use crate::theme::{Tokens, color};
|
||||
|
||||
mod crumbs;
|
||||
mod filter;
|
||||
mod hscroll;
|
||||
mod name_tip;
|
||||
mod row_actions;
|
||||
mod rows;
|
||||
#[cfg(test)]
|
||||
mod scroll_tests;
|
||||
pub use crumbs::crumbs;
|
||||
pub use filter::{cached_filtered, filtered};
|
||||
pub use hscroll::{MAX_CONTENT_WIDTH, content_width, row_width, wheel_to_horizontal};
|
||||
pub use name_tip::{DELAY_SECS as TIP_DELAY_SECS, name_tip};
|
||||
pub use rows::{FlatRow, OVERSCAN_ROWS, cached_rows, first_visible_row, flatten, row_top, row_window};
|
||||
|
||||
/// Row height, in points.
|
||||
@@ -128,6 +135,8 @@ pub struct TreeResponse {
|
||||
pub context: Option<String>,
|
||||
/// Context-menu choice, dispatched by the app.
|
||||
pub action: Option<crate::context_menu::MenuAction>,
|
||||
/// The hover trash button of a row was clicked: the app asks before deleting it.
|
||||
pub delete: Option<crate::context_menu::MenuAction>,
|
||||
/// A row was dropped on a folder: the move to make.
|
||||
pub moved: Option<Move>,
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Right-clicked path.
|
||||
context: Option<String>,
|
||||
/// The hover trash button was clicked: the path to delete.
|
||||
delete: Option<crate::context_menu::MenuAction>,
|
||||
}
|
||||
|
||||
/// 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<String>, selected: Option<&str>) -> RowOutcome {
|
||||
fn draw_row(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet<String>, 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<S
|
||||
let dy = (1.0 - drop) * -10.0;
|
||||
ui.scope(|ui| {
|
||||
ui.multiply_opacity(fade);
|
||||
draw_row_inner(ui, tokens, node, collapsed, selected, dy)
|
||||
draw_row_inner(ui, tokens, node, collapsed, selected, dy, writable)
|
||||
})
|
||||
.inner
|
||||
}
|
||||
@@ -597,7 +617,7 @@ fn paint_guides(ui: &Ui, tokens: &Tokens, node: &FlatRow, slot: Rect, selected:
|
||||
}
|
||||
|
||||
/// The row itself, drawn inside the fade scope of [`draw_row`].
|
||||
fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet<String>, selected: Option<&str>, dy: f32) -> RowOutcome {
|
||||
fn draw_row_inner(ui: &mut Ui, tokens: &Tokens, node: &FlatRow, collapsed: &BTreeSet<String>, 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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<FlatRow>>, 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::<Widest>(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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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<Harness<'static, ()>> {
|
||||
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");
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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<Request>, 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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<String> {
|
||||
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::<String>::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"));
|
||||
}
|
||||
}
|
||||
@@ -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::<usize>().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)
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -25,7 +25,7 @@ fn read_ui(value: &Value) -> Result<UiState, String> {
|
||||
|| !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());
|
||||
}
|
||||
|
||||
@@ -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<String> = wanted.into_iter().filter(exists).take(crate::tabs::MAX_TABS).collect();
|
||||
let wanted: Vec<String> = wanted.into_iter().filter(exists).take(crate::tabs::MAX_PERSISTED_TABS).collect();
|
||||
let open_now: Vec<String> = 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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Selection> {
|
||||
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) })
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<super::confirm::Confirm>,
|
||||
/// Remaining paths to close, stopping at each unsaved prompt.
|
||||
pub(in crate::shell::app) closes: VecDeque<String>,
|
||||
/// Deletion to run after every affected buffer was resolved.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<i32> {
|
||||
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<i32>) {
|
||||
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<usize>) {
|
||||
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<registry::Registry> {
|
||||
super::registry()
|
||||
}
|
||||
@@ -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),
|
||||
];
|
||||
@@ -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<i32>,
|
||||
}
|
||||
|
||||
/// 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<Fired> {
|
||||
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
|
||||
}
|
||||
@@ -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> = [(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<NavFocus> {
|
||||
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::<NavFocus>(Id::new(PENDING))) == Some(card) {
|
||||
ctx.data_mut(|data| data.remove_temp::<NavFocus>(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::<Snapshot>(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::<Rect>(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));
|
||||
}
|
||||
@@ -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<NavFocus>,
|
||||
/// 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<RwLock<Arc<Registry>>> = OnceLock::new();
|
||||
|
||||
fn cell() -> &'static RwLock<Arc<Registry>> {
|
||||
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<Registry> {
|
||||
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<String, String>) -> Vec<String> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<i32>,
|
||||
/// 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<Entry>,
|
||||
}
|
||||
|
||||
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<String, String>) -> (Self, Vec<String>) {
|
||||
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<Binding> {
|
||||
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<String> {
|
||||
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<Conflict> {
|
||||
let mut found: Vec<Conflict> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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<usize> {
|
||||
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<PathBuf>,
|
||||
seen: Vec<String>,
|
||||
stack: Vec<String>,
|
||||
}
|
||||
|
||||
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<String> {
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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<Event>) -> Vec<Fired> {
|
||||
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::<String>::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<String> {
|
||||
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<String> = (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");
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>) -> 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>) -> 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<SidebarTab>, &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<SidebarTab>, &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<RightTab>, &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<RightTab>, &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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -47,6 +47,8 @@ pub struct VimNav {
|
||||
pub(crate) command_history: Vec<String>,
|
||||
/// 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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
//! Copy for the review panels: comments and suggestions.
|
||||
|
||||
pub mod comment_panel;
|
||||
@@ -8,4 +8,5 @@ table! {
|
||||
follow = "ui.explorer.follow";
|
||||
pinned = "ui.explorer.pinned";
|
||||
clear_filter = "ui.explorer.clear-filter";
|
||||
delete = "ui.explorer.delete";
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
// 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> = 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::*;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -224,6 +224,8 @@ pub struct WhiteboardView {
|
||||
pub(in crate::views::whiteboard_view) palette_rect: Option<egui::Rect>,
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Answer> {
|
||||
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,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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<MenuAction> {
|
||||
/// Conventional keys; the rest follow `menu_keys::assign`. Delete takes X so D stays with Duplicate.
|
||||
fn preferred_key(kind: MenuKind) -> Option<char> {
|
||||
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<MenuAction> {
|
||||
ui.set_width(WIDTH);
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
crate::menu_keys::name_menu(ui, name);
|
||||
let items: Vec<(&str, Option<char>)> = 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))
|
||||
}
|
||||
|
||||
@@ -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<Item = char> + '_ {
|
||||
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<char> {
|
||||
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<char>)], reserved: &[char]) -> Vec<Option<char>> {
|
||||
let mut out: Vec<Option<char>> = vec![None; items.len()];
|
||||
let mut used: Vec<char> = 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> {
|
||||
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<char>)> {
|
||||
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<char> = 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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::<bool>(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<char>,
|
||||
/// 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<char>,
|
||||
}
|
||||
|
||||
/// 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<Seen>,
|
||||
pub(crate) cur: Vec<Seen>,
|
||||
pub(crate) used: Vec<char>,
|
||||
/// Row a mnemonic or `l` just activated.
|
||||
pub(crate) activate: Option<Id>,
|
||||
/// Widget that had focus when the menu opened; Esc gives it back.
|
||||
pub(crate) opener: Option<Id>,
|
||||
/// 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<R>(ctx: &Context, key: Id, f: impl FnOnce(&mut State) -> R) -> R {
|
||||
ctx.data_mut(|d| f(d.get_temp_mut_or_default::<State>(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::<Vec<_>>().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());
|
||||
}
|
||||
}
|
||||
@@ -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<Id> = 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<Step> {
|
||||
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<Id>, focused: Option<Id>) {
|
||||
ctx.memory_mut(|m| match (opener, focused) {
|
||||
(Some(opener), _) => m.request_focus(opener),
|
||||
(None, Some(focused)) => m.surrender_focus(focused),
|
||||
(None, None) => {}
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
@@ -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;
|
||||
|
||||
@@ -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<f32> = 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<egui::Event>) -> (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;
|
||||
|
||||
@@ -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<usize> {
|
||||
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<usize>,
|
||||
/// 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<usize>, 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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RowChoice> {
|
||||
/// 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<RowChoice> {
|
||||
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<RowChoice> {
|
||||
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<usize> {
|
||||
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::<ListState>(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::<ListState>(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
|
||||
}
|
||||
|
||||
@@ -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<egui::Event>) -> (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"));
|
||||
}
|
||||
@@ -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<Session>,
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
@@ -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<Self> {
|
||||
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<Event>) -> 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<Event>, 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:?}");
|
||||
}
|
||||
@@ -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<Pos2>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String> {
|
||||
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<Event>| 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<Option<String>> = 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");
|
||||
}
|
||||
@@ -75,7 +75,9 @@ fn pointer(pos: Pos2, pressed: bool) -> Vec<Event> {
|
||||
}
|
||||
|
||||
fn key(alt: bool) -> Vec<Event> {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user