diff --git a/Cargo.lock b/Cargo.lock index 40cc8052..c7efd7a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1880,6 +1880,7 @@ dependencies = [ "gpui-pre", "log", "settings", + "window-wrapper", ] [[package]] @@ -10835,6 +10836,7 @@ dependencies = [ "serde_json", "settings", "table", + "window-wrapper", ] [[package]] diff --git a/assets/icons/map-pin.svg b/assets/icons/map-pin.svg new file mode 100644 index 00000000..9029b306 --- /dev/null +++ b/assets/icons/map-pin.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/message-square.svg b/assets/icons/message-square.svg new file mode 100644 index 00000000..89008980 --- /dev/null +++ b/assets/icons/message-square.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/mouse-pointer-2.svg b/assets/icons/mouse-pointer-2.svg new file mode 100644 index 00000000..21a7c20a --- /dev/null +++ b/assets/icons/mouse-pointer-2.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/pencil.svg b/assets/icons/pencil.svg new file mode 100644 index 00000000..f458a3f5 --- /dev/null +++ b/assets/icons/pencil.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/rectangle.svg b/assets/icons/rectangle.svg new file mode 100644 index 00000000..1df393fd --- /dev/null +++ b/assets/icons/rectangle.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/square-pen.svg b/assets/icons/square-pen.svg new file mode 100644 index 00000000..56ca1e39 --- /dev/null +++ b/assets/icons/square-pen.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index ef91e7b6..513d128c 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -1,7 +1,8 @@ //! Our own icons in front of `gpui_component_assets`, which is otherwise the only asset source. //! The bundled icon set has no filled panel glyphs (the title bar's "this dock is open" state) and //! no picture glyph (visual search), and none of the pop-out viewer's window, pin or missing-file -//! glyphs, nor the viewer's fit-to-width glyph, so those SVGs are ours, copied from Lucide. +//! glyphs, nor the viewer's fit-to-width or annotation glyphs, so those SVGs are ours, copied from +//! Lucide. use std::borrow::Cow; @@ -9,7 +10,7 @@ use gpui::{AssetSource, Result, SharedString}; pub struct Assets; -const OWN: [(&str, &str); 10] = [ +const OWN: [(&str, &str); 16] = [ ( "icons/history.svg", include_str!("../../../assets/icons/history.svg"), @@ -50,6 +51,30 @@ const OWN: [(&str, &str); 10] = [ "icons/move-horizontal.svg", include_str!("../../../assets/icons/move-horizontal.svg"), ), + ( + "icons/square-pen.svg", + include_str!("../../../assets/icons/square-pen.svg"), + ), + ( + "icons/mouse-pointer-2.svg", + include_str!("../../../assets/icons/mouse-pointer-2.svg"), + ), + ( + "icons/rectangle.svg", + include_str!("../../../assets/icons/rectangle.svg"), + ), + ( + "icons/map-pin.svg", + include_str!("../../../assets/icons/map-pin.svg"), + ), + ( + "icons/message-square.svg", + include_str!("../../../assets/icons/message-square.svg"), + ), + ( + "icons/pencil.svg", + include_str!("../../../assets/icons/pencil.svg"), + ), ]; impl AssetSource for Assets { diff --git a/crates/app/src/export.rs b/crates/app/src/export.rs index b28ce414..60762ac2 100644 --- a/crates/app/src/export.rs +++ b/crates/app/src/export.rs @@ -398,6 +398,9 @@ fn sheet_notes(file: &Path, grid: &ExportGrid) -> anyhow::Result> message: note.message, created_at: note.created_at, author: note.author, + id: Some(note.id), + region: note.region.and_then(|r| serde_json::to_string(&r).ok()), + kind: note.kind.map(|kind| kind.key().to_string()), }) .collect::>(); Ok(qrate_export::sheet_notes( diff --git a/crates/app/src/macos_services.rs b/crates/app/src/macos_services.rs index 42047e4b..3d0c6c07 100644 --- a/crates/app/src/macos_services.rs +++ b/crates/app/src/macos_services.rs @@ -1,4 +1,8 @@ -use std::{cell::RefCell, ffi::CStr, sync::OnceLock}; +use std::{ + cell::RefCell, + ffi::{CStr, c_void}, + sync::OnceLock, +}; use cocoa::{ base::{id, nil}, @@ -28,7 +32,7 @@ pub(crate) fn register(sender: async_channel::Sender) { .expect("qrate's folder service class is registered once"); provider.add_method( sel!(openWithQrate:userData:error:), - open_folder as extern "C" fn(&Object, Sel, id, id, *mut id), + open_folder as extern "C" fn(&Object, Sel, id, id, *mut c_void), ); let object: id = msg_send![provider.register(), new]; let app: id = msg_send![class!(NSApplication), sharedApplication]; @@ -37,7 +41,7 @@ pub(crate) fn register(sender: async_channel::Sender) { } } -extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut id) { +extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut c_void) { unsafe { let classes: id = msg_send![class!(NSArray), arrayWithObject: class!(NSURL)]; let urls: id = msg_send![pasteboard, readObjectsForClasses: classes options: nil]; @@ -64,7 +68,7 @@ extern "C" fn open_folder(_: &Object, _: Sel, pasteboard: id, _: id, error: *mut if !error.is_null() { let message = NSString::alloc(nil).init_str("Choose a folder to start a qrate project."); - *error = msg_send![message, autorelease]; + *error.cast::() = msg_send![message, autorelease]; } } } diff --git a/crates/app/src/status_items/plugin_bar.rs b/crates/app/src/status_items/plugin_bar.rs index 10644e59..09c16c71 100644 --- a/crates/app/src/status_items/plugin_bar.rs +++ b/crates/app/src/status_items/plugin_bar.rs @@ -33,6 +33,43 @@ impl PluginBar { _sub: cx.observe_global::(|_, cx| cx.notify()), } } + + /// Menu equivalents of the bar contributions, using the same command dispatcher. + pub fn overflow_menu( + bar: Bar, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + let items = [Side::Left, Side::Right] + .into_iter() + .flat_map(|side| BarContributions::at(bar, side, cx)) + .collect::>(); + if items.is_empty() { + return menu; + } + menu.submenu("Plugin controls", window, cx, move |menu, window, cx| { + items.iter().cloned().fold(menu, |menu, (plugin, item)| { + let mut actions = Vec::new(); + for action in [item.left, item.right].into_iter().flatten() { + match action { + BarAction::Command(command) => actions.push((item.text.clone(), command)), + BarAction::Menu(commands) => actions.extend(commands), + } + } + // One command may be exposed by both mouse buttons. + let mut seen = std::collections::HashSet::new(); + actions.retain(|(_, command)| seen.insert(command.clone())); + if actions.is_empty() { + menu.label(item.text) + } else { + menu.submenu(item.text, window, cx, move |menu, _, _| { + entries(plugin.clone(), actions.clone(), menu) + }) + } + }) + }) + } } /// Run one of a plugin's commands against whatever the table has selected. The command answers on diff --git a/crates/app/src/title_items/mod.rs b/crates/app/src/title_items/mod.rs index 8625b738..37527848 100644 --- a/crates/app/src/title_items/mod.rs +++ b/crates/app/src/title_items/mod.rs @@ -1,12 +1,14 @@ pub mod launcher_bar; mod update_notice; +use std::rc::Rc; + use gpui::*; use gpui_component::{ ActiveTheme, dock::{DockArea, DockPlacement}, h_flex, - menu::AppMenuBar, + menu::{AppMenuBar, PopupMenu, PopupMenuItem}, }; use plugin_api::{Bar, Side}; @@ -39,6 +41,56 @@ impl Render for FeedbackButton { /// are registered on the left by default. Right: generic open/close buttons for each dock. pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> TitleBarRegistry { let mut registry = TitleBarRegistry::default(); + let menu_dock = dock.clone(); + registry.overflow_menu = Some(Rc::new(move |menu, window, cx, focus| { + let menus = gpui_component::GlobalState::global(cx).app_menus().to_vec(); + let menu = menus.into_iter().fold(menu, |menu, app_menu| { + let focus = focus.clone(); + let submenu = PopupMenu::build(window, cx, move |menu, window, cx| { + append_menu_items(menu, app_menu.items, window, cx, focus) + }); + menu.item(PopupMenuItem::submenu(app_menu.name, submenu).disabled(app_menu.disabled)) + }); + let menu = PluginBar::overflow_menu(Bar::Title, menu, window, cx); + let dock = menu_dock.clone(); + let dock_focus = focus.clone(); + let menu = menu + .separator() + .item(PopupMenuItem::new("Feedback").on_click(|_, _, cx| { + cx.open_url(&crate::logging::feedback_url(cx, None)); + })) + .submenu("Panels", window, cx, move |menu, _, cx| { + let menu = match &dock_focus { + Some(focus) => menu.action_context(focus.clone()), + None => menu, + }; + [ + ( + "Left dock", + DockPlacement::Left, + Box::new(ToggleLeftDock) as Box, + ), + ( + "Bottom dock", + DockPlacement::Bottom, + Box::new(ToggleBottomDock), + ), + ( + "Right dock", + DockPlacement::Right, + Box::new(ToggleRightDock), + ), + ] + .into_iter() + .fold(menu, |menu, (label, placement, action)| { + let open = dock + .upgrade() + .is_some_and(|dock| dock.read(cx).is_dock_open(placement)); + menu.menu_with_check(label, open, action) + }) + }); + update_notice::overflow_menu(menu, cx) + })); // The library's menu bar, not a row of independent dropdowns: it holds the "a menu is open" // state that makes hovering a sibling switch to it, and arrow keys walk between them. @@ -89,3 +141,37 @@ pub fn build_title_bar_registry(cx: &mut App, dock: WeakEntity) -> Tit registry } + +/// Convert the same owned menus AppMenuBar reads, preserving action, check, and disabled state. +fn append_menu_items( + mut menu: PopupMenu, + items: Vec, + window: &mut Window, + cx: &mut Context, + focus: Option, +) -> PopupMenu { + if let Some(focus) = &focus { + menu = menu.action_context(focus.clone()); + } + for item in items { + menu = match item { + OwnedMenuItem::Action { + name, + action, + checked, + disabled, + .. + } => menu.menu_with_check_and_disabled(name, checked, action.boxed_clone(), disabled), + OwnedMenuItem::Separator => menu.separator(), + OwnedMenuItem::Submenu(submenu) => { + let focus = focus.clone(); + let child = PopupMenu::build(window, cx, move |menu, window, cx| { + append_menu_items(menu, submenu.items, window, cx, focus) + }); + menu.item(PopupMenuItem::submenu(submenu.name, child).disabled(submenu.disabled)) + } + OwnedMenuItem::SystemMenu(_) => menu, + }; + } + menu.scrollable(true) +} diff --git a/crates/app/src/title_items/update_notice.rs b/crates/app/src/title_items/update_notice.rs index ce662f55..64d67ce5 100644 --- a/crates/app/src/title_items/update_notice.rs +++ b/crates/app/src/title_items/update_notice.rs @@ -8,6 +8,48 @@ use gpui_component::{ }; use crate::update_check::{AutoUpdater, UpdateStatus}; +use gpui_component::menu::{PopupMenu, PopupMenuItem}; + +pub(super) fn overflow_menu(menu: PopupMenu, cx: &mut Context) -> PopupMenu { + let Some(updater) = AutoUpdater::get(cx) else { + return menu; + }; + if !updater.read(cx).visible() { + return menu; + } + let status = updater.read(cx).status().clone(); + let menu = menu.separator(); + let menu = match &status { + UpdateStatus::Downloading { version, .. } => menu.item( + PopupMenuItem::new(format!( + "Downloading qrate v{version}… {}%", + status.progress_percent().unwrap_or_default() + )) + .disabled(true), + ), + UpdateStatus::Ready { .. } => { + menu.item(PopupMenuItem::new("Restart to update").on_click(crate::restart_for_update)) + } + UpdateStatus::Restarting => menu.item(PopupMenuItem::new("Restarting…").disabled(true)), + UpdateStatus::Error { .. } => menu.item( + PopupMenuItem::new("Update failed — open About") + .on_click(|_, _, cx| crate::about::open_about_window(cx)), + ), + _ => return menu, + }; + if matches!( + status, + UpdateStatus::Ready { .. } | UpdateStatus::Error { .. } + ) { + menu.item( + PopupMenuItem::new("Dismiss update notice").on_click(move |_, _, cx| { + updater.update(cx, |updater, cx| updater.dismiss(cx)); + }), + ) + } else { + menu + } +} pub struct UpdateNotice { updater: Option>, diff --git a/crates/diagnostics/Cargo.toml b/crates/diagnostics/Cargo.toml index fc86c49e..30880bd0 100644 --- a/crates/diagnostics/Cargo.toml +++ b/crates/diagnostics/Cargo.toml @@ -8,6 +8,7 @@ log.workspace = true gpui.workspace = true gpui-component.workspace = true settings = { path = "../settings" } +window-wrapper = { path = "../window-wrapper" } [dev-dependencies] # `#[gpui::test]`, TestAppContext, and run_test are all behind this feature. diff --git a/crates/diagnostics/src/fixes.rs b/crates/diagnostics/src/fixes.rs index e9411672..66f723a2 100644 --- a/crates/diagnostics/src/fixes.rs +++ b/crates/diagnostics/src/fixes.rs @@ -404,7 +404,7 @@ mod tests { source: Source::Validator(source.into()), message: "not a known term".into(), group: None, - filed: None, + note: None, }], cx, ); @@ -516,7 +516,7 @@ mod tests { source: Source::Validator("LCSH".into()), message: "not a known term".into(), group: None, - filed: None, + note: None, }, Diagnostic { location: location("Subject"), @@ -524,7 +524,7 @@ mod tests { source: Source::Validator("LCSH".into()), message: "also deprecated".into(), group: None, - filed: None, + note: None, }, ]; Diagnostics::set( diff --git a/crates/diagnostics/src/lib.rs b/crates/diagnostics/src/lib.rs index 7a609164..a005a4a8 100644 --- a/crates/diagnostics/src/lib.rs +++ b/crates/diagnostics/src/lib.rs @@ -6,6 +6,7 @@ //! panel asks the app to reveal a cell. pub mod fixes; +pub mod note_card; mod panel; mod validator; pub use fixes::{Fix, FixProviders, GroupFix, GroupFixProviders, GroupMember, SourceActions}; @@ -20,7 +21,8 @@ use std::path::PathBuf; use gpui::{App, Global, Hsla, SharedString}; use gpui_component::ActiveTheme as _; -use settings::history::{Change, Entry, Origin}; +use settings::history::{Change, Entry, EntryId, Origin}; +pub use settings::project::{NoteId, NoteKind, Region}; /// The one dataset a project can hold today. `__notes` keys by name so a second sheet is new /// rows rather than a new table. @@ -176,9 +178,29 @@ pub struct Diagnostic { pub source: Source, pub message: SharedString, pub group: Option, - /// Who filed this and when, for authored notes. Always `None` on a computed finding — a - /// validator's output is recomputed on open, so it has no history to carry. + /// Set on every authored note, never on a computed finding — a validator's output is + /// recomputed on open, so it has no identity or history to carry. + pub note: Option, +} + +/// What an authored note carries beyond its text. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NoteMeta { + pub id: NoteId, pub filed: Option, + /// Where on the row's file it points; `None` for a note on the data itself. + pub region: Option, + pub kind: Option, + /// When its words or kind last changed, and by whom. + pub edited: Option, +} + +/// Everything a note write can change. +#[derive(Clone, PartialEq, Eq, Debug)] +struct Body { + message: SharedString, + region: Option, + kind: Option, } impl Diagnostic { @@ -279,6 +301,11 @@ pub struct Diagnostics { /// a key carrying the column name would allocate a `SharedString` on every lookup and give /// the cost straight back. by_row: HashMap, Vec>, + /// The `items` index of each authored note, by its id. + by_note: HashMap, + /// `items` indices of the notes on regions of each `dataset_main` row's file, by the row's + /// stable id — what the viewer asks for on every pointer move. + regions: HashMap>, /// Which project's stored notes are loaded. `CurrentProject` is mutated by every /// project-scoped setting write, so its observer fires far more often than the project /// actually changes. @@ -320,8 +347,20 @@ impl Diagnostics { mut items: Vec, columns: &settings::columns::ColumnSettingsMap, ) { + let mut next = self.next_note_id(); for diagnostic in &mut items { if diagnostic.source == Source::Note { + // Every note in the store is addressable by id, whichever way it arrived. + diagnostic.note.get_or_insert_with(|| { + next += 1; + NoteMeta { + id: next - 1, + filed: None, + region: None, + kind: None, + edited: None, + } + }); continue; } if diagnostic.severity == Severity::Note { @@ -367,11 +406,23 @@ impl Diagnostics { fn reindex(&mut self) { self.by_row.clear(); + self.by_note.clear(); + self.regions.clear(); for (ix, diagnostic) in self.items.iter().enumerate() { self.by_row .entry(diagnostic.location.row) .or_default() .push(ix); + let Some(note) = diagnostic.note.as_ref() else { + continue; + }; + self.by_note.insert(note.id, ix); + if let Some(row_id) = diagnostic.location.row_id + && note.region.is_some() + && diagnostic.location.dataset == DATASET_MAIN + { + self.regions.entry(row_id).or_default().push(ix); + } } } @@ -429,18 +480,26 @@ impl Diagnostics { Self::at(dataset, row, column, cx).map(|d| d.severity).min() } - /// The note filed here, if any — what the `Notes ▸` menu offers to edit rather than add. The - /// *first* of them: editing is how a single note is corrected, while a second observation - /// about the same item is added rather than overwriting the first. + /// The region-less note filed here, if any — what the grid's `Notes ▸` menu offers to edit + /// rather than add. The *first* of them: editing is how a single note is corrected, while a + /// second observation about the same item is added rather than overwriting the first. pub fn note_at( dataset: &str, row: Option, column: Option<&str>, cx: &App, ) -> Option { - Self::at(dataset, row, column, cx) - .find(|d| d.source == Source::Note) - .map(|d| d.message.clone()) + Self::plain_note_at(dataset, row, column, cx).map(|d| d.message.clone()) + } + + fn plain_note_at<'a>( + dataset: &'a str, + row: Option, + column: Option<&'a str>, + cx: &'a App, + ) -> Option<&'a Diagnostic> { + Self::notes_at(dataset, row, column, cx) + .find(|d| d.note.as_ref().is_none_or(|n| n.region.is_none())) } /// Every note filed here, oldest first — what the Notes panel lists. An item accumulates @@ -476,97 +535,270 @@ impl Diagnostics { .filter(move |d| d.location.dataset == dataset && d.source == Source::Note) } - /// File another note here without disturbing the ones already at this location. `set_note`'s - /// counterpart: that one corrects, this one adds. - #[cfg(test)] - pub fn add_note(location: Location, message: SharedString, cx: &mut App) { + /// One note, by the id history and the note card address it by. + pub fn note(id: NoteId, cx: &App) -> Option<&Diagnostic> { + let this = cx.try_global::()?; + this.by_note.get(&id).map(|&ix| &this.items[ix]) + } + + /// The notes on regions of one item's file, by the item's stable id. + pub fn regions_in_row( + row_id: settings::project::RowId, + cx: &App, + ) -> impl Iterator { + cx.try_global::().into_iter().flat_map(move |this| { + this.regions + .get(&row_id) + .map_or([].as_slice(), Vec::as_slice) + .iter() + .map(move |&ix| &this.items[ix]) + }) + } + + fn next_note_id(&self) -> NoteId { + // ponytail: a timestamp, so an id a deleted note left in the log is never handed out again + // without storing a counter. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as NoteId); + let last = self + .items + .iter() + .filter_map(|d| d.note.as_ref()) + .map(|n| n.id) + .max(); + now.max(last.unwrap_or(0) + 1) + } + + /// File a new note beside whatever is already at `location`, on `region` of the row's file when + /// it is about part of the picture. An empty message files nothing. + pub fn file_note( + location: Location, + region: Option, + kind: Option, + message: SharedString, + origin: Origin, + cx: &mut App, + ) -> Option { if message.trim().is_empty() { - return; + return None; } - let filed = Self::filed_now(cx); - let logged = Self::note_entry(&location, None, Some(&message), Origin::Typed, cx); - let this = cx.default_global::(); - this.items.push(Diagnostic { - location, - severity: Severity::Note, - source: Source::Note, + let id = cx.default_global::().next_note_id(); + let body = Body { message, - group: None, - filed, + region, + kind, + }; + Self::put(id, location, Some(body), origin, cx); + Some(id) + } + + /// Rewrite one note's text and kind, keeping where it points; empty text deletes it. + pub fn edit_note( + id: NoteId, + message: SharedString, + kind: Option, + origin: Origin, + cx: &mut App, + ) { + let Some((location, mut body)) = Self::body(id, cx) else { + return; + }; + body.message = message; + body.kind = kind; + let after = (!body.message.trim().is_empty()).then_some(body); + Self::put(id, location, after, origin, cx); + } + + /// Put a note back the way `change` found it, as a restore of entry `from`: its words, kind and + /// region, or gone if it did not exist yet. `row` and `column` are its current address. + pub fn restore_note( + change: &Change, + row: Option, + column: Option, + from: EntryId, + cx: &mut App, + ) { + let Change::Note { + row: row_id, + before, + id: Some(id), + region_before, + kind_before, + .. + } = change + else { + return; + }; + let location = Location { + dataset: DATASET_MAIN.into(), + row, + row_id: *row_id, + column, + }; + let body = before.as_ref().map(|message| Body { + message: message.clone().into(), + region: *region_before, + kind: *kind_before, }); - this.reindex(); - this.persist(&logged); + Self::put(*id, location, body, Origin::Restore(from), cx); } - /// Stamp for a note being filed right now: today's date from the project file's own clock, and - /// whoever the archivist has told the app they are. Either half may be missing. - fn filed_now(cx: &App) -> Option { - let date = cx - .try_global::() - .and_then(|p| settings::project::today(&p.file)) - .map(SharedString::from); - let author = settings::history::author(cx).map(SharedString::from); - (date.is_some() || author.is_some()).then_some(Filed { date, author }) + /// Move or reshape the region one note points at. + pub fn move_note(id: NoteId, region: Region, origin: Origin, cx: &mut App) { + let Some((location, mut body)) = Self::body(id, cx) else { + return; + }; + body.region = Some(region); + Self::put(id, location, Some(body), origin, cx); } - /// The log entry for one note going from `before` to `after`. Empty when nothing changed, or - /// when no project is open to log it in. - fn note_entry( - location: &Location, - before: Option<&SharedString>, - after: Option<&SharedString>, - origin: Origin, - cx: &App, - ) -> Vec { - if before == after || location.dataset != DATASET_MAIN { - return Vec::new(); - } - let change = Change::Note { - row: location.row_id, - column: location.column.as_ref().map(|c| c.to_string()), - before: before.map(|b| b.to_string()), - after: after.map(|a| a.to_string()), + fn body(id: NoteId, cx: &App) -> Option<(Location, Body)> { + let d = Self::note(id, cx)?; + let meta = d.note.as_ref()?; + let body = Body { + message: d.message.clone(), + region: meta.region, + kind: meta.kind, }; - vec![Entry::new( - origin, - vec![change], - settings::history::author(cx), - )] + Some((d.location.clone(), body)) } - /// Attach a note here, replacing any note already at this location; an empty `message` deletes - /// it. Writes straight through to `__notes` — this is a deliberate keystroke, not the hot path - /// the debounced setting writer exists for. + /// Correct the region-less note at `location`, or file one if there is none; an empty + /// `message` deletes it. The grid's note editor, which only ever shows that one note. pub fn set_note(location: Location, message: SharedString, origin: Origin, cx: &mut App) { - let (before, filed) = Self::notes_at( + let existing = Self::plain_note_at( &location.dataset, location.row, location.column.as_deref(), cx, ) - .next() - .map(|d| (Some(d.message.clone()), d.filed.clone())) - .unwrap_or_default(); - let after = (!message.trim().is_empty()).then_some(&message); - let logged = Self::note_entry(&location, before.as_ref(), after, origin, cx); - // Keeps the original filing stamp: correcting a transcription is not re-observing the - // item, and re-dating it to today would erase when the observation was actually made. - let filed = filed.or_else(|| Self::filed_now(cx)); + .and_then(|d| d.note.as_ref()) + .map(|n| (n.id, n.kind)); + match existing { + Some((id, kind)) => Self::edit_note(id, message, kind, origin, cx), + None => { + Self::file_note(location, None, None, message, origin, cx); + } + } + } + + /// Replay a logged note change, as a restore does: the note it names takes the change's + /// `after`, filed again at `location` if it has since been deleted. A change logged before notes + /// had ids names the region-less note at `location`. + pub fn apply_note(location: Location, change: &Change, origin: Origin, cx: &mut App) { + let Change::Note { + id, + after, + region_after, + kind_after, + .. + } = change + else { + return; + }; + let id = id + .or_else(|| { + Self::plain_note_at( + &location.dataset, + location.row, + location.column.as_deref(), + cx, + ) + .and_then(|d| d.note.as_ref()) + .map(|n| n.id) + }) + .unwrap_or_else(|| cx.default_global::().next_note_id()); + let after = after.as_ref().map(|text| Body { + message: text.into(), + region: *region_after, + kind: *kind_after, + }); + Self::put(id, location, after, origin, cx); + } + + /// Stamp for a note being filed or reworded at `at`: the local time to the minute, and whoever + /// the archivist has told the app they are. + fn stamp(at: i64, cx: &App) -> Filed { + let date = settings::history::local_times(&[at]) + .pop() + .map(|(day, time)| format!("{day} {time}").into()); + let author = settings::history::author(cx).map(SharedString::from); + Filed { date, author } + } + + /// Every note write ends here: note `id` becomes `after` — filed at `location` if it is new, + /// removed if `after` is `None` — then logged and saved together. Writes straight through to + /// `__notes`: this is a deliberate act, not the hot path the debounced setting writer exists for. + fn put(id: NoteId, location: Location, after: Option, origin: Origin, cx: &mut App) { + let author = settings::history::author(cx); + let before = Self::body(id, cx).map(|(_, body)| body); + if before == after { + return; + } + let entry = Entry::new(origin, Vec::new(), author); + let stamp = Self::stamp(entry.at, cx); let this = cx.default_global::(); - this.items - .retain(|d| d.source != Source::Note || d.location != location); - if !message.trim().is_empty() { - this.items.push(Diagnostic { + let at = this + .items + .iter() + .position(|d| d.note.as_ref().is_some_and(|n| n.id == id)); + let logged = (location.dataset == DATASET_MAIN).then(|| { + let change = Change::Note { + row: location.row_id, + column: location.column.as_ref().map(|c| c.to_string()), + before: before.as_ref().map(|b| b.message.to_string()), + after: after.as_ref().map(|b| b.message.to_string()), + id: Some(id), + region_before: before.as_ref().and_then(|b| b.region), + region_after: after.as_ref().and_then(|b| b.region), + kind_before: before.as_ref().and_then(|b| b.kind), + kind_after: after.as_ref().and_then(|b| b.kind), + }; + Entry { + changes: vec![change], + ..entry + } + }); + let reworded = before + .as_ref() + .zip(after.as_ref()) + .is_some_and(|(b, a)| b.message != a.message || b.kind != a.kind); + match (at, after) { + // In place, keeping its severity and its original filing stamp: correcting a + // transcription is not re-observing the item. + (Some(ix), Some(body)) => { + let d = &mut this.items[ix]; + d.message = body.message; + if let Some(note) = d.note.as_mut() { + note.region = body.region; + note.kind = body.kind; + if reworded { + note.edited = Some(stamp); + } + } + } + (Some(ix), None) => { + this.items.remove(ix); + } + (None, Some(body)) => this.items.push(Diagnostic { location, severity: Severity::Note, source: Source::Note, - message, + message: body.message, group: None, - filed, - }); + note: Some(NoteMeta { + id, + filed: Some(stamp), + region: body.region, + kind: body.kind, + edited: None, + }), + }), + (None, None) => {} } this.reindex(); - this.persist(&logged); + this.persist(logged.as_slice()); } /// Rebind authored notes to the current source positions after a structural edit or its undo. @@ -576,7 +808,7 @@ impl Diagnostics { row_ids: &[settings::project::RowId], origin: Origin, cx: &mut App, - ) { + ) -> Vec { let author = settings::history::author(cx); let positions: HashMap<_, _> = row_ids .iter() @@ -601,6 +833,11 @@ impl Diagnostics { column: item.location.column.as_ref().map(|c| c.to_string()), before: Some(item.message.to_string()), after: None, + id: item.note.as_ref().map(|n| n.id), + region_before: item.note.as_ref().and_then(|n| n.region), + region_after: None, + kind_before: item.note.as_ref().and_then(|n| n.kind), + kind_after: None, }); return false; }; @@ -611,11 +848,12 @@ impl Diagnostics { if changed { let logged = match dropped.is_empty() { true => Vec::new(), - false => vec![Entry::new(origin, dropped, author)], + false => vec![Entry::new(origin, dropped.clone(), author)], }; this.reindex(); this.persist(&logged); } + dropped } /// Follow a column rename, which re-keys every note addressed to the old name. @@ -640,21 +878,25 @@ impl Diagnostics { .items .iter() .filter(|d| d.source == Source::Note) - .map(|d| settings::project::StoredNote { + .filter_map(|d| Some((d, d.note.as_ref()?))) + .map(|(d, note)| settings::project::StoredNote { dataset: d.location.dataset.to_string(), row: d.location.row, row_id: d.location.row_id, column: d.location.column.as_ref().map(|c| c.to_string()), severity: d.severity.key().into(), message: d.message.to_string(), - created_at: d + created_at: note .filed .as_ref() .and_then(|f| f.date.as_ref().map(SharedString::to_string)), - author: d + author: note .filed .as_ref() .and_then(|f| f.author.as_ref().map(SharedString::to_string)), + id: note.id, + region: note.region, + kind: note.kind, }) .collect(); if let Err(err) = settings::project::write_notes(file, SOURCE_NOTE, ¬es, history) { @@ -686,6 +928,9 @@ fn load_project_notes(cx: &mut App) { } let stored = settings::project::read_notes(&file).unwrap_or_default(); + let edits = settings::history::note_edits(&file) + .inspect_err(|err| log::warn!("could not read when notes were last edited: {err}")) + .unwrap_or_default(); cx.default_global::().loaded = Some(file); // Republished under `Source::Note`, exactly as if a note had just been attached, so the @@ -703,13 +948,22 @@ fn load_project_notes(cx: &mut App) { source: Source::Note, message: n.message.clone().into(), group: None, - filed: match (&n.created_at, &n.author) { - (None, None) => None, - (date, author) => Some(Filed { - date: date.clone().map(SharedString::from), + note: Some(NoteMeta { + id: n.id, + filed: match (&n.created_at, &n.author) { + (None, None) => None, + (date, author) => Some(Filed { + date: date.clone().map(SharedString::from), + author: author.clone().map(SharedString::from), + }), + }, + region: n.region, + kind: n.kind, + edited: edits.get(&n.id).map(|(date, author)| Filed { + date: Some(date.clone().into()), author: author.clone().map(SharedString::from), }), - }, + }), }) .collect(); Diagnostics::set(&Source::Note, DATASET_MAIN, items, cx); @@ -740,7 +994,10 @@ mod tests { // Never `use super::*` here: this module's parent has `use gpui::*` in scope transitively, // and the chained glob makes gpui's `test` macro shadow the `#[test]` its own expansion // emits, recursing until rustc's stack overflows. - use crate::{DATASET_MAIN, Diagnostic, Diagnostics, Filed, Location, Severity, Source, init}; + use crate::{ + DATASET_MAIN, Diagnostic, Diagnostics, Filed, Location, NoteKind, Region, Severity, Source, + init, + }; use gpui::{App, SharedString, TestAppContext}; use settings::history::Origin; use settings::project::{CurrentProject, ProjectData, ProjectSpec, StoredNote}; @@ -797,8 +1054,22 @@ mod tests { row_id: None, column: column.map(Into::into), }; - Diagnostics::add_note(at(Some("Date taken")), "1962 is a guess".into(), cx); - Diagnostics::add_note(at(None), "whole print is faded".into(), cx); + Diagnostics::file_note( + at(Some("Date taken")), + None, + None, + "1962 is a guess".into(), + Origin::Typed, + cx, + ); + Diagnostics::file_note( + at(None), + None, + None, + "whole print is faded".into(), + Origin::Typed, + cx, + ); assert_eq!( Diagnostics::notes_at(DATASET_MAIN, Some(3), None, cx).count(), @@ -830,13 +1101,14 @@ mod tests { source, message: msg.into(), group: None, - filed: None, + note: None, } } - /// An item accumulates observations over decades. `add_note` files another beside the ones - /// already there; `set_note` corrects a single one and — the part worth pinning — keeps its - /// original filing stamp, because fixing a typo is not re-observing the object in 2026. + /// An item accumulates observations over decades. `file_note` files another beside the ones + /// already there; `set_note` corrects a single one and — the part worth pinning — leaves the + /// others alone and keeps its original filing stamp, because fixing a typo is not re-observing + /// the object in 2026. #[gpui::test] fn notes_accumulate_and_a_correction_keeps_its_original_date(cx: &mut TestAppContext) { cx.update(|cx| { @@ -848,12 +1120,16 @@ mod tests { }; let filed = |cx: &App| { Diagnostics::notes_at(DATASET_MAIN, Some(1), None, cx) - .map(|d| (d.message.to_string(), d.filed.clone())) + .map(|d| { + let filed = d.note.as_ref().and_then(|n| n.filed.clone()); + (d.message.to_string(), filed) + }) .collect::>() }; - Diagnostics::add_note(cell.clone(), "verso inscription".into(), cx); - Diagnostics::add_note(cell.clone(), "same backdrop as row 12".into(), cx); + for text in ["verso inscription", "same backdrop as row 12"] { + Diagnostics::file_note(cell.clone(), None, None, text.into(), Origin::Typed, cx); + } assert_eq!( Diagnostics::notes_at(DATASET_MAIN, Some(1), None, cx).count(), 2, @@ -865,9 +1141,12 @@ mod tests { date: Some("1998-03-04".into()), author: Some("MA".into()), }); - cx.default_global::().items[0].filed = original.clone(); + cx.default_global::().items[0] + .note + .as_mut() + .unwrap() + .filed = original.clone(); - // set_note collapses to one and keeps that stamp. Diagnostics::set_note( cell, "verso inscription, in pencil".into(), @@ -875,8 +1154,13 @@ mod tests { cx, ); let notes = filed(cx); - assert_eq!(notes.len(), 1, "a correction replaces rather than adds"); + assert_eq!( + notes.len(), + 2, + "a correction touches only the note it corrects" + ); assert_eq!(notes[0].0, "verso inscription, in pencil"); + assert_eq!(notes[1].0, "same backdrop as row 12"); assert_eq!( notes[0].1, original, "the filing date is not moved to today" @@ -884,6 +1168,88 @@ mod tests { }); } + /// A note on part of the image shares its row's location with the row's own note, so the grid's + /// editor must never reach it: editing or deleting the row note leaves the region where it was. + /// Moving the region, deleting it, and restoring it all go by id, and each is logged. + #[gpui::test] + fn a_region_note_is_addressed_by_id_alone(cx: &mut TestAppContext) { + cx.update(|cx| { + let row = Location { + dataset: DATASET_MAIN.into(), + row: Some(2), + row_id: Some(3), + column: None, + }; + let stamp = Region { + page: 1, + x: 6000, + y: 7000, + w: 1500, + h: 1200, + of: None, + }; + let id = Diagnostics::file_note( + row.clone(), + Some(stamp), + Some(NoteKind::Transcription), + "customs stamp, Lisbon".into(), + Origin::Typed, + cx, + ) + .unwrap(); + Diagnostics::set_note(row.clone(), "faded".into(), Origin::Typed, cx); + Diagnostics::set_note(row.clone(), "".into(), Origin::Clear, cx); + assert_eq!(Diagnostics::note_at(DATASET_MAIN, Some(2), None, cx), None); + let region = |cx: &App| Diagnostics::note(id, cx).and_then(|d| d.note.as_ref()?.region); + assert_eq!( + region(cx), + Some(stamp), + "the row note's edits never touched it" + ); + + let moved = Region { x: 5000, ..stamp }; + Diagnostics::move_note(id, moved, Origin::Drawn, cx); + assert_eq!(region(cx), Some(moved)); + let edited = + |cx: &App| Diagnostics::note(id, cx).and_then(|d| d.note.as_ref()?.edited.clone()); + assert_eq!(edited(cx), None, "a move is not a rewording"); + Diagnostics::edit_note( + id, + "customs stamp, Lisbon 1931".into(), + Some(NoteKind::Transcription), + Origin::Typed, + cx, + ); + assert!(edited(cx).is_some_and(|e| e.date.is_some())); + + Diagnostics::edit_note(id, "".into(), None, Origin::Clear, cx); + assert!(Diagnostics::note(id, cx).is_none()); + + // The deletion's inverse, as a restore replays it, files it back under the same id. + let deleted = settings::history::Change::Note { + row: Some(3), + column: None, + before: Some("customs stamp, Lisbon".into()), + after: None, + id: Some(id), + region_before: Some(moved), + region_after: None, + kind_before: Some(NoteKind::Transcription), + kind_after: None, + }; + Diagnostics::apply_note(row, &deleted.inverse(), Origin::Restore(1), cx); + assert_eq!(region(cx), Some(moved)); + assert_eq!( + Diagnostics::note(id, cx).and_then(|d| d.note.as_ref()?.kind), + Some(NoteKind::Transcription) + ); + assert_eq!( + Diagnostics::note(id, cx).map(|d| d.message.as_ref()), + Some("customs stamp, Lisbon") + ); + }); + } + /// Both halves are optional, and a note with neither must not render an empty byline. #[test] fn a_filing_stamp_reads_as_whichever_halves_exist() { @@ -932,6 +1298,9 @@ mod tests { message: "check this".into(), created_at: None, author: None, + id: 1, + region: None, + kind: None, }, StoredNote { dataset: DATASET_MAIN.into(), @@ -942,6 +1311,9 @@ mod tests { message: "whole row".into(), created_at: None, author: None, + id: 2, + region: None, + kind: None, }, ], &[], @@ -1050,7 +1422,7 @@ mod tests { source: v.clone(), message: "typo".into(), group: None, - filed: None, + note: None, }], cx, ); @@ -1253,4 +1625,78 @@ mod tests { assert!(Diagnostics::ignored(cx).is_empty()); }); } + + /// Restoring an annotation's entry puts it back as the entry found it: a move undone, and a + /// deleted note back under its own id. + #[gpui::test] + fn restoring_a_note_change_puts_the_annotation_back(cx: &mut TestAppContext) { + cx.update(|cx| { + let location = Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(9), + column: None, + }; + let stamp = Region { + page: 0, + x: 100, + y: 100, + w: 500, + h: 500, + of: None, + }; + let id = Diagnostics::file_note( + location, + Some(stamp), + Some(NoteKind::Question), + "stamp".into(), + Origin::Drawn, + cx, + ) + .unwrap(); + let moved = Region { x: 900, ..stamp }; + Diagnostics::move_note(id, moved, Origin::Drawn, cx); + let change = + |before: Option<&str>, region_before, after: Option<&str>, region_after| { + settings::history::Change::Note { + row: Some(9), + column: None, + before: before.map(Into::into), + after: after.map(Into::into), + id: Some(id), + region_before, + region_after, + kind_before: Some(NoteKind::Question), + kind_after: Some(NoteKind::Question), + } + }; + let region = |cx: &App| Diagnostics::note(id, cx).and_then(|d| d.note.as_ref()?.region); + + Diagnostics::restore_note( + &change(Some("stamp"), Some(stamp), Some("stamp"), Some(moved)), + Some(0), + None, + 1, + cx, + ); + assert_eq!(region(cx), Some(stamp)); + + Diagnostics::edit_note(id, SharedString::default(), None, Origin::Drawn, cx); + assert!(Diagnostics::note(id, cx).is_none()); + Diagnostics::restore_note( + &change(Some("stamp"), Some(stamp), None, None), + Some(0), + None, + 2, + cx, + ); + let back = Diagnostics::note(id, cx).expect("the deleted note is back"); + assert_eq!(back.message, SharedString::from("stamp")); + assert_eq!( + back.note.as_ref().and_then(|n| n.kind), + Some(NoteKind::Question) + ); + assert_eq!(region(cx), Some(stamp)); + }); + } } diff --git a/crates/diagnostics/src/note_card.rs b/crates/diagnostics/src/note_card.rs new file mode 100644 index 00000000..0f9985d1 --- /dev/null +++ b/crates/diagnostics/src/note_card.rs @@ -0,0 +1,192 @@ +//! The note hover card: one note whole, then who filed it and when. The viewer shows it on a region +//! and the grid on a cell; each caller only decides where it goes. + +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, InteractiveElement as _, IntoElement, ParentElement, SharedString, + StatefulInteractiveElement as _, Styled, Window, div, px, +}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable as _, h_flex, v_flex}; + +use crate::{Diagnostic, NoteKind, Severity, severity_color}; + +pub const WIDTH: f32 = 280.; + +/// The muted line under a note: its author or "Unsigned", when it was filed, and "edited" once it +/// has been reworded. +pub fn byline(note: &Diagnostic) -> SharedString { + let meta = note.note.as_ref(); + let filed = meta.and_then(|n| n.filed.as_ref()); + let author = filed + .and_then(|f| f.author.clone()) + .unwrap_or_else(|| "Unsigned".into()); + [ + Some(author), + filed.and_then(|f| f.date.clone()), + meta.and_then(|n| n.edited.as_ref()) + .map(|_| "edited".into()), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · ") + .into() +} + +/// The kinds a note can be, as chips with `current` lit. A note nobody gave a kind is a plain +/// note, and picking that one hands `pick` `None` so it stays stored that way. +pub fn kind_chips( + current: Option, + pick: impl Fn(Option, &mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let pick = Rc::new(pick); + h_flex() + .gap_1() + .children(NoteKind::ALL.map(|kind| { + let pick = pick.clone(); + div() + .id(kind.key()) + .h(px(20.)) + .px_2() + .flex() + .items_center() + .rounded(px(10.)) + .border_1() + .text_xs() + .cursor_pointer() + .map(|chip| match current.unwrap_or(NoteKind::Note) == kind { + true => chip + .bg(theme.primary.opacity(0.14)) + .text_color(theme.primary) + .border_color(theme.primary.opacity(0.5)), + false => chip + .text_color(theme.muted_foreground) + .border_color(theme.border), + }) + .child(kind.label()) + .on_click(move |_, window, cx| { + pick((kind != NoteKind::Note).then_some(kind), window, cx) + }) + })) + .into_any_element() +} + +/// `note` over its [`byline`]. What a cell's tooltip used to say — the column's `description` and +/// the validators' `findings` — follows under a divider, so the note always reads first. +pub fn note_card( + note: &Diagnostic, + description: Option, + findings: Vec<(Severity, SharedString)>, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let extra = description.is_some() || !findings.is_empty(); + v_flex() + .w(px(WIDTH)) + .gap_1() + .px_2p5() + .py_2() + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.border) + .rounded(px(6.)) + .shadow_md() + .text_size(px(13.)) + .line_height(px(18.)) + .child(div().whitespace_normal().child(note.message.clone())) + .child( + div() + .text_xs() + .line_height(px(16.)) + .text_color(theme.muted_foreground) + .child(byline(note)), + ) + .when(extra, |card| { + card.child( + v_flex() + .gap_1() + .mt_1() + .pt_1p5() + .border_t_1() + .border_color(theme.border) + .text_xs() + .line_height(px(16.)) + .children(description.map(|description| { + div().text_color(theme.muted_foreground).child(description) + })) + .children(findings.into_iter().map(|(severity, message)| { + h_flex() + .items_start() + .gap_1p5() + .child( + Icon::new(match severity { + Severity::Error => IconName::CircleX, + Severity::Warning => IconName::TriangleAlert, + Severity::Note => IconName::Info, + }) + .small() + .flex_none() + .mt(px(1.)) + .text_color(severity_color(severity, cx)), + ) + .child(div().flex_1().min_w_0().child(message)) + })), + ) + }) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use gpui::SharedString; + + use crate::note_card::byline; + use crate::{DATASET_MAIN, Diagnostic, Filed, Location, NoteMeta, Severity, Source}; + + fn note(author: Option<&str>, date: Option<&str>, edited: bool) -> Diagnostic { + Diagnostic { + location: Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(1), + column: None, + }, + severity: Severity::Note, + source: Source::Note, + message: "Studio stamp".into(), + group: None, + note: Some(NoteMeta { + id: 1, + filed: Some(Filed { + date: date.map(SharedString::from), + author: author.map(SharedString::from), + }), + region: None, + kind: None, + edited: edited.then(|| Filed { + date: Some("2026-10-01 16:05".into()), + author: None, + }), + }), + } + } + + /// Who, when, and whether it changed since — an unsigned note says so rather than leaving a + /// blank where a name would be. + #[test] + fn the_byline_names_the_author_or_says_unsigned() { + assert_eq!( + byline(¬e(Some("Ravi Kapoor"), Some("2026-09-30 10:12"), true)), + "Ravi Kapoor · 2026-09-30 10:12 · edited" + ); + assert_eq!( + byline(¬e(None, Some("2026-10-02 09:30"), false)), + "Unsigned · 2026-10-02 09:30" + ); + } +} diff --git a/crates/diagnostics/src/panel.rs b/crates/diagnostics/src/panel.rs index 632edfe0..a07d1693 100644 --- a/crates/diagnostics/src/panel.rs +++ b/crates/diagnostics/src/panel.rs @@ -6,12 +6,16 @@ use gpui::*; use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState}; use gpui_component::dock::{BasePanel, Panel, PanelEvent}; -use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_component::searchable_list::{SearchableListDelegate, SearchableListItem}; use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ ActiveTheme as _, Icon, IconName, IndexPath, Selectable as _, Sizable as _, h_flex, v_flex, }; +use window_wrapper::{ + panel_headers::{PanelHeaderRegistry, PanelHeaders}, + responsive_header::{HeaderMenu, HeaderStage}, +}; use crate::{ Diagnostic, DiagnosticHooks, Diagnostics, Location, Scope, Severity, Source, severity_color, @@ -387,6 +391,7 @@ pub struct ProblemsPanel { impl ProblemsPanel { pub fn new(_window: &mut Window, cx: &mut Context) -> Self { + PanelHeaderRegistry::register::("ProblemsPanel", cx); let mut this = Self { focus_handle: cx.focus_handle(), active: false, @@ -510,44 +515,195 @@ impl BasePanel for ProblemsPanel { } } -impl Panel for ProblemsPanel { +impl ProblemsPanel { + fn header_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + include_filters: bool, + ) -> PopupMenu { + let panel = cx.entity().downgrade(); + let menu = menu.when(include_filters, |menu| { + let filters = PopupMenu::build(window, cx, |menu, _, _| { + Filter::ALL + .into_iter() + .zip(self.counts) + .fold(menu, |menu, (filter, count)| { + let panel = panel.clone(); + menu.item( + PopupMenuItem::new(format!("{} ({count})", filter.label())) + .checked(self.filter == filter) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.filter = filter; + this.refresh(cx); + cx.notify(); + }) + .ok(); + }), + ) + }) + }); + menu.item(PopupMenuItem::submenu("Show", filters)) + }); + let menu = menu.when( + self.filter != Filter::Notes && self.sources.len() > 1, + |menu| { + let sources = PopupMenu::build(window, cx, |menu, _, _| { + self.sources.iter().fold(menu, |menu, source| { + let panel = panel.clone(); + let source = source.clone(); + menu.item( + PopupMenuItem::new(source.clone()) + .checked(!self.excluded_sources.contains(&source)) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + if !this.excluded_sources.remove(&source) { + this.excluded_sources.insert(source.clone()); + } + this.refresh(cx); + cx.notify(); + }) + .ok(); + }), + ) + }) + }); + menu.item(PopupMenuItem::submenu("Sources", sources)) + }, + ); + menu.when( + self.filter != Filter::Notes && (self.ignored_count > 0 || self.show_ignored), + |menu| { + menu.item( + PopupMenuItem::new(format!("Show ignored ({})", self.ignored_count)) + .checked(self.show_ignored) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.show_ignored = !this.show_ignored; + this.refresh(cx); + cx.notify(); + }) + .ok(); + }), + ) + }, + ) + } + // The filter tabs sit in the title (the bar's stretching slot, so they stay left) and the // source picker in the suffix, so the list starts right under the title bar. - fn title(&mut self, _w: &mut Window, cx: &mut Context) -> impl IntoElement { - h_flex().items_center().gap_3().child("Problems").child( - TabBar::new("problems-filter") - .segmented() - .xsmall() - .selected_index( - Filter::ALL - .iter() - .position(|f| *f == self.filter) - .unwrap_or(0), - ) - .children(Filter::ALL.iter().zip(self.counts).map(|(f, n)| { - // The same icon and colour the rows of that severity carry. - let icon = match f { - Filter::All => Icon::new(IconName::Menu), - Filter::Errors => Icon::new(IconName::CircleX) - .text_color(severity_color(Severity::Error, cx)), - Filter::Warnings => Icon::new(IconName::TriangleAlert) - .text_color(severity_color(Severity::Warning, cx)), - Filter::Notes => Icon::new(IconName::Info), - }; - Tab::new().aria_label(f.label()).child( - h_flex() - .items_center() - .gap_1() - .child(icon.xsmall()) - .child(format!("{} ({n})", f.label())), + fn header_tabs(&mut self, names: bool, cx: &mut Context) -> AnyElement { + h_flex() + .items_center() + .gap_3() + .child("Problems") + .child( + TabBar::new("problems-filter") + .segmented() + .xsmall() + .selected_index( + Filter::ALL + .iter() + .position(|f| *f == self.filter) + .unwrap_or(0), ) - })) - .on_click(cx.listener(|this, ix: &usize, _w, cx| { - this.filter = Filter::ALL[*ix]; - this.refresh(cx); - cx.notify(); - })), - ) + .children(Filter::ALL.iter().zip(self.counts).map(|(f, n)| { + // The same icon and colour the rows of that severity carry. + let icon = match f { + Filter::All => Icon::new(IconName::Menu), + Filter::Errors => Icon::new(IconName::CircleX) + .text_color(severity_color(Severity::Error, cx)), + Filter::Warnings => Icon::new(IconName::TriangleAlert) + .text_color(severity_color(Severity::Warning, cx)), + Filter::Notes => Icon::new(IconName::Info), + }; + let label = f.label(); + Tab::new().aria_label(label).child( + div() + .id(label) + .when(!names, |content| { + content.tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new(label) + .build(window, cx) + }) + }) + .child(h_flex().items_center().gap_1().child(icon.xsmall()).child( + if names { + format!("{label} ({n})") + } else { + n.to_string() + }, + )), + ) + })) + .on_click(cx.listener(|this, ix: &usize, _w, cx| { + this.filter = Filter::ALL[*ix]; + this.refresh(cx); + cx.notify(); + })), + ) + .into_any_element() + } +} + +impl PanelHeaders for ProblemsPanel { + fn header_stages( + &mut self, + defaults: Vec, + common_menu: HeaderMenu, + cx: &mut Context, + ) -> Vec { + let panel = cx.entity().downgrade(); + let secondary_menu: HeaderMenu = Rc::new(move |menu, window, cx, focus| { + let menu = match panel.upgrade() { + Some(panel) => { + panel.update(cx, |panel, cx| panel.header_menu(menu, window, cx, false)) + } + None => menu, + }; + common_menu(menu, window, cx, focus) + }); + let mut stages = vec![defaults[0].clone()]; + // First move the secondary controls into the menu, then remove the tab names. + for names in [true, false] { + let panel = cx.entity().downgrade(); + stages.push( + HeaderStage::new(move |_, cx| match panel.upgrade() { + Some(panel) => panel.update(cx, |panel, cx| { + panel.header_tabs(names, cx).into_any_element() + }), + None => div().into_any_element(), + }) + .with_menu(secondary_menu.clone()), + ); + } + stages.push( + defaults + .last() + .expect("default header has a compact stage") + .clone(), + ); + stages + } +} + +impl Panel for ProblemsPanel { + fn dropdown_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + self.header_menu(menu, window, cx, true) + } + + fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.header_tabs(true, cx) } fn title_suffix( @@ -988,7 +1144,7 @@ mod tests { summary: "misspelled: recieve".into(), subject: Some("recieve".into()), }), - filed: None, + note: None, }) .collect() } @@ -1245,7 +1401,7 @@ mod tests { source: Source::Note, message: "m".into(), group: None, - filed: None, + note: None, }) .collect(), cx, @@ -1263,7 +1419,7 @@ mod tests { source: Source::Validator("test".into()), message: "computed".into(), group: None, - filed: None, + note: None, }; assert!(Filter::All.admits(&computed)); assert!(!Filter::Notes.admits(&computed)); @@ -1316,7 +1472,7 @@ mod tests { source: Source::Validator(source.into()), message: "m".into(), group: None, - filed: None, + note: None, }) .collect(), cx, @@ -1331,7 +1487,7 @@ mod tests { source: Source::Note, message: "user note".into(), group: None, - filed: None, + note: None, }], cx, ); diff --git a/crates/diagnostics/src/validator.rs b/crates/diagnostics/src/validator.rs index 3c192f6e..b6ed883b 100644 --- a/crates/diagnostics/src/validator.rs +++ b/crates/diagnostics/src/validator.rs @@ -563,7 +563,7 @@ pub fn address( source: Source::Validator(validator.clone()), message: finding.message, group: finding.group, - filed: None, + note: None, }) .collect() } diff --git a/crates/preview/src/crop.rs b/crates/preview/src/crop.rs new file mode 100644 index 00000000..9e3cfd41 --- /dev/null +++ b/crates/preview/src/crop.rs @@ -0,0 +1,170 @@ +//! A note card's picture of its region: that part of the page, cut from the capped copy a card or +//! the Details pane already decodes, and held only in memory. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use gpui::{App, Asset, ImageCacheError, ImageSource, RenderImage, Window}; +use image::Frame; + +/// The longest edge a crop is cut to: twice the largest card that draws one. +const EDGE: u32 = 112; +/// How much of the page's shorter side a pin's crop shows around it. +const AROUND_PIN: f32 = 0.15; +const SCALE: f32 = 10_000.; + +/// File, page, area, decoder generation, and file identity. +type Key = (PathBuf, usize, [u16; 4], u64, Option); + +pub struct Crop; + +impl Asset for Crop { + type Source = Key; + type Output = Option>; + + fn load( + (path, page, area, _, _): Self::Source, + cx: &mut App, + ) -> impl Future + Send + 'static { + let executor = cx.background_executor().clone(); + async move { executor.spawn(async move { cut(&path, page, area) }).await } + } +} + +fn cut(path: &Path, page: usize, [x, y, w, h]: [u16; 4]) -> Option> { + let pixels = crate::thumbnail_pixels(path, crate::PANE, page)?; + let (width, height) = (pixels.width() as f32, pixels.height() as f32); + let (left, top) = (f32::from(x) / SCALE * width, f32::from(y) / SCALE * height); + let (mut l, mut t, mut r, mut b) = ( + left, + top, + left + f32::from(w) / SCALE * width, + top + f32::from(h) / SCALE * height, + ); + if w == 0 && h == 0 { + let half = width.min(height) * AROUND_PIN / 2.; + (l, t, r, b) = (l - half, t - half, r + half, b + half); + } + let (l, t) = (l.clamp(0., width - 1.), t.clamp(0., height - 1.)); + let (r, b) = (r.clamp(l + 1., width), b.clamp(t + 1., height)); + let cut = image::imageops::crop_imm( + &pixels, + l as u32, + t as u32, + (r - l).max(1.) as u32, + (b - t).max(1.) as u32, + ) + .to_image(); + let mut bgra = crate::downscale(cut.into(), EDGE); + for px in bgra.pixels_mut() { + px.0.swap(0, 2); + } + Some(Arc::new(RenderImage::new([Frame::new(bgra)]))) +} + +/// What a card draws for the part of `path`'s `page` that `area` covers — x, y, width and height +/// in ten-thousandths of the upright page, zero-sized for a pin. Fails, so `with_fallback` takes +/// over, when the file can't be read and was not cut earlier this session. +pub fn crop(path: &Path, page: usize, area: [u16; 4]) -> ImageSource { + let generation = crate::extension(path).map_or(0, |extension| crate::generation(&extension)); + let key = ( + path.to_path_buf(), + page, + area, + generation, + crate::cache::key(path, crate::PANE, page), + ); + ImageSource::Custom(Arc::new(move |window: &mut Window, cx: &mut App| { + let loaded = window.use_asset::(&key, cx)?; + Some(loaded.ok_or_else(|| { + ImageCacheError::Other(Arc::new(anyhow::anyhow!("no crop of {}", key.0.display()))) + })) + })) +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use gpui::{Context, ImageSource, Render, Styled as _, TestAppContext, Window, img, px}; + + use crate::crop::{crop, cut}; + + struct CropProbe(PathBuf); + + impl Render for CropProbe { + fn render( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> impl gpui::IntoElement { + img(crop(&self.0, 0, [0, 0, 10_000, 10_000])) + .w(px(112.)) + .h(px(112.)) + } + } + + #[gpui::test] + fn crops_refresh_when_a_missing_file_appears_or_a_source_is_replaced(cx: &mut TestAppContext) { + let path = + std::env::temp_dir().join(format!("qrate-crop-refresh-{}.png", std::process::id())); + let _ = std::fs::remove_file(&path); + let (_, cx) = cx.add_window_view(|_, _| CropProbe(path.clone())); + cx.run_until_parked(); + let load = |cx: &mut gpui::VisualTestContext| { + let ImageSource::Custom(load) = crop(&path, 0, [0, 0, 10_000, 10_000]) else { + panic!("a crop uses its asset loader"); + }; + cx.update(|window, cx| load(window, cx)) + .expect("the crop finished loading") + }; + assert!(load(cx).is_err()); + + for (seconds, rgba, bgra) in [ + (1, [255, 0, 0, 255], [0, 0, 255, 255]), + (2, [0, 0, 255, 255], [255, 0, 0, 255]), + ] { + image::RgbaImage::from_pixel(20, 20, image::Rgba(rgba)) + .save(&path) + .unwrap(); + std::fs::OpenOptions::new() + .write(true) + .open(&path) + .unwrap() + .set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(seconds)) + .unwrap(); + cx.update(|_, cx| crate::forget(&path, cx)); + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + cx.run_until_parked(); + let image = load(cx).unwrap(); + assert_eq!(&image.as_bytes(0).unwrap()[..4], &bgra); + } + std::fs::remove_file(path).unwrap(); + } + + /// A region is cut from where it sits on the page, and a pin from a square around it. + #[test] + fn a_crop_is_the_part_of_the_page_the_region_covers() { + let path = std::env::temp_dir().join("qrate-crop.png"); + let mut page = image::RgbaImage::new(200, 100); + for (x, _, px) in page.enumerate_pixels_mut() { + *px = image::Rgba([if x < 100 { 255 } else { 0 }, 0, 0, 255]); + } + page.save(&path).unwrap(); + + let right_half = cut(&path, 0, [5000, 0, 5000, 10_000]).unwrap(); + let size = right_half.size(0); + assert_eq!((size.width.0, size.height.0), (100, 100)); + // BGRA: red sits in the third byte, and the right half has none. + assert_eq!(right_half.as_bytes(0).unwrap()[2], 0); + + let pin = cut(&path, 0, [2500, 5000, 0, 0]).unwrap(); + let size = pin.size(0); + assert_eq!((size.width.0, size.height.0), (15, 15)); + assert!( + pin.as_bytes(0).unwrap()[2] > 250, + "the pin sits in the red half" + ); + } +} diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index fb00b3c4..1f909740 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -11,11 +11,13 @@ mod audio; pub mod cache; +mod crop; mod embedded; mod media; mod native; mod pdf; pub mod playback; +pub use crop::crop; use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; diff --git a/crates/project-wizard/src/project.rs b/crates/project-wizard/src/project.rs index 2ff0cad0..13a99612 100644 --- a/crates/project-wizard/src/project.rs +++ b/crates/project-wizard/src/project.rs @@ -6,7 +6,7 @@ use std::path::Path; pub use settings::project::{ - ProjectColumn, ProjectSpec, RowId, RowStructure, SourceKind, StoredNote, write_notes, + NoteId, ProjectColumn, ProjectSpec, RowId, RowStructure, SourceKind, StoredNote, write_notes, write_row_structure, }; diff --git a/crates/project-wizard/src/steps/review.rs b/crates/project-wizard/src/steps/review.rs index bdbc5bca..a074a2cd 100644 --- a/crates/project-wizard/src/steps/review.rs +++ b/crates/project-wizard/src/steps/review.rs @@ -357,7 +357,11 @@ impl ProjectWizard { let notes: Vec<_> = preview .notes .iter() - .map(|n| project::StoredNote { + .enumerate() + .map(|(i, n)| project::StoredNote { + id: i as project::NoteId + 1, + region: None, + kind: None, dataset: diagnostics::DATASET_MAIN.into(), row: Some(n.row), // Creation inserts rows in source order, starting at SQLite id 1. diff --git a/crates/qrate-export/src/notes.rs b/crates/qrate-export/src/notes.rs index 262bac80..fa778e35 100644 --- a/crates/qrate-export/src/notes.rs +++ b/crates/qrate-export/src/notes.rs @@ -2,7 +2,7 @@ use std::collections::{BTreeMap, HashMap}; -use rusqlite::{Connection, OptionalExtension as _}; +use rusqlite::Connection; use serde_json::{Value, json}; #[derive(Clone, Debug, PartialEq, Eq)] @@ -14,6 +14,12 @@ pub struct ProjectNote { pub message: String, pub created_at: Option, pub author: Option, + /// Stable identity, `None` in a file written before notes had one. + pub id: Option, + /// Where on the row's file the note points, as the app's JSON. `None` for a note on the data. + pub region: Option, + /// Note, transcription or question, as stored; `None` when its author did not say. + pub kind: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -24,24 +30,21 @@ pub struct SheetNote { pub text: String, } -/// Older projects may not have a notes table or provenance columns yet. +/// Older projects may not have a notes table, or any of the columns added to it since. pub fn read_project_notes(conn: &Connection) -> rusqlite::Result> { if !crate::table_exists(conn, "__notes")? { return Ok(Vec::new()); } - let provenance: bool = conn - .query_row( - "SELECT 1 FROM pragma_table_info('__notes') WHERE name='created_at'", - [], - |row| row.get::<_, i64>(0), - ) - .optional()? - .is_some(); - let columns = if provenance { - "created_at, author" - } else { - "NULL AS created_at, NULL AS author" - }; + let present: Vec = conn + .prepare("SELECT name FROM pragma_table_info('__notes')")? + .query_map([], |row| row.get(0))? + .collect::>()?; + let columns = ["created_at", "author", "note_id", "region", "kind"] + .map(|name| match present.iter().any(|p| p == name) { + true => name.to_string(), + false => format!("NULL AS {name}"), + }) + .join(", "); let mut stmt = conn.prepare(&format!( "SELECT dataset, row_ix, column_name, severity, message, {columns} FROM __notes" ))?; @@ -54,6 +57,9 @@ pub fn read_project_notes(conn: &Connection) -> rusqlite::Result "Undo".into(), Origin::Redo => "Redo".into(), Origin::Restore(id) => format!("Restored to #{id}"), + Origin::Drawn => "Drawn on the image".into(), } } } @@ -111,6 +114,17 @@ pub enum Change { column: Option, before: Option, after: Option, + /// Which note, where a location holds several. `None` in entries from before notes had ids. + #[serde(default, skip_serializing_if = "Option::is_none")] + id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + region_before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + region_after: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + kind_before: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + kind_after: Option, }, } @@ -179,11 +193,21 @@ impl Change { column, before, after, + id, + region_before, + region_after, + kind_before, + kind_after, } => Change::Note { row, column, before: after, after: before, + id, + region_before: region_after, + region_after: region_before, + kind_before: kind_after, + kind_after: kind_before, }, } } @@ -481,6 +505,45 @@ pub fn renames(path: &Path) -> Result> { .collect() } +/// When each note was last reworded and by whom: its newest logged change of text or kind, as local +/// `YYYY-MM-DD HH:MM`. A move is not a rewording. +pub fn note_edits(path: &Path) -> Result)>> { + let conn = crate::project::open_ro(path)?; + if !qrate_export::table_exists(&conn, "__history_changes")? { + return Ok(HashMap::new()); + } + let today = Local::now().date_naive(); + let mut stmt = conn.prepare( + "SELECT h.at, h.author, c.change FROM __history_changes c JOIN __history h ON h.id = c.entry_id + WHERE c.change LIKE '{\"Note\"%' ORDER BY c.entry_id, c.seq", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, Option>(1)?, + r.get::<_, String>(2)?, + )) + })?; + let mut edits = HashMap::new(); + for row in rows { + let (at, author, change) = row?; + if let Ok(Change::Note { + id: Some(id), + before: Some(before), + after: Some(after), + kind_before, + kind_after, + .. + }) = serde_json::from_str(&change) + && (before != after || kind_before != kind_after) + { + let (day, time, _) = parts(at, today); + edits.insert(id, (format!("{day} {time}"), author)); + } + } + Ok(edits) +} + /// How many entries a project keeps. Unset — or anything that isn't a positive number — keeps /// every one, which is the default: this log is an audit trail, so nothing goes unless asked. pub const HISTORY_LIMIT_KEY: &str = "history_limit"; @@ -557,8 +620,8 @@ pub fn clear(path: &Path) -> Result<()> { #[cfg(test)] mod tests { use super::{ - Change, Entry, EntryId, Origin, clear, entries_after, former_names, local_times, page, - prune, renames, set_label, + Change, Entry, EntryId, Origin, clear, entries_after, former_names, local_times, + note_edits, page, prune, renames, set_label, }; use crate::project::{ProjectSpec, create_project_file, save_dataset, write_notes}; @@ -622,6 +685,18 @@ mod tests { column: None, before: Some("faded".into()), after: None, + id: Some(7), + region_before: Some(crate::project::Region { + page: 2, + x: 1200, + y: 500, + w: 2500, + h: 0, + of: Some((4000, 3000)), + }), + region_after: None, + kind_before: Some(crate::project::NoteKind::Question), + kind_after: None, }], None, ); @@ -638,6 +713,77 @@ mod tests { assert_eq!(entries_after(&path, 2).unwrap().len(), 1); } + /// A note counts as edited when its words or kind changed, not when its region moved or when it + /// was first filed, and the newest edit is the one it reports. + #[test] + fn a_note_is_edited_by_its_newest_rewording() { + let path = project("note-edits.qrate"); + let note = |id, before: Option<&str>, after: Option<&str>| Change::Note { + row: Some(1), + column: None, + before: before.map(Into::into), + after: after.map(Into::into), + id: Some(id), + region_before: None, + region_after: None, + kind_before: None, + kind_after: None, + }; + let entry = |at, author: &str, change| Entry { + at, + ..Entry::new(Origin::Typed, vec![change], Some(author.into())) + }; + let entries = [ + entry(1_000, "am", note(1, None, Some("faded"))), + entry( + 2_000, + "rk", + note(1, Some("faded"), Some("faded, in pencil")), + ), + entry( + 3_000, + "am", + note(1, Some("faded, in pencil"), Some("faded, in pencil")), + ), + entry(4_000, "am", note(2, None, Some("stamp"))), + ]; + write_notes(&path, "note", &[], &entries).unwrap(); + + let edits = note_edits(&path).unwrap(); + assert_eq!(edits.len(), 1, "filing a note is not editing it"); + let (when, author) = &edits[&1]; + assert_eq!( + author.as_deref(), + Some("rk"), + "the move after it is not a rewording" + ); + assert_eq!( + when, + &format!( + "{} {}", + local_times(&[2_000])[0].0, + local_times(&[2_000])[0].1 + ) + ); + } + + /// A note change logged before notes had ids or regions still reads, as a change to the one + /// note at its location — a year-old log is the point of keeping one. + #[test] + fn a_note_change_from_before_ids_still_reads() { + let old = r#"{"Note":{"row":3,"column":"Title","before":null,"after":"verso"}}"#; + let Change::Note { + id, + region_after, + kind_after, + .. + } = serde_json::from_str(old).unwrap() + else { + panic!("not a note change"); + }; + assert_eq!((id, region_after, kind_after), (None, None, None)); + } + /// The panel pages newest first, a name sticks to its entry until it is taken away, and /// clearing forgets the log without touching the data. #[test] diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index d6d19bae..e50a65d1 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -43,6 +43,9 @@ pub const AUTOSAVE_KEY: &str = "autosave"; /// User-wide author for new notes and history entries. Empty leaves them unsigned. pub const NOTE_AUTHOR_KEY: &str = "note_author"; +/// Set once the annotation composer has asked for a name, signed or skipped, so it never asks again. +pub const NOTE_AUTHOR_ASKED_KEY: &str = "note_author_asked"; + /// `AppSettings` key for whether Google Sheets export and sync are enabled. User-wide only: a /// project must not opt the person using qrate into an external service. Authentication is a /// separate action, and public-sheet import does not use this setting. diff --git a/crates/settings/src/project.rs b/crates/settings/src/project.rs index ed14645b..70d07d35 100644 --- a/crates/settings/src/project.rs +++ b/crates/settings/src/project.rs @@ -583,17 +583,29 @@ const NOTES_DDL: &str = r#" source TEXT NOT NULL, message TEXT NOT NULL, created_at TEXT, - author TEXT + author TEXT, + note_id INTEGER, + region TEXT, + kind TEXT ); "#; -/// Bring a `__notes` table written before notes carried provenance up to the columns above. -/// Nullable, so every existing row reads back with no date and no author and renders as the bare -/// note it has always been — there is nothing to backfill, because that information was never -/// captured. Idempotent: a duplicate-column error is the table already being current. -fn add_note_provenance(tx: &rusqlite::Transaction<'_>) -> Result<()> { - for column in ["created_at", "author"] { - match tx.execute(&format!("ALTER TABLE __notes ADD COLUMN {column} TEXT"), []) { +/// Bring a `__notes` table written by an older qrate up to the columns above. Nullable, so every +/// existing row reads back as the bare note it has always been — there is nothing to backfill, +/// because that information was never captured. Idempotent: a duplicate-column error is the table +/// already being current. +fn add_note_columns(tx: &rusqlite::Transaction<'_>) -> Result<()> { + for (column, kind) in [ + ("created_at", "TEXT"), + ("author", "TEXT"), + ("note_id", "INTEGER"), + ("region", "TEXT"), + ("kind", "TEXT"), + ] { + match tx.execute( + &format!("ALTER TABLE __notes ADD COLUMN {column} {kind}"), + [], + ) { Ok(_) => {} // `duplicate column name` is the only error worth swallowing; anything else means the // table is not what we think it is and the caller should hear about it. @@ -624,15 +636,78 @@ pub struct StoredNote { /// is worth keeping even unsigned. pub created_at: Option, pub author: Option, + /// What history entries name this note by, since a location can hold several. + pub id: NoteId, + /// Set when the note points at part of the row's file rather than at its data. + pub region: Option, + pub kind: Option, } -/// Today, from SQLite's own clock — the one dependency here that already knows what day it is. -/// Local rather than UTC: an archivist reading "filed 2026-08-14" means their own Tuesday. -pub fn today(path: &Path) -> Option { - open_ro(path) - .ok()? - .query_row("SELECT date('now','localtime')", [], |r| r.get(0)) - .ok() +pub type NoteId = i64; + +/// What sort of observation a note is, when its author said. `None` is simply a note. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum NoteKind { + Note, + Transcription, + Question, +} + +impl NoteKind { + pub const ALL: [NoteKind; 3] = [NoteKind::Note, NoteKind::Transcription, NoteKind::Question]; + + /// The `__notes.kind` text. + pub fn key(self) -> &'static str { + match self { + NoteKind::Note => "note", + NoteKind::Transcription => "transcription", + NoteKind::Question => "question", + } + } + + pub fn from_key(key: &str) -> Option { + Self::ALL.into_iter().find(|kind| kind.key() == key) + } + + pub fn label(self) -> &'static str { + match self { + NoteKind::Note => "Note", + NoteKind::Transcription => "Transcription", + NoteKind::Question => "Question", + } + } +} + +/// Where on a row's file a note points, in [`Region::SCALE`]ths of the upright page — a W3C +/// `xywh=percent:` fragment at two decimals. Fractions because a PDF page has no pixel size of its +/// own, and integers so a region compares exactly. Zero width and height is a pin. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Region { + /// Zero-based page of a PDF or image stack; `0` for a single image. + pub page: u32, + pub x: u16, + pub y: u16, + pub w: u16, + pub h: u16, + /// The page's pixel size when the region was drawn, for a file that has one — how a viewer + /// notices the file was replaced underneath the note. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub of: Option<(u32, u32)>, +} + +impl Region { + pub const SCALE: u16 = 10_000; + + /// Whether a page of `size` pixels still has the shape this region was drawn on. A region is + /// fractions of its page, so the same picture at another resolution fits. + pub fn fits(&self, size: (u32, u32)) -> bool { + self.of.is_none_or(|(width, height)| { + let a = u64::from(width) * u64::from(size.1); + let b = u64::from(height) * u64::from(size.0); + a.abs_diff(b) * 100 <= a.max(b) + }) + } } /// Every stored note. A file written before `__notes` existed yields an empty vec, the same @@ -649,9 +724,30 @@ pub fn read_notes(path: &Path) -> Result> { .map(|(source, id)| id.map(|id| (id, source))) .collect::>()? }; + // A file from before notes had ids numbers them here, in table order: the same numbers on + // every open until the next save writes them down. + let mut next = notes.iter().filter_map(|n| n.id).max().unwrap_or(0); Ok(notes .into_iter() .map(|note| StoredNote { + id: note.id.unwrap_or_else(|| { + next += 1; + next + }), + // A region this build can't parse leaves the note on its row rather than losing it. + region: note.region.as_deref().and_then(|json| { + serde_json::from_str(json) + .inspect_err(|err| { + log::warn!("ignoring a note's unreadable region {json}: {err}") + }) + .ok() + }), + kind: note.kind.as_deref().and_then(|key| { + NoteKind::from_key(key).or_else(|| { + log::warn!("ignoring a note's unknown kind {key:?}"); + None + }) + }), row: match note.dataset.as_str() { "dataset_main" if !row_positions.is_empty() => { note.row_id.and_then(|id| row_positions.get(&id).copied()) @@ -682,15 +778,16 @@ pub fn write_notes( let mut conn = open_rw(path)?; let tx = conn.transaction()?; tx.execute_batch(NOTES_DDL).context("Create __notes")?; - add_note_provenance(&tx)?; + add_note_columns(&tx)?; tx.execute("DELETE FROM __notes WHERE source = ?1", params![source]) .context("Clear notes")?; { let mut stmt = tx.prepare( - "INSERT INTO __notes(dataset, row_ix, column_name, severity, source, message, created_at, author) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + "INSERT INTO __notes(dataset, row_ix, column_name, severity, source, message, created_at, author, note_id, region, kind) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", )?; for n in notes { + let region = n.region.as_ref().map(serde_json::to_string).transpose()?; stmt.execute(params![ n.dataset, n.row_id, @@ -699,7 +796,10 @@ pub fn write_notes( source, n.message, n.created_at, - n.author + n.author, + n.id, + region, + n.kind.map(NoteKind::key) ]) .context("Insert note")?; } @@ -1309,6 +1409,9 @@ mod tests { message: msg.into(), created_at: None, author: None, + id: row.map_or(0, |row| row as NoteId + 1), + region: None, + kind: None, } } @@ -1324,6 +1427,27 @@ mod tests { .unwrap(); } + /// A region drawn on a 4000×3000 scan still fits the same scan at half the resolution, and + /// stops fitting once the page is another shape. + #[test] + fn a_region_fits_its_page_at_any_resolution() { + let region = Region { + page: 0, + x: 0, + y: 0, + w: 100, + h: 100, + of: Some((4000, 3000)), + }; + assert!(region.fits((2000, 1500))); + assert!( + region.fits((2001, 1500)), + "a pixel of rounding is not a new shape" + ); + assert!(!region.fits((3000, 4000))); + assert!(Region { of: None, ..region }.fits((3000, 4000))); + } + #[test] fn visual_index_round_trips_and_forgets_another_models_vectors() { let path = tempfile("visual.qrate"); @@ -1377,17 +1501,31 @@ mod tests { assert_eq!(old.len(), 1, "the old note survives the schema it predates"); assert_eq!(old[0].message, "verso inscription"); assert_eq!(old[0].created_at, None, "nothing to backfill, so no date"); + assert_eq!(old[0].id, 1, "numbered on read, before any save"); - // Saving anything upgrades the table, and a note filed now carries its provenance. + // Saving anything upgrades the table, and a note filed now carries its provenance and + // where on the image it points. let mut fresh = note(Some(2), Some("Title"), "identified by her daughter"); fresh.created_at = Some("2026-08-14".into()); fresh.author = Some("rk".into()); - write_notes(&path, "import", &[fresh], &[]).unwrap(); + fresh.id = 41; + fresh.region = Some(Region { + page: 0, + x: 100, + y: 200, + w: 3000, + h: 1500, + of: None, + }); + fresh.kind = Some(NoteKind::Transcription); + write_notes(&path, "import", std::slice::from_ref(&fresh), &[]).unwrap(); let after = read_notes(&path).unwrap(); assert_eq!(after.len(), 1); assert_eq!(after[0].created_at.as_deref(), Some("2026-08-14")); assert_eq!(after[0].author.as_deref(), Some("rk")); + assert_eq!((after[0].id, after[0].region), (41, fresh.region)); + assert_eq!(after[0].kind, Some(NoteKind::Transcription)); } #[test] @@ -1980,6 +2118,9 @@ mod tests { message: "on item 2".into(), created_at: None, author: None, + id: 2, + region: None, + kind: None, }, StoredNote { dataset: "dataset_main".into(), @@ -1990,6 +2131,9 @@ mod tests { message: "on item 5".into(), created_at: None, author: None, + id: 5, + region: None, + kind: None, }, ], &[], diff --git a/crates/table/src/agent.rs b/crates/table/src/agent.rs index dbd04fac..139e328d 100644 --- a/crates/table/src/agent.rs +++ b/crates/table/src/agent.rs @@ -836,7 +836,7 @@ fn stage_findings( message: finding.message.into(), group: None, // A computed finding carries no filing stamp — it is recomputed, not observed once. - filed: None, + note: None, }); if let Some(replacement) = finding.replacement { diff --git a/crates/table/src/cell.rs b/crates/table/src/cell.rs index cd459002..3d587906 100644 --- a/crates/table/src/cell.rs +++ b/crates/table/src/cell.rs @@ -6,8 +6,8 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, AppContext as _, BorderStyle, Bounds, Context, ElementId, EmptyView, ExternalPaths, - InteractiveElement as _, IntoElement, MouseButton, ParentElement as _, Pixels, SharedString, + AnyElement, BorderStyle, Bounds, Context, ElementId, ExternalPaths, InteractiveElement as _, + IntoElement, MouseButton, ParentElement as _, Pixels, SharedString, StatefulInteractiveElement as _, Styled as _, StyledText, TextOverflow, Window, canvas, div, fill, outline, point, px, size, }; @@ -75,7 +75,7 @@ pub(crate) fn render_cell( }; let marked = worst_from(true); let flagged = worst_from(false); - let tip = note::tooltip_text(delegate, &location, cx); + let tip = note::tip(delegate, &location, cx); let note_editor = note::editor(delegate, Some(row_ix), Some(col_ix), cx); let mode = delegate.text_mode(col_ix); @@ -155,16 +155,7 @@ pub(crate) fn render_cell( .when(tip.is_some() || cut_short, |cell| { cell.tooltip(move |window, cx| { let truncated = cut_short && layout.len() != text.len(); - let tip: Option = match (tip.clone(), truncated) { - (Some(tip), true) => Some(format!("{text}\n\n{tip}").into()), - (Some(tip), false) => Some(tip), - (None, true) => Some(text.clone()), - (None, false) => None, - }; - match tip { - Some(tip) => gpui_component::tooltip::Tooltip::new(tip).build(window, cx), - None => cx.new(|_| EmptyView).into(), - } + note::tip_view(tip.as_ref(), truncated.then(|| text.clone()), window, cx) }) .tooltip_show_delay(note::HOVER_DELAY) }) diff --git a/crates/table/src/delegate.rs b/crates/table/src/delegate.rs index dad47873..079d1fd6 100644 --- a/crates/table/src/delegate.rs +++ b/crates/table/src/delegate.rs @@ -9,6 +9,7 @@ use gpui::{ Task, Window, div, px, }; use gpui_component::{ + ActiveTheme as _, input::TextareaState, table::{Column, TableDelegate, TableState}, }; @@ -974,6 +975,7 @@ impl QrateTableDelegate { .collect() }; match step { + Step::Notes(notes) => notes.clone(), Step::Cells(cells) => cells .iter() .map(|(row, col, before, after)| Change::Cell { @@ -1054,10 +1056,67 @@ impl QrateTableDelegate { let mut reshaped = Reshaped::default(); self.replay(step, forward, &mut changes, &mut reshaped); self.settle(reshaped); - self.log(origin, changes.clone()); + self.log( + origin, + changes + .iter() + .filter(|change| !matches!(change, Change::Note { .. })) + .cloned() + .collect(), + ); changes } + /// Keep the latest deleted notes with the row step, including notes edited between replays. + pub(crate) fn track_row_notes(&mut self, rows: &[RowId], notes: Vec, forward: bool) { + let Some(step) = self.history.replayed_mut(forward) else { + return; + }; + if !matches!(step, Step::Batch(_)) { + let original = std::mem::replace(step, Step::Batch(Vec::new())); + *step = Step::Batch(vec![original]); + } + let Step::Batch(steps) = step else { + unreachable!(); + }; + for step in steps.iter_mut() { + if let Step::Notes(notes) = step { + notes.retain(|note| { + !matches!(note, Change::Note { row: Some(row), .. } if rows.contains(row)) + }); + } + } + steps.retain(|step| !matches!(step, Step::Notes(notes) if notes.is_empty())); + if !notes.is_empty() { + steps.push(Step::Notes(match forward { + true => notes, + false => notes.iter().rev().map(Change::inverse).collect(), + })); + } + } + + /// Saved and pending changes after an entry, without duplicating a background save's log. + pub(crate) fn history_since( + &self, + file: &std::path::Path, + after: EntryId, + ) -> anyhow::Result> { + let _turn = self + .ledger + .write + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut entries = settings::history::entries_after(file, after)?; + let skip = (self + .ledger + .written + .load(Ordering::SeqCst) + .saturating_sub(self.saved_history) as usize) + .min(self.unsaved.len()); + entries.extend_from_slice(&self.unsaved[skip..]); + Ok(entries) + } + /// Put the grid back the way `changes` say, by identity rather than position: the log's /// positions are only where to put a row or column back. Everything applied lands as one undo /// step and one log entry. A change that no longer fits — its row already gone, its column @@ -1260,6 +1319,7 @@ impl QrateTableDelegate { changes.extend(self.changes(step).iter().rev().map(Change::inverse)); } match step { + Step::Notes(_) => {} Step::Cells(cells) => { for (row, col, before, after) in cells { let text = if forward { after } else { before }; @@ -2507,9 +2567,13 @@ impl TableDelegate for QrateTableDelegate { self.visible_rows.len() } - fn column(&self, col_ix: usize, _cx: &App) -> Column { + fn column(&self, col_ix: usize, cx: &App) -> Column { if col_ix == row_index::COL_IX { - return row_index::column(); + return row_index::column( + self.row_ids.len(), + self.hierarchy.children(None).len() != self.row_ids.len(), + cx.theme().font_size, + ); } let column = self.columns[col_ix - 1].clone(); // The library's fixed region is however many leading columns carry this, so a count is the diff --git a/crates/table/src/filter.rs b/crates/table/src/filter.rs index bed5ba7c..46d05cbe 100644 --- a/crates/table/src/filter.rs +++ b/crates/table/src/filter.rs @@ -98,7 +98,7 @@ pub(crate) fn render_th( let location = delegate.location(None, Some(data_col)); let worst = Diagnostics::worst_at(&location.dataset, None, location.column.as_deref(), cx); - let tip = note::tooltip_text(delegate, &location, cx); + let tip = note::tip(delegate, &location, cx); let note_editor = note::editor(delegate, None, Some(data_col), cx); h_flex() @@ -120,11 +120,9 @@ pub(crate) fn render_th( cx.notify(); })) .when_some(worst, |th, severity| th.child(note::marker(severity, cx))) - .when_some(tip, |th, text| { - th.tooltip(move |window, cx| { - gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) - }) - .tooltip_show_delay(note::HOVER_DELAY) + .when_some(tip, |th, tip| { + th.tooltip(move |window, cx| note::tip_view(Some(&tip), None, window, cx)) + .tooltip_show_delay(note::HOVER_DELAY) }) .context_menu(move |menu, window, cx| { note::menu(Target::Column(data_col), menu, window, cx) diff --git a/crates/table/src/history.rs b/crates/table/src/history.rs index a996340d..31e44004 100644 --- a/crates/table/src/history.rs +++ b/crates/table/src/history.rs @@ -53,6 +53,8 @@ pub(crate) struct Col { pub(crate) enum Step { /// Every cell touched, as `(row, col, before, after)`. Cells(Vec<(usize, usize, SharedString, SharedString)>), + /// Authored notes affected by a row deletion, replayed by the table's caller. + Notes(Vec), RowsAdded { at: usize, rows: Vec, @@ -97,6 +99,7 @@ impl Step { fn is_empty(&self) -> bool { match self { Step::Cells(cells) => cells.is_empty(), + Step::Notes(notes) => notes.is_empty(), Step::RowsAdded { rows, cells, .. } => rows.is_empty() && cells.is_empty(), Step::RowsRemoved { rows, .. } => rows.is_empty(), Step::Renamed { before, after, .. } => before == after, @@ -140,6 +143,13 @@ impl History { self.done.last_mut() } + pub(crate) fn replayed_mut(&mut self, forward: bool) -> Option<&mut Step> { + match forward { + true => self.done.last_mut(), + false => self.undone.last_mut(), + } + } + /// The last step, to be replayed backwards. `None` when there is nothing left to undo. pub(crate) fn undo(&mut self) -> Option { let step = self.done.pop()?; diff --git a/crates/table/src/lib.rs b/crates/table/src/lib.rs index 3f99473b..aa3e3830 100644 --- a/crates/table/src/lib.rs +++ b/crates/table/src/lib.rs @@ -24,6 +24,9 @@ mod relink; mod row_index; mod visual; +#[cfg(test)] +mod tests; + pub use agent::{AGENT_SOURCE, respond_to_agent, respond_to_agent_async}; pub use delegate::{QrateTableDelegate, Selection, TableChanged}; pub use editor::editor_box; @@ -434,7 +437,21 @@ pub fn history_step(redo: bool, cx: &mut App) { true => Origin::Redo, false => Origin::Undo, }; - settle(&state, &changes, origin, cx); + let dropped = settle(&state, &changes, origin, cx); + let removed: Vec<_> = changes + .iter() + .filter_map(|change| match change { + Change::RowRemoved { row, .. } => Some(*row), + _ => None, + }) + .collect(); + if !removed.is_empty() { + state.update(cx, |state, _| { + state + .delegate_mut() + .track_row_notes(&removed, dropped, redo); + }); + } } /// Everything that follows the grid changing under the user rather than one cell at a time — an @@ -445,7 +462,7 @@ fn settle( changes: &[Change], origin: Origin, cx: &mut App, -) { +) -> Vec { state.update(cx, |state, cx| { // A row put back by a restore carries no photo, and an undone filename names another. let rows = changed_rows(state.delegate(), changes); @@ -455,13 +472,38 @@ fn settle( cx.emit(delegate::TableChanged); cx.notify(); }); - if history::moves_rows(changes) { + let dropped = if history::moves_rows(changes) { let row_ids = state.read(cx).delegate().row_ids().to_vec(); - diagnostics::Diagnostics::align_note_rows(diagnostics::DATASET_MAIN, &row_ids, origin, cx); - } + diagnostics::Diagnostics::align_note_rows( + diagnostics::DATASET_MAIN, + &row_ids, + origin.clone(), + cx, + ) + } else { + Vec::new() + }; for change in changes { - if let Change::ColumnRenamed { before, after } = change { - follow_rename(before, &after.clone().into(), cx); + match change { + Change::ColumnRenamed { before, after } => { + follow_rename(before, &after.clone().into(), cx); + } + Change::Note { + row, column, after, .. + } => { + let position = row.and_then(|id| state.read(cx).delegate().row_of(id)); + if row.is_some() && position.is_none() && after.is_some() { + continue; + } + let location = diagnostics::Location { + dataset: diagnostics::DATASET_MAIN.into(), + row: position, + row_id: *row, + column: column.clone().map(Into::into), + }; + diagnostics::Diagnostics::apply_note(location, change, origin.clone(), cx); + } + _ => {} } } TablePanel::persist_columns(state, cx); @@ -469,6 +511,7 @@ fn settle( settings::dirty::mark(settings::dirty::PROJECT_DATA, cx); revalidate_now(cx); autosave(cx); + dropped } /// Re-key what is stored under a column's name — its notes, its settings, its `__columns` row — @@ -520,10 +563,7 @@ pub fn restore_to(to: EntryId, cx: &mut App) { settle(&state, &applied, Origin::Restore(to), cx); for note in notes { - let Change::Note { - row, column, after, .. - } = note - else { + let Change::Note { row, column, .. } = ¬e else { continue; }; let position = row.and_then(|id| state.read(cx).delegate().row_of(id)); @@ -533,15 +573,10 @@ pub fn restore_to(to: EntryId, cx: &mut App) { let location = diagnostics::Location { dataset: diagnostics::DATASET_MAIN.into(), row: position, - row_id: row, - column: column.map(Into::into), + row_id: *row, + column: column.clone().map(Into::into), }; - diagnostics::Diagnostics::set_note( - location, - after.unwrap_or_default().into(), - Origin::Restore(to), - cx, - ); + diagnostics::Diagnostics::apply_note(location, ¬e, Origin::Restore(to), cx); } } @@ -568,6 +603,45 @@ pub fn restore_value( } } +/// Restore a note at its row's live position and follow column renames since its history entry. +pub fn restore_note(change: &Change, from: EntryId, cx: &mut App) { + let Change::Note { row, column, .. } = change else { + return; + }; + let Some((file, state)) = save_target(cx) else { + return; + }; + let delegate = state.read(cx).delegate(); + let position = row.and_then(|id| delegate.row_of(id)); + if row.is_some() && position.is_none() { + return; + } + let mut column = column.clone(); + if column.is_some() { + let entries = match delegate.history_since(&file, from) { + Ok(entries) => entries, + Err(err) => { + log::error!("couldn't resolve the restored note's column: {err}"); + return; + } + }; + for change in entries.iter().flat_map(|entry| &entry.changes) { + if let Change::ColumnRenamed { before, after } = change + && column.as_deref() == Some(before.as_str()) + { + column = Some(after.clone()); + } + } + if column + .as_deref() + .is_some_and(|column| delegate.data_col(column).is_none()) + { + return; + } + } + diagnostics::Diagnostics::restore_note(change, position, column.map(Into::into), from, cx); +} + /// A change to the grid's shape rather than its contents. One enum rather than five entry points /// because every one of them has the same tail: re-index the notes, re-save the column layout, /// revalidate, mark dirty, save. @@ -751,12 +825,26 @@ pub fn structural(op: Structural, cx: &mut App) { Structural::InsertRow { .. } | Structural::DuplicateRow { .. } | Structural::DeleteRows(_) ) { let row_ids = state.read(cx).delegate().row_ids().to_vec(); - diagnostics::Diagnostics::align_note_rows( + let dropped = diagnostics::Diagnostics::align_note_rows( diagnostics::DATASET_MAIN, &row_ids, Origin::Structure, cx, ); + if !dropped.is_empty() { + let removed: Vec<_> = dropped + .iter() + .filter_map(|change| match change { + Change::Note { row, .. } => *row, + _ => None, + }) + .collect(); + state.update(cx, |state, _| { + state + .delegate_mut() + .track_row_notes(&removed, dropped, true); + }); + } } if matches!( op, @@ -945,6 +1033,19 @@ pub fn save_now(cx: &mut App) -> Result<(), String> { Ok(()) } +/// Flush pending edits before clearing the log, so queued saves cannot put old entries back. +pub fn clear_history(cx: &mut App) -> Result<(), String> { + save_now(cx)?; + if let Some(file) = cx + .try_global::() + .map(|project| project.file.clone()) + { + settings::history::clear(&file) + .map_err(|err| format!("Couldn't clear the project history: {err}"))?; + } + Ok(()) +} + /// What the workspace's Getting started guide reads off the grid to tell which of its tasks are /// already done. Read rather than reported: the grid has no idea a guide exists, and a task an /// archivist finished on their own (typing a title before being asked) should still tick. diff --git a/crates/table/src/note.rs b/crates/table/src/note.rs index 706a45e4..94311abe 100644 --- a/crates/table/src/note.rs +++ b/crates/table/src/note.rs @@ -9,16 +9,17 @@ use std::path::PathBuf; use gpui::prelude::FluentBuilder as _; use gpui::{ - AnyElement, App, ClipboardItem, Context, Entity, Focusable as _, InteractiveElement as _, - IntoElement, ParentElement as _, Path, SharedString, Styled as _, Window, canvas, deferred, - div, point, px, + AnyElement, AnyView, App, AppContext as _, ClipboardItem, Context, Entity, Focusable as _, + InteractiveElement as _, IntoElement, ParentElement as _, Path, SharedString, Styled as _, + Window, canvas, deferred, div, point, px, }; use gpui_component::input::Textarea; use gpui_component::menu::{PopupMenu, PopupMenuItem}; use gpui_component::table::TableState; +use gpui_component::tooltip::Tooltip; use gpui_component::{ActiveTheme as _, Icon, IconName, h_flex}; -use diagnostics::{Diagnostics, Location, Severity, Source, severity_color}; +use diagnostics::{Diagnostics, Location, NoteId, Severity, Source, severity_color}; use plugin_api::{ ColumnMapContributions, CommandContext, MenuContributions, MenuTarget, PluginHooks, }; @@ -121,30 +122,108 @@ pub(crate) fn squiggle(severity: Severity, cx: &App) -> impl IntoElement { .h(px(SQUIGGLE_H + SQUIGGLE_T)) } -/// The column description followed by every diagnostic at a location — the cell/header tooltip. -pub(crate) fn tooltip_text( - delegate: &QrateTableDelegate, - location: &Location, - cx: &App, -) -> Option { +/// What hovering a cell, a row number or a header shows. +#[derive(Clone)] +pub(crate) enum Tip { + /// The note card for the note filed here, with the column's description and every other + /// finding here under it. + Note { + id: NoteId, + description: Option, + findings: Vec<(Severity, SharedString)>, + }, + /// The column's description followed by every finding, where no note is filed. + Text(SharedString), +} + +/// The tip for `location`, if there is anything to say. A note on the data comes first; a row +/// whose only notes are on its file shows the first of those. +pub(crate) fn tip(delegate: &QrateTableDelegate, location: &Location, cx: &App) -> Option { let description = location .column .as_deref() .and_then(|name| delegate.column_description(name)); - let diagnostics = Diagnostics::at( - &location.dataset, - location.row, - location.column.as_deref(), - cx, - ) - .map(|diagnostic| diagnostic.message.as_ref()) - .collect::>() - .join("\n"); - match (description, diagnostics.is_empty()) { - (Some(description), false) => Some(format!("{description}\n\n{diagnostics}").into()), - (Some(description), true) => Some(description.to_string().into()), - (None, false) => Some(diagnostics.into()), - (None, true) => None, + let here = || { + Diagnostics::at( + &location.dataset, + location.row, + location.column.as_deref(), + cx, + ) + }; + let shown = here() + .filter_map(|d| d.note.as_ref()) + .min_by_key(|n| n.region.is_some()) + .map(|n| n.id); + match shown { + Some(id) => Some(Tip::Note { + id, + description, + findings: here() + .filter(|d| d.note.as_ref().is_none_or(|n| n.id != id)) + .map(|d| (d.severity, d.message.clone())) + .collect(), + }), + None => { + let findings = here() + .map(|d| d.message.as_ref()) + .collect::>() + .join("\n"); + match (description, findings.is_empty()) { + (Some(description), false) => Some(format!("{description}\n\n{findings}").into()), + (Some(description), true) => Some(description), + (None, false) => Some(findings.into()), + (None, true) => None, + } + .map(Tip::Text) + } + } +} + +/// `tip` as the tooltip view. `shown` is the cell's own text when the cell cut it short, which +/// leads a text tip and joins the description on a note card. +pub(crate) fn tip_view( + tip: Option<&Tip>, + shown: Option, + window: &mut Window, + cx: &mut App, +) -> AnyView { + match (tip.cloned(), shown) { + ( + Some(Tip::Note { + id, + description, + findings, + }), + shown, + ) => { + let description = match (shown, description) { + (Some(shown), Some(description)) => { + Some(format!("{shown}\n\n{description}").into()) + } + (shown, description) => shown.or(description), + }; + Tooltip::element(move |_, cx| match Diagnostics::note(id, cx) { + Some(note) => diagnostics::note_card::note_card( + note, + description.clone(), + findings.clone(), + cx, + ), + None => div().into_any_element(), + }) + .p_0() + .border_0() + .shadow_none() + .bg(gpui::transparent_black()) + .build(window, cx) + } + (Some(Tip::Text(text)), Some(shown)) => { + Tooltip::new(SharedString::from(format!("{shown}\n\n{text}"))).build(window, cx) + } + (Some(Tip::Text(text)), None) => Tooltip::new(text).build(window, cx), + (None, Some(shown)) => Tooltip::new(shown).build(window, cx), + (None, None) => cx.new(|_| gpui::EmptyView).into(), } } @@ -944,7 +1023,7 @@ mod tests { source: Source::Note, message: "look at this".into(), group: None, - filed: None, + note: None, }; // A cell note, a whole-row note, and a whole-column note — one per marker site. Diagnostics::set( @@ -969,7 +1048,7 @@ mod tests { source: v.clone(), message: "bad".into(), group: None, - filed: None, + note: None, }], cx, ); @@ -998,6 +1077,16 @@ mod tests { cx.try_global::().is_some(), "the table never painted, so nothing here was exercised" ); + + // The note leads its cell's card, and the error on the same cell goes under it. + let delegate = state.read(cx).delegate(); + match crate::note::tip(delegate, &at(Some(0), Some("Title")), cx) { + Some(crate::note::Tip::Note { findings, .. }) => { + assert_eq!(findings, vec![(Severity::Error, "bad".into())]); + } + _ => panic!("a cell with a note shows the note card"), + } + assert!(crate::note::tip(delegate, &at(Some(1), Some("Title")), cx).is_none()); }); cx.run_until_parked(); } diff --git a/crates/table/src/panel.rs b/crates/table/src/panel.rs index be970798..d2607a07 100644 --- a/crates/table/src/panel.rs +++ b/crates/table/src/panel.rs @@ -2391,7 +2391,7 @@ mod tests { source: Source::Note, message: "original".into(), group: None, - filed: None, + note: None, }], cx, ); diff --git a/crates/table/src/row_index.rs b/crates/table/src/row_index.rs index 4b4042d5..1e631e74 100644 --- a/crates/table/src/row_index.rs +++ b/crates/table/src/row_index.rs @@ -1,5 +1,3 @@ -use std::sync::OnceLock; - use gpui::prelude::FluentBuilder as _; use gpui::{ Context, FontWeight, InteractiveElement as _, IntoElement, SharedString, div, prelude::*, px, @@ -18,8 +16,6 @@ use crate::note::{self, Target}; pub(crate) const COL_IX: usize = 0; -const WIDTH: f32 = 128.; - #[derive(Clone)] struct RowDrag(usize); @@ -38,18 +34,19 @@ impl gpui::Render for RowDragPreview { } } -pub(crate) fn column() -> Column { - static COLUMN: OnceLock = OnceLock::new(); - COLUMN - .get_or_init(|| { - Column::new("__row_ix", "") - .fixed_left() - .resizable(false) - .movable(false) - .selectable(false) - .width(px(WIDTH)) - }) - .clone() +pub(crate) fn column(rows: usize, hierarchical: bool, font_size: gpui::Pixels) -> Column { + let digits = rows.max(1).ilog10() + 1; + let width = if hierarchical { + px(128.) + } else { + (font_size * (digits as f32 * 0.6 + 1.5)).max(px(36.)) + }; + Column::new("__row_ix", "") + .fixed_left() + .resizable(false) + .movable(false) + .selectable(false) + .width(width) } pub(crate) fn render_td( @@ -66,7 +63,7 @@ pub(crate) fn render_td( }; let location = delegate.location(Some(row_ix), None); let worst = Diagnostics::worst_at(&location.dataset, Some(row_ix), None, cx); - let tip = note::tooltip_text(delegate, &location, cx); + let tip = note::tip(delegate, &location, cx); let depth = delegate.row_depth(view_ix); let children = delegate.row_child_count(row_ix); let expanded = delegate.row_expanded(row_ix); @@ -152,11 +149,9 @@ pub(crate) fn render_td( }) .child(SharedString::from((row_ix + 1).to_string())) .when_some(worst, |d, severity| d.child(note::marker(severity, cx))) - .when_some(tip, |d, text| { - d.tooltip(move |window, cx| { - gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) - }) - .tooltip_show_delay(note::HOVER_DELAY) + .when_some(tip, |d, tip| { + d.tooltip(move |window, cx| note::tip_view(Some(&tip), None, window, cx)) + .tooltip_show_delay(note::HOVER_DELAY) }) .context_menu(move |menu, window, cx| note::menu(Target::Row(row_ix), menu, window, cx)) .when_some( diff --git a/crates/table/src/tests.rs b/crates/table/src/tests.rs new file mode 100644 index 00000000..437a561b --- /dev/null +++ b/crates/table/src/tests.rs @@ -0,0 +1,213 @@ +use std::path::PathBuf; + +use diagnostics::{DATASET_MAIN, Diagnostics, Location, NoteKind, Region}; +use gpui::{Entity, TestAppContext}; +use gpui_component::table::TableState; +use settings::history::{Change, Origin}; + +use crate::{QrateTableDelegate, Structural, TablePanel, TableStateHandle}; + +fn project( + cx: &mut TestAppContext, + name: &str, +) -> (PathBuf, Entity>) { + let folder = std::env::temp_dir().join(format!("qrate-audit-tests-{}", std::process::id())); + std::fs::create_dir_all(&folder).unwrap(); + let file = folder.join(format!("{name}.qrate")); + let _ = std::fs::remove_file(&file); + settings::project::create_project_file( + &file, + &settings::project::ProjectSpec { + name: "T", + headers: &["Title".into()], + rows: &[vec!["one".into()], vec!["two".into()]], + ..Default::default() + }, + ) + .unwrap(); + cx.update(|cx| { + gpui_component::init(cx); + let mut app = settings::AppSettings::default(); + app.values.insert( + settings::AUTOSAVE_KEY.into(), + settings::Val::Text("off".into()), + ); + cx.set_global(app); + cx.set_global(settings::SettingsPersistence::default()); + cx.set_global(settings::project::CurrentProject { + file: file.clone(), + data: settings::project::load_project_file(&file).unwrap(), + }); + diagnostics::init(cx); + }); + cx.add_window_view(TablePanel::new); + let state = cx.update(|cx| cx.global::().0.upgrade().unwrap()); + (file, state) +} + +fn location(row: usize, row_id: i64) -> Location { + Location { + dataset: DATASET_MAIN.into(), + row: Some(row), + row_id: Some(row_id), + column: Some("Title".into()), + } +} + +#[gpui::test] +fn row_delete_undo_restores_notes_and_annotations(cx: &mut TestAppContext) { + let (file, state) = project(cx, "delete-undo"); + cx.update(|cx| { + let region = Region { + page: 0, + x: 100, + y: 200, + w: 300, + h: 400, + of: None, + }; + let note = + Diagnostics::file_note(location(0, 1), None, None, "note".into(), Origin::Typed, cx) + .unwrap(); + let annotation = Diagnostics::file_note( + location(0, 1), + Some(region), + Some(NoteKind::Question), + "annotation".into(), + Origin::Drawn, + cx, + ) + .unwrap(); + crate::structural(Structural::DeleteRows(vec![0]), cx); + assert!(Diagnostics::note(note, cx).is_none()); + assert!(Diagnostics::note(annotation, cx).is_none()); + + crate::history_step(false, cx); + assert_eq!(state.read(cx).delegate().row_of(1), Some(0)); + assert_eq!( + Diagnostics::note(note, cx).unwrap().message.as_ref(), + "note" + ); + let restored = Diagnostics::note(annotation, cx).unwrap(); + assert_eq!(restored.location, location(0, 1)); + assert_eq!(restored.note.as_ref().unwrap().region, Some(region)); + assert_eq!( + restored.note.as_ref().unwrap().kind, + Some(NoteKind::Question) + ); + + Diagnostics::edit_note(note, "revised".into(), None, Origin::Typed, cx); + crate::history_step(true, cx); + assert!(Diagnostics::note(note, cx).is_none()); + crate::history_step(false, cx); + assert_eq!( + Diagnostics::note(note, cx).unwrap().message.as_ref(), + "revised" + ); + + Diagnostics::edit_note(note, "".into(), None, Origin::Typed, cx); + crate::history_step(true, cx); + crate::history_step(false, cx); + assert!( + Diagnostics::note(note, cx).is_none(), + "a separately deleted note stays deleted" + ); + }); + let notes = settings::project::read_notes(&file).unwrap(); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].row_id, Some(1)); + assert_eq!(notes[0].message, "annotation"); +} + +#[gpui::test] +fn redo_insert_restores_notes_written_after_insertion(cx: &mut TestAppContext) { + let (_, state) = project(cx, "insert-redo"); + cx.update(|cx| { + crate::structural(Structural::InsertRow { at: 1 }, cx); + let row_id = state.read(cx).delegate().row_id(1).unwrap(); + let note = Diagnostics::file_note( + location(1, row_id), + None, + None, + "new".into(), + Origin::Typed, + cx, + ) + .unwrap(); + crate::history_step(false, cx); + assert!(Diagnostics::note(note, cx).is_none()); + crate::history_step(true, cx); + assert_eq!( + Diagnostics::note(note, cx).unwrap().location, + location(1, row_id) + ); + assert_eq!(Diagnostics::note(note, cx).unwrap().message.as_ref(), "new"); + }); +} + +#[gpui::test] +fn restoring_note_follows_saved_and_pending_column_renames(cx: &mut TestAppContext) { + let (file, _) = project(cx, "restore-renamed-note"); + cx.update(|cx| { + let note = Diagnostics::file_note( + location(0, 1), + None, + None, + "original".into(), + Origin::Typed, + cx, + ) + .unwrap(); + Diagnostics::edit_note(note, "edited".into(), None, Origin::Typed, cx); + let edited = settings::history::entries_after(&file, 0) + .unwrap() + .pop() + .unwrap(); + crate::structural( + Structural::RenameColumn { + col: 0, + name: "Caption".into(), + }, + cx, + ); + crate::save_now(cx).unwrap(); + crate::structural( + Structural::RenameColumn { + col: 0, + name: "Label".into(), + }, + cx, + ); + Diagnostics::edit_note(note, "".into(), None, Origin::Typed, cx); + + crate::restore_note(&edited.changes[0], edited.id, cx); + let restored = Diagnostics::note(note, cx).unwrap(); + assert_eq!(restored.message.as_ref(), "original"); + assert_eq!(restored.location.column.as_deref(), Some("Label")); + assert_eq!(restored.location.row_id, Some(1)); + }); +} + +#[gpui::test] +fn clearing_history_flushes_edits_and_keeps_queued_saves_from_recreating_it( + cx: &mut TestAppContext, +) { + let (file, state) = project(cx, "clear-history"); + cx.update(|cx| { + crate::write_cell(0, 0, "before clear".into(), Origin::Typed, cx); + let queued = state.read(cx).delegate().save_snapshot(); + assert!(!queued.history.is_empty()); + crate::clear_history(cx).unwrap(); + assert!(state.read(cx).delegate().unsaved_history().is_empty()); + assert!(settings::history::entries_after(&file, 0).unwrap().is_empty()); + assert_eq!(settings::project::load_project_file(&file).unwrap().rows[0][0], "before clear"); + + crate::write_snapshot(&file, &queued).unwrap(); + assert!(settings::history::entries_after(&file, 0).unwrap().is_empty()); + crate::write_cell(0, 0, "after clear".into(), Origin::Typed, cx); + crate::save_now(cx).unwrap(); + let entries = settings::history::entries_after(&file, 0).unwrap(); + assert_eq!(entries.len(), 1); + assert!(matches!(&entries[0].changes[0], Change::Cell { before, after, .. } if before == "before clear" && after == "after clear")); + }); +} diff --git a/crates/window-wrapper/src/lib.rs b/crates/window-wrapper/src/lib.rs index 85e2903d..fd02b491 100644 --- a/crates/window-wrapper/src/lib.rs +++ b/crates/window-wrapper/src/lib.rs @@ -1,4 +1,6 @@ pub mod bar; +pub mod panel_headers; +pub mod responsive_header; pub mod status_bar; pub mod title_bar; pub mod window_registry; diff --git a/crates/window-wrapper/src/panel_headers.rs b/crates/window-wrapper/src/panel_headers.rs new file mode 100644 index 00000000..7338f935 --- /dev/null +++ b/crates/window-wrapper/src/panel_headers.rs @@ -0,0 +1,63 @@ +//! Optional panel-owned header stages. Providers receive a view only while its header renders; +//! the registry stores function pointers, never panel entities. + +use std::collections::HashMap; + +use gpui::*; + +use crate::responsive_header::{HeaderMenu, HeaderStage}; + +pub trait PanelHeaders: Render { + fn header_stages( + &mut self, + defaults: Vec, + common_menu: HeaderMenu, + cx: &mut Context, + ) -> Vec; +} + +type Provider = fn(AnyView, Vec, HeaderMenu, &mut App) -> Vec; + +#[derive(Default)] +pub struct PanelHeaderRegistry(HashMap<&'static str, Provider>); + +impl Global for PanelHeaderRegistry {} + +impl PanelHeaderRegistry { + pub fn register(name: &'static str, cx: &mut App) { + fn stages( + view: AnyView, + defaults: Vec, + common_menu: HeaderMenu, + cx: &mut App, + ) -> Vec { + match view.downcast::() { + Ok(panel) => panel.update(cx, |panel, cx| { + panel.header_stages(defaults, common_menu, cx) + }), + Err(_) => defaults, + } + } + if !cx.has_global::() { + cx.set_global(Self::default()); + } + cx.global_mut::().0.insert(name, stages::); + } + + pub(crate) fn stages( + name: &str, + view: AnyView, + defaults: Vec, + common_menu: HeaderMenu, + cx: &mut App, + ) -> Vec { + let provider = cx + .try_global::() + .and_then(|registry| registry.0.get(name)) + .copied(); + match provider { + Some(provider) => provider(view, defaults, common_menu, cx), + None => defaults, + } + } +} diff --git a/crates/window-wrapper/src/responsive_header.rs b/crates/window-wrapper/src/responsive_header.rs new file mode 100644 index 00000000..e905d39c --- /dev/null +++ b/crates/window-wrapper/src/responsive_header.rs @@ -0,0 +1,344 @@ +//! Measured, ordered header layouts. Panel owners decide which controls remain visible at +//! each stage and supply matching dropdown entries for the controls that disappear. + +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_component::{ + IconName, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + menu::{DropdownMenu as _, PopupMenu}, +}; + +use crate::panel_headers::PanelHeaderRegistry; + +type Content = Rc AnyElement>; +pub type HeaderMenu = + Rc, Option) -> PopupMenu>; + +/// One candidate, ordered from most visible controls to least visible controls. +#[derive(Clone)] +pub struct HeaderStage { + content: Content, + measurement: Option, + menu: Option, + truncate: bool, +} + +impl HeaderStage { + pub fn new(content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self { + Self { + content: Rc::new(content), + measurement: None, + menu: None, + truncate: false, + } + } + + pub fn with_menu(mut self, menu: HeaderMenu) -> Self { + self.menu = Some(menu); + self + } + + pub fn truncate(mut self) -> Self { + self.truncate = true; + self + } + + pub fn measure_controls( + mut self, + content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.measurement = Some(Rc::new(content)); + self + } +} + +#[derive(Default)] +struct HeaderState { + action_context: Option, + menu_open: bool, + stage: Option, +} + +pub struct ResponsiveHeader { + id: ElementId, + height: Pixels, + stages: Vec, + menu_left: bool, +} + +impl ResponsiveHeader { + /// Default policy for simple headers: normal controls, then a title and dropdown. + pub fn new( + id: impl Into, + height: Pixels, + expanded: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + title: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + menu: impl Fn(PopupMenu, &mut Window, &mut Context, Option) -> PopupMenu + + 'static, + ) -> Self { + Self::from_stages( + id, + height, + vec![ + HeaderStage::new(expanded), + HeaderStage::new(title).truncate().with_menu(Rc::new(menu)), + ], + ) + } + + pub fn from_stages(id: impl Into, height: Pixels, stages: Vec) -> Self { + assert!( + !stages.is_empty(), + "a responsive header needs at least one stage" + ); + Self { + id: id.into(), + height, + stages, + menu_left: false, + } + } + + /// Let a registered panel replace the default stages without changing the dock skin. + pub fn panel_stages( + mut self, + name: &str, + view: AnyView, + common_menu: HeaderMenu, + cx: &mut App, + ) -> Self { + let defaults = self.stages.clone(); + let stages = PanelHeaderRegistry::stages(name, view, self.stages, common_menu, cx); + self.stages = if stages.is_empty() { defaults } else { stages }; + self + } + + pub fn measure_controls( + mut self, + content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + self.stages[0] = self.stages[0].clone().measure_controls(content); + self + } + + pub fn menu_left(mut self) -> Self { + self.menu_left = true; + self + } +} + +impl IntoElement for ResponsiveHeader { + type Element = Self; + fn into_element(self) -> Self { + self + } +} + +pub struct HeaderLayout { + widths: Vec, + state: Entity, +} + +impl Element for ResponsiveHeader { + type RequestLayoutState = HeaderLayout; + type PrepaintState = AnyElement; + + fn id(&self) -> Option { + Some(self.id.clone()) + } + fn source_location(&self) -> Option<&'static std::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, HeaderLayout) { + let state = window.use_keyed_state("header-state", cx, |_, _| HeaderState::default()); + if !state.read(cx).menu_open { + let focus = window.focused(cx); + state.update(cx, |state, _| state.action_context = focus); + } + let widths = self + .stages + .iter() + .enumerate() + .map(|(ix, stage)| { + let content = stage.measurement.as_ref().unwrap_or(&stage.content); + let body = content(window, cx); + let body = if stage.menu.is_some() { + // Probes contain a stateless button-sized box. Creating live dropdowns here + // would retain an open popup after its stage stopped being visible. + let menu_width = if self.menu_left { + px(24.) + } else { + window.rem_size() * 1.25 + }; + h_flex() + .gap_1() + .px_2() + .when(self.menu_left, |row| row.pl_0()) + .child(body) + .child(div().flex_none().w(menu_width)) + .into_any_element() + } else { + body + }; + let mut probe = div() + .id(ElementId::NamedInteger("header-probe".into(), ix as u64)) + .child(body) + .into_any_element(); + probe + .layout_as_root( + size( + AvailableSpace::MaxContent, + AvailableSpace::Definite(self.height), + ), + window, + cx, + ) + .width + }) + .collect(); + let mut frame = div().w_full().min_w_0().h(self.height).into_any_element(); + ( + frame.request_layout(window, cx), + HeaderLayout { widths, state }, + ) + } + + fn prepaint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + bounds: Bounds, + layout: &mut HeaderLayout, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let previous = layout.state.read(cx).stage; + // Collapse as soon as controls stop fitting. Require another 8px before restoring a + // wider stage, so small resize/font rounding changes cannot oscillate between stages. + let selected = layout + .widths + .iter() + .enumerate() + .position(|(ix, width)| { + let buffer = if previous.is_some_and(|previous| ix < previous) { + px(8.) + } else { + px(0.) + }; + *width + buffer <= bounds.size.width + }) + .unwrap_or(self.stages.len() - 1); + if previous != Some(selected) { + if layout.state.read(cx).menu_open + && let Some(focus) = layout.state.read(cx).action_context.clone() + { + focus.focus(window, cx); + } + layout.state.update(cx, |state, _| { + state.stage = Some(selected); + state.menu_open = false; + }); + } + let stage = &self.stages[selected]; + let body = (stage.content)(window, cx); + let body = if let Some(builder) = &stage.menu { + let builder = builder.clone(); + let focus_state = layout.state.clone(); + let open_state = layout.state.clone(); + let menu = Button::new("header-overflow-trigger") + .icon(IconName::Menu) + .ghost() + .xsmall() + .when(self.menu_left, |button| { + button.with_size(px(20.)).size(px(24.)) + }) + .tooltip("Header controls") + .dropdown_menu_with_anchor( + if self.menu_left { + Anchor::TopLeft + } else { + Anchor::TopRight + }, + move |menu, window, cx| { + let focus = focus_state.read(cx).action_context.clone(); + let menu = match &focus { + Some(focus) => menu.action_context(focus.clone()), + None => menu, + }; + builder(menu, window, cx, focus) + }, + ) + .on_open_change(move |open, _, cx| { + open_state.update(cx, |state, _| state.menu_open = *open); + }); + let body = div() + .flex_1() + .min_w_0() + .when(stage.truncate, |body| body.truncate()) + .child(body); + // The host occludes the native drag region; its trigger must not occlude the host. + let menu = div().flex_none().occlude().child(menu); + let row = h_flex() + .size_full() + .min_w_0() + .gap_1() + .px_2() + .when(self.menu_left, |row| row.pl_0()); + if self.menu_left { + row.child(menu).child(body) + } else { + row.child(body).child(menu) + } + .into_any_element() + } else { + body + }; + // Definite available space alone does not force a root's width. Stretch the selected + // header so its existing left/right justification still spans the panel. + let mut visible = div() + .id(ElementId::NamedInteger( + "header-stage".into(), + selected as u64, + )) + .size_full() + .flex() + .flex_col() + .items_stretch() + .child(body) + .into_any_element(); + visible.prepaint_as_root( + bounds.origin, + size( + AvailableSpace::Definite(bounds.size.width), + AvailableSpace::Definite(bounds.size.height), + ), + window, + cx, + ); + visible + } + + fn paint( + &mut self, + _: Option<&GlobalElementId>, + _: Option<&InspectorElementId>, + _: Bounds, + _: &mut HeaderLayout, + visible: &mut AnyElement, + window: &mut Window, + cx: &mut App, + ) { + visible.paint(window, cx); + } +} diff --git a/crates/window-wrapper/src/title_bar.rs b/crates/window-wrapper/src/title_bar.rs index aa9eb554..e4b82aff 100644 --- a/crates/window-wrapper/src/title_bar.rs +++ b/crates/window-wrapper/src/title_bar.rs @@ -3,18 +3,22 @@ use gpui::*; use gpui_component::{ActiveTheme, TitleBar}; use crate::bar::{BarItems, BarRegistry}; +use crate::responsive_header::{HeaderMenu, ResponsiveHeader}; #[derive(Default)] -pub struct TitleBarRegistry(BarItems); +pub struct TitleBarRegistry { + items: BarItems, + pub overflow_menu: Option, +} impl Global for TitleBarRegistry {} impl BarRegistry for TitleBarRegistry { fn items(&self) -> &BarItems { - &self.0 + &self.items } fn items_mut(&mut self) -> &mut BarItems { - &mut self.0 + &mut self.items } } @@ -50,6 +54,7 @@ impl RenderOnce for AppTitleBar { let views = |items: &Vec| { items .iter() + .filter(|item| item.occupied(cx)) .map(|item| item.view.clone()) .collect::>() }; @@ -58,45 +63,129 @@ impl RenderOnce for AppTitleBar { .map(|r| (views(&r.items().left), views(&r.items().right))) .unwrap_or_default(); + let compact_title = self.title.clone(); + let compact_author = self.author.clone(); + let measure_left = left.clone(); + let measure_right = right.clone(); + let dirty = self.dirty; + TitleBar::new() + // macOS reserves this space for its traffic lights; other platforms can start + // the menu close to the window edge. + .when(!cfg!(target_os = "macos"), |bar| bar.pl(px(8.))) .text_xs() .text_color(cx.theme().foreground) .child( - gpui_component::h_flex() - .flex_1() - .gap_1() - .justify_start() - .children(left), - ) - .child( - gpui_component::h_flex() - .justify_center() - .items_center() - .gap_1p5() - .when(self.dirty, |this| { - this.child(div().size(px(6.)).rounded_full().bg(cx.theme().foreground)) - }) - .when(!self.title.is_empty(), |this| { - this.child(self.title.clone()) - }) - // Who the change log will credit, said as what it is: the name this - // session's edits go out under, not a property of the project. - .child(div().text_color(cx.theme().muted_foreground).child( - if self.author.is_empty() { - "· No author set".to_string() - } else { - format!("· Editing as {}", self.author) - }, - )), - ) - .child( - gpui_component::h_flex() - .flex_1() - .justify_end() - .pr_4() - .gap_1() - .items_center() - .children(right), + ResponsiveHeader::new( + "app-responsive-header", + gpui_component::TITLE_BAR_HEIGHT, + move |window, cx| { + gpui_component::h_flex() + .size_full() + .min_w_0() + .gap_2() + .child( + gpui_component::h_flex() + .flex_none() + .gap_1() + .children(left.clone()), + ) + .child( + gpui_component::h_flex() + .flex_1() + .min_w(window.rem_size() * 3.) + .justify_center() + .gap_1p5() + .overflow_hidden() + .when(dirty, |title| { + title.child( + div() + .flex_none() + .size(px(6.)) + .rounded_full() + .bg(cx.theme().foreground), + ) + }) + .child(div().min_w_0().truncate().child(self.title.clone())) + .child( + div() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(if self.author.is_empty() { + "· No author set".to_string() + } else { + format!("· Editing as {}", self.author) + }), + ), + ) + .child( + gpui_component::h_flex() + .flex_none() + .pr_4() + .gap_1() + .children(right.clone()), + ) + .into_any_element() + }, + move |_, cx| { + gpui_component::h_flex() + .min_w_0() + .gap_1p5() + .justify_center() + .when(dirty, |title| { + title.child( + div() + .flex_none() + .size(px(6.)) + .rounded_full() + .bg(cx.theme().foreground), + ) + }) + .child(div().min_w_0().truncate().child(compact_title.clone())) + .child( + div() + .min_w_0() + .truncate() + .text_color(cx.theme().muted_foreground) + .child(if compact_author.is_empty() { + "· No author set".to_string() + } else { + format!("· Editing as {compact_author}") + }), + ) + .into_any_element() + }, + move |menu, window, cx, focus| { + let builder = cx + .try_global::() + .and_then(|registry| registry.overflow_menu.clone()); + match builder { + Some(builder) => builder(menu, window, cx, focus), + None => menu, + } + }, + ) + .measure_controls(move |window, _| { + gpui_component::h_flex() + .gap_2() + .child( + gpui_component::h_flex() + .flex_none() + .gap_1() + .children(measure_left.clone()), + ) + .child(div().flex_none().w(window.rem_size() * 3.)) + .child( + gpui_component::h_flex() + .flex_none() + .pr_4() + .gap_1() + .children(measure_right.clone()), + ) + .into_any_element() + }) + .menu_left(), ) } } diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 7caece29..ca20e443 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -17,6 +17,7 @@ settings = { path = "../settings" } table = { path = "../table" } agent-runtime = { path = "../agent-runtime" } components = { path = "../components" } +window-wrapper = { path = "../window-wrapper" } [dev-dependencies] # `#[gpui::test]`, TestAppContext, and run_test are all behind this feature. diff --git a/crates/workspace/src/panels/agent.rs b/crates/workspace/src/panels/agent.rs index 72368c9e..9789f996 100644 --- a/crates/workspace/src/panels/agent.rs +++ b/crates/workspace/src/panels/agent.rs @@ -6,7 +6,7 @@ use std::time::{Duration, Instant}; use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_component::dock::{BasePanel, DockPlacement, Panel, PanelEvent}; -use gpui_component::menu::{ContextMenuExt as _, PopupMenuItem}; +use gpui_component::menu::{ContextMenuExt as _, PopupMenu, PopupMenuItem}; use gpui_component::scroll::ScrollableElement as _; use gpui_component::tab::{Tab, TabBar}; use gpui_component::{ @@ -398,6 +398,72 @@ impl BasePanel for AgentPanel { } impl Panel for AgentPanel { + fn dropdown_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + let panel = cx.entity().downgrade(); + let views = PopupMenu::build(window, cx, |menu, _, _| { + View::ALL.into_iter().fold(menu, |menu, view| { + let panel = panel.clone(); + menu.item( + PopupMenuItem::new(view.label()) + .checked(view == self.view) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.view = view; + if view == View::Terminal && !this.terminal.is_running() { + this.opened_auth_urls.clear(); + this.terminal.start(true, cx); + } + cx.notify(); + }) + .ok(); + }), + ) + }) + }); + let menu = menu.item(PopupMenuItem::submenu("View", views)); + let new_session = panel.clone(); + let stop = panel.clone(); + menu.separator() + .item( + PopupMenuItem::new("New Pi session").on_click(move |_, _, cx| { + new_session + .update(cx, |this, cx| { + this.opened_auth_urls.clear(); + this.view = View::Terminal; + this.terminal.new_session(cx); + cx.notify(); + }) + .ok(); + }), + ) + .item( + PopupMenuItem::new("Stop Pi") + .disabled(!self.terminal.is_running()) + .on_click(move |_, _, cx| { + stop.update(cx, |this, cx| { + this.terminal.stop(); + cx.notify(); + }) + .ok(); + }), + ) + .item(PopupMenuItem::new("Restart Pi").on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.opened_auth_urls.clear(); + this.terminal.start(true, cx); + cx.notify(); + }) + .ok(); + })) + } + fn title(&mut self, _w: &mut Window, _cx: &mut Context) -> impl IntoElement { SharedString::from("Agent") } diff --git a/crates/workspace/src/panels/details.rs b/crates/workspace/src/panels/details.rs index fb529e55..3c264e8e 100644 --- a/crates/workspace/src/panels/details.rs +++ b/crates/workspace/src/panels/details.rs @@ -1,5 +1,5 @@ -use std::cell::Cell; -use std::collections::HashSet; +use std::cell::{Cell, RefCell}; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::Duration; @@ -7,11 +7,12 @@ use std::time::Duration; use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_component::{ - ActiveTheme, Icon, IconName, Sizable, StyledExt as _, + ActiveTheme, Disableable as _, Icon, IconName, Selectable as _, Sizable, StyledExt as _, button::{Button, ButtonVariants}, dock::{BasePanel, DockPlacement, Panel, PanelEvent}, h_flex, - input::{Escape, InputEvent, TextareaState}, + input::{Escape, InputEvent, Textarea, TextareaState}, + menu::{DropdownMenu as _, PopupMenu, PopupMenuItem}, resizable::{resizable_panel, v_resizable}, scroll::ScrollableElement, table::TableState, @@ -67,6 +68,17 @@ const EDITOR_MAX_WINDOW_SHARE: f32 = 0.4; /// text is a click away in the editor. const VALUE_LINE_CLAMP: usize = 4; +/// A file's version on disk, and the pixel size it had at that version. +type Header = (Option, Option<(u32, u32)>); + +/// Which notes the Notes section lists. +#[derive(Clone, Copy, PartialEq, Eq)] +enum NotesFilter { + All, + Image, + Fields, +} + /// Where Details starts out and what it puts in the status bar. `default_placement` is only the /// starting point — the user can dock it anywhere, and that choice is what gets persisted. pub static DETAILS_META: PanelMeta = PanelMeta { @@ -153,6 +165,25 @@ pub struct DetailsPanel { rows: Option>, /// Repaints the Pop out button when that window opens or closes. _pop_out_sub: Subscription, + notes_filter: NotesFilter, + /// Only the notes signed with the archivist's own name. + notes_mine: bool, + notes_hover: Option, + /// The note being reworded in its card, and the kind picked for it there. + note_edit: Option<( + diagnostics::NoteId, + Entity, + Option, + )>, + notes_scroll: ScrollHandle, + history_scroll: ScrollHandle, + notes_header_width: Rc>, + /// Set when the viewer picks a region, so its card is scrolled into view on the next paint. + follow_pick: Cell, + /// Each file's pixel size, read again only when the file on disk is a different one. + headers: RefCell>, + /// Repaints when a note changes, and follows the region the viewer picks. + _notes_subs: [Subscription; 2], } impl DetailsPanel { @@ -212,6 +243,22 @@ impl DetailsPanel { rows: None, _pop_out_sub: cx .observe_global::(|_this: &mut Self, cx| cx.notify()), + notes_filter: NotesFilter::All, + notes_mine: false, + notes_hover: None, + note_edit: None, + notes_scroll: ScrollHandle::new(), + history_scroll: ScrollHandle::new(), + notes_header_width: Rc::default(), + follow_pick: Cell::new(false), + headers: RefCell::default(), + _notes_subs: [ + cx.observe_global::(|_this: &mut Self, cx| cx.notify()), + cx.observe_global::(|this: &mut Self, cx| { + this.follow_pick.set(true); + cx.notify(); + }), + ], }; this.bind(cx); this @@ -402,65 +449,609 @@ impl DetailsPanel { cx.notify(); } - /// The Notes sub-panel: a collapsible list of what has been written about the selection, newest - /// group last, headed by the item it belongs to once more than one item is picked. + /// The Notes sub-panel: what has been written about the selection, grouped by item. A note on + /// a region of the file shows a crop of it; hovering lights the region in the viewer and a + /// click zooms the viewer to it. /// /// Returns `AnyElement` because it is one child of a deeply chained builder — see the note on /// `render_image_frame`. - fn notes_panel(&self, picked: &[usize], cx: &mut Context) -> AnyElement { + fn notes_panel( + &self, + picked: &[usize], + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { let Some(state) = self.state.as_ref().and_then(|w| w.upgrade()) else { return div().into_any_element(); }; let delegate = state.read(cx); let delegate = delegate.delegate(); - /// One note as the panel draws it: what it says, and who filed it when. - type Note = (SharedString, Option); - /// The notes on one selected item, under that item's title. - type Group = (SharedString, Vec); - + let me = settings::history::author(cx); + let can_filter_mine = me.is_some(); + let chosen = cx.try_global::().and_then(|p| p.0); + let (filter, mine) = (self.notes_filter, self.notes_mine); + let in_pop_out = self.rows.is_some(); + + struct Group { + name: SharedString, + title: SharedString, + path: Option, + missing: Option<(usize, SharedString)>, + paged: bool, + cards: Vec<(diagnostics::Diagnostic, Option, bool)>, + } + let mut total = 0; let groups: Vec = picked .iter() .filter_map(|&row| { + let numbers = delegate + .row_id(row) + .map(|id| crate::viewer::marks(id, cx)) + .unwrap_or_default(); + let path = delegate.row_image(row).map(Path::to_path_buf); + let header = std::cell::OnceCell::new(); let notes: Vec<_> = diagnostics::Diagnostics::notes_in_row(diagnostics::DATASET_MAIN, row, cx) - .map(|note| { - // Which field it hangs off, when it hangs off one: without it a note - // about the date and a note about the photographer read as two - // remarks on the same thing. - let filed = note.filed.as_ref().and_then(diagnostics::Filed::label); - let meta = match (note.location.column.as_ref(), filed) { - (Some(column), Some(filed)) => Some(format!("{column} · {filed}")), - (Some(column), None) => Some(column.to_string()), - (None, filed) => filed.map(Into::into), - }; - (note.message.clone(), meta.map(SharedString::from)) - }) .collect(); - if notes.is_empty() { + total += notes.len(); + let cards: Vec<_> = notes + .into_iter() + .filter(|note| { + let meta = note.note.as_ref(); + let on_image = meta.is_some_and(|n| n.region.is_some()); + let author = meta.and_then(|n| n.filed.as_ref()?.author.as_deref()); + let place = match filter { + NotesFilter::All => true, + NotesFilter::Image => on_image, + NotesFilter::Fields => !on_image, + }; + place && (!mine || (me.is_some() && author == me.as_deref())) + }) + .map(|note| { + let meta = note.note.as_ref(); + let number = meta + .and_then(|n| numbers.iter().find(|m| m.id == n.id).map(|m| m.number)); + let region = meta.and_then(|n| n.region); + let changed = region + .and_then(|_| *header.get_or_init(|| self.header(path.as_deref()?))) + .zip(region) + .is_some_and(|(header, region)| !region.fits(header)); + (note.clone(), number, changed) + }) + .collect(); + if cards.is_empty() { return None; } - let title = delegate + let regions = cards.iter().any(|(_, number, _)| number.is_some()); + let missing = regions + .then(|| table::file_links::missing_file(delegate, row, cx)) + .flatten() + .map(|(_, name)| (row, name)); + let mut fields = delegate .row_fields(row) .into_iter() .map(|(_, value)| value) - .find(|value| !value.is_empty()) - .unwrap_or_default(); - Some((title, notes)) + .filter(|value| !value.is_empty()); + let (name, title) = ( + fields.next().unwrap_or_default(), + fields.next().unwrap_or_default(), + ); + let paged = path + .as_deref() + .and_then(preview::known_pages) + .is_some_and(|pages| pages > 1); + Some(Group { + name, + title, + path, + missing, + paged, + cards, + }) }) .collect(); - let total: usize = groups.iter().map(|(_, notes)| notes.len()).sum(); - let several = picked.len() > 1; + let shown: usize = groups.iter().map(|g| g.cards.len()).sum(); + + // Flat, so the picked card can be scrolled to by index. + let mut index = 0; + let mut chosen_at = None; + let theme = cx.theme().clone(); + let mut entries: Vec = Vec::new(); + for group in groups { + entries.push( + h_flex() + .gap_1p5() + .px_1() + .pt_1() + .text_xs() + .min_w_0() + .text_color(theme.muted_foreground) + .child( + div() + .flex_none() + .font_semibold() + .text_color(theme.foreground) + .child(group.name.clone()), + ) + .child(div().min_w_0().truncate().child(group.title.clone())) + .into_any_element(), + ); + index += 1; + if let Some((row, name)) = group.missing.clone() { + entries.push( + h_flex() + .gap_2() + .pl_2() + .pr_1p5() + .py_1() + .rounded(theme.radius) + .bg(theme.warning.opacity(0.12)) + .child( + Icon::new(IconName::TriangleAlert) + .xsmall() + .text_color(theme.warning), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .child(format!("File not found: {name}")), + ) + .child( + Button::new(("notes-locate", row)) + .label("Locate file…") + .xsmall() + .on_click(move |_, window, cx| { + TablePanelHandle::update(cx, |table, cx| { + table.locate_file(row, window, cx) + }) + }), + ) + .into_any_element(), + ); + index += 1; + } + for (note, number, changed) in group.cards { + let meta = note.note.clone(); + let id = meta.as_ref().map(|n| n.id); + let region = meta.as_ref().and_then(|n| n.region); + let on = id.is_some() && id == chosen; + if on { + chosen_at = Some(index); + } + let hovered = id.is_some() && id == self.notes_hover; + let editing = self + .note_edit + .as_ref() + .filter(|(editing, ..)| Some(*editing) == id) + .map(|(_, text, kind)| (text.clone(), *kind)); + let filed = meta.as_ref().and_then(|n| n.filed.clone()); + let lead = match (region, number) { + (Some(region), Some(number)) => [ + Some(format!("#{number}")), + group.paged.then(|| format!("page {}", region.page + 1)), + Some( + meta.as_ref() + .and_then(|n| n.kind) + .map_or("Note", |kind| kind.label()) + .to_string(), + ), + ] + .into_iter() + .flatten() + .collect::>(), + _ => note + .location + .column + .iter() + .map(|column| column.to_string()) + .collect(), + }; + let lead = lead + .into_iter() + .chain( + filed + .as_ref() + .and_then(|f| f.date.as_ref()) + .map(|d| d.to_string()), + ) + .collect::>() + .join(" · "); + let author: SharedString = filed + .as_ref() + .and_then(|f| f.author.clone()) + .unwrap_or_else(|| "Unsigned".into()); + let edited = meta.as_ref().and_then(|n| n.edited.clone()).map(|e| { + let by = e + .author + .clone() + .unwrap_or_else(|| "someone unsigned".into()); + match e.date { + Some(date) => format!("Edited {date} by {by}"), + None => format!("Edited by {by}"), + } + }); + let thumb = region.map(|region| { + let pin = region.w == 0 && region.h == 0; + let scale = f32::from(diagnostics::Region::SCALE); + let (x, y) = (f32::from(region.x) / scale, f32::from(region.y) / scale); + let (w, h) = (f32::from(region.w) / scale, f32::from(region.h) / scale); + let (border, muted, tile) = (theme.border, theme.muted_foreground, theme.muted); + let map = move || { + div() + .size(px(48.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .border_1() + .border_dashed() + .border_color(border) + .bg(tile) + .child( + div() + .relative() + .w(px(34.)) + .h(px(26.)) + .border_1() + .border_color(muted.opacity(0.6)) + .child(match pin { + true => div() + .absolute() + .left(relative(x)) + .top(relative(y)) + .ml(px(-2.)) + .mt(px(-2.)) + .size(px(4.)) + .rounded_full() + .bg(muted), + false => div() + .absolute() + .left(relative(x)) + .top(relative(y)) + .w(relative(w)) + .h(relative(h)) + .border_1() + .border_color(muted) + .bg(muted.opacity(0.25)), + }), + ) + .into_any_element() + }; + match group.path.as_deref().filter(|_| group.missing.is_none()) { + Some(path) => div() + .relative() + .size(px(48.)) + .flex_none() + .rounded(px(4.)) + .overflow_hidden() + .child( + img(preview::crop( + path, + region.page as usize, + [region.x, region.y, region.w, region.h], + )) + .size_full() + .object_fit(ObjectFit::Cover) + .with_fallback(map), + ) + .when(pin, |thumb| { + thumb.child( + div() + .absolute() + .left(px(19.)) + .top(px(19.)) + .size(px(10.)) + .rounded_full() + .bg(rgb(0x161616)) + .border(px(1.5)) + .border_color(white()), + ) + }) + .into_any_element(), + None => div().flex_none().child(map()).into_any_element(), + } + }); + entries.push( + h_flex() + .id(ElementId::NamedInteger( + "note-card".into(), + id.unwrap_or_default() as u64, + )) + .relative() + .items_start() + .gap_2() + .p_2() + .rounded(px(6.)) + .border_1() + .map(|card| match (on, hovered) { + (true, _) => card.border_color(theme.primary).bg(theme.table_active), + (false, true) => card + .border_color(theme.border) + .bg(theme.muted.opacity(0.55)), + (false, false) => card.border_color(theme.border).bg(theme.background), + }) + .when(region.is_some() && editing.is_none(), |card| { + card.cursor_pointer() + }) + .on_hover(cx.listener(move |this, hover: &bool, _, cx| { + let now = match hover { + true => id, + false => this.notes_hover.filter(|was| Some(*was) != id), + }; + if now != this.notes_hover { + this.notes_hover = now; + let lit = now.filter(|_| region.is_some()); + cx.set_global(crate::viewer::Lit(lit)); + cx.notify(); + } + })) + .when_some( + id.filter(|_| region.is_some() && editing.is_none()), + |card, id| { + card.on_click(move |_, window, cx| { + cx.set_global(crate::viewer::Picked(Some(id))); + crate::viewer::reveal(id, in_pop_out, window, cx); + }) + }, + ) + .children(thumb) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap(px(3.)) + .text_size(px(13.)) + .line_height(px(18.)) + .map(|body| match editing { + Some((text, kind)) => body.child( + v_flex() + .gap_1() + .child(Textarea::new(&text).h(px(54.))) + .child({ + let panel = cx.entity().downgrade(); + diagnostics::note_card::kind_chips( + kind, + move |kind, _, cx| { + panel + .update(cx, |this, cx| { + if let Some(edit) = + this.note_edit.as_mut() + { + edit.2 = kind; + } + cx.notify(); + }) + .ok(); + }, + cx, + ) + }) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(match cfg!(target_os = "macos") { + true => "⌘Enter to save, Esc to cancel", + false => { + "Ctrl+Enter to save, Esc to cancel" + } + }), + ), + ), + None => { + body.child(div().line_clamp(3).child(note.message.clone())) + } + }) + .child( + div() + .w_full() + .min_w_0() + .whitespace_normal() + .text_xs() + .line_height(px(16.)) + .text_color(theme.muted_foreground) + .child(format!("{lead} · {author}")) + .children(edited.map(|tip| { + div() + .id(ElementId::NamedInteger( + "note-edited".into(), + id.unwrap_or_default() as u64, + )) + .ml_1() + .underline() + .child("edited") + .tooltip(move |window, cx| { + gpui_component::tooltip::Tooltip::new( + tip.clone(), + ) + .build(window, cx) + }) + })), + ) + .when(changed, |body| { + body.child( + h_flex().child( + h_flex() + .gap_1() + .h(px(20.)) + .px_1p5() + .mt_0p5() + .rounded(px(4.)) + .bg(theme.warning.opacity(0.12)) + .text_xs() + .child( + Icon::new(IconName::TriangleAlert) + .xsmall() + .text_color(theme.warning), + ) + .child("Image changed since annotated"), + ), + ) + }), + ) + .when_some( + id.filter(|_| hovered && self.note_edit.is_none()), + |card, id| { + let kind = meta.as_ref().and_then(|n| n.kind); + let message = note.message.clone(); + card.child( + h_flex() + .absolute() + .top(px(5.)) + .right(px(5.)) + .gap(px(1.)) + .p(px(1.)) + .rounded(px(4.)) + .bg(theme.popover) + .border_1() + .border_color(theme.border) + .shadow_md() + .child( + Button::new(ElementId::NamedInteger( + "note-edit".into(), + id as u64, + )) + .icon(Icon::empty().path("icons/pencil.svg")) + .ghost() + .xsmall() + .tooltip("Edit") + .on_click(cx.listener(move |this, _, window, cx| { + this.edit_note(id, &message, window, cx); + })), + ) + .children((picked.len() == 1).then(|| { + Button::new(ElementId::NamedInteger( + "note-history".into(), + id as u64, + )) + .icon(Icon::empty().path("icons/history.svg")) + .ghost() + .xsmall() + .tooltip("Show this item's history") + .on_click( + cx.listener(|this, _, _, cx| { + this.history_open = true; + cx.notify(); + }), + ) + })) + .child( + Button::new(ElementId::NamedInteger( + "note-delete".into(), + id as u64, + )) + .icon(IconName::Delete) + .ghost() + .xsmall() + .tooltip("Delete") + .on_click(move |_, window, cx| { + let answer = window.prompt( + PromptLevel::Warning, + match region { + Some(_) => "Delete this annotation?", + None => "Delete this note?", + }, + Some("History keeps a copy you can restore."), + &["Delete", "Cancel"], + cx, + ); + cx.spawn(async move |cx| { + if answer.await != Ok(0) { + return; + } + cx.update(|cx| { + diagnostics::Diagnostics::edit_note( + id, + SharedString::default(), + kind, + Origin::Details, + cx, + ) + }); + }) + .detach(); + }), + ), + ) + }, + ) + .into_any_element(), + ); + index += 1; + } + } + if self.follow_pick.take() + && let Some(at) = chosen_at + { + self.notes_scroll.scroll_to_item(at); + } let open = self.notes_open; + let count_label = match (total, shown) { + (0, _) => "none".to_string(), + (total, shown) if shown == total => total.to_string(), + (total, shown) => format!("{shown} of {total}"), + }; + let text_width = |text: &str, bold: bool| { + let mut run = window.text_style().to_run(text.len()); + if bold { + run.font.weight = FontWeight::SEMIBOLD; + } + window + .text_system() + .shape_line( + text.to_string().into(), + window.rem_size() * 0.75, + &[run], + None, + ) + .width + }; + let filters_min_width = text_width("Notes", true) + + text_width(&count_label, false) + + ["All", "On image", "On fields", "Mine"] + .into_iter() + .map(|label| text_width(label, false)) + .sum::() + // Header padding, toggle, gaps, segment padding, and Mine's padding/border. + + window.rem_size() * (1. + 1.25 + 5. * 0.375 + 3. * 0.75 + 0.5) + + px(6.); + let compact = self.notes_header_width.get() < filters_min_width; + let segment = |label: &'static str, value: NotesFilter| { + div() + .id(label) + .px_1p5() + .py(px(2.)) + .rounded(px(3.)) + .whitespace_nowrap() + .cursor_pointer() + .map(|segment| match filter == value { + true => segment.bg(theme.background).text_color(theme.foreground), + false => segment.text_color(theme.muted_foreground), + }) + .child(label) + .on_click(cx.listener(move |this, _, _, cx| { + this.notes_filter = value; + cx.notify(); + })) + }; v_flex() .debug_selector(|| "details-notes-panel".into()) .size_full() .min_h_0() + .min_w_0() + .overflow_hidden() .border_t_1() - .border_color(cx.theme().border) + .border_color(theme.border) .child( h_flex() + .relative() .flex_none() + .w_full() + .min_w_0() .h(px(NOTES_HEADER_H)) .items_center() .gap_1p5() @@ -468,7 +1059,28 @@ impl DetailsPanel { .py_1() // Opaque: the notes scroll under this, and a transparent strip let them read // through the heading. - .bg(cx.theme().background) + .bg(theme.background) + .child({ + let width = self.notes_header_width.clone(); + let panel = cx.entity().downgrade(); + canvas( + move |bounds, window, _| { + let was_compact = width.get() < filters_min_width; + width.set(bounds.size.width); + if was_compact != (bounds.size.width < filters_min_width) { + let panel = panel.clone(); + window.on_next_frame(move |_, cx| { + panel.update(cx, |_, cx| cx.notify()).ok(); + }); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .top_0() + .left_0() + .size_full() + }) .child( Button::new("details-notes-toggle") .icon(match open { @@ -496,69 +1108,205 @@ impl DetailsPanel { cx.notify(); })), ) - .child(div().text_xs().font_semibold().child("Notes")) + .child(div().flex_none().text_xs().font_semibold().child("Notes")) .child( div() + .flex_none() .text_xs() - .text_color(cx.theme().muted_foreground) - .child(match total { - 0 => "none".to_string(), - n => n.to_string(), - }), - ), + .whitespace_nowrap() + .text_color(theme.muted_foreground) + .child(count_label), + ) + .child(div().flex_1().min_w_0()) + .when(open && total > 0 && compact, |header| { + let panel = cx.entity().downgrade(); + let label = match filter { + NotesFilter::All => "All notes", + NotesFilter::Image => "On image", + NotesFilter::Fields => "On fields", + }; + let tip = if mine { + format!("{label} · Only mine") + } else { + label.to_string() + }; + header.child( + Button::new("notes-filters") + .icon(IconName::Menu) + .ghost() + .xsmall() + .selected(filter != NotesFilter::All || mine) + .tooltip(format!("Filter notes: {tip}")) + .dropdown_menu_with_anchor( + Anchor::TopRight, + move |menu: PopupMenu, _, _| { + let menu = [ + ("All notes", NotesFilter::All), + ("On image", NotesFilter::Image), + ("On fields", NotesFilter::Fields), + ] + .into_iter() + .fold( + menu, + |menu, (label, value)| { + let panel = panel.clone(); + menu.item( + PopupMenuItem::new(label) + .checked(filter == value) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.notes_filter = value; + cx.notify(); + }) + .ok(); + }), + ) + }, + ); + let panel = panel.clone(); + menu.separator().item( + PopupMenuItem::new("Only mine") + .checked(mine) + .disabled(!can_filter_mine) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.notes_mine = !this.notes_mine; + cx.notify(); + }) + .ok(); + }), + ) + }, + ), + ) + }) + .when(open && total > 0 && !compact, |header| { + header + .child( + h_flex() + .flex_none() + .p(px(2.)) + .rounded(px(5.)) + .bg(theme.muted) + .text_xs() + .child(segment("All", NotesFilter::All)) + .child(segment("On image", NotesFilter::Image)) + .child(segment("On fields", NotesFilter::Fields)), + ) + .child( + Button::new("notes-mine") + .label("Mine") + .xsmall() + .outline() + .selected(mine) + .disabled(!can_filter_mine) + .tooltip(match !can_filter_mine { + true => "Set your name in Settings to filter your notes", + false => "Only notes you signed", + }) + .on_click(cx.listener(|this, _, _, cx| { + this.notes_mine = !this.notes_mine; + cx.notify(); + })), + ) + }), ) .when(open, |section| { section.child( - div() + v_flex() + .id("details-notes-list") + .relative() .flex_1() .min_h_0() - .overflow_y_scrollbar() - .px_3() + .overflow_y_scroll() + .track_scroll(&self.notes_scroll) + .vertical_scrollbar(&self.notes_scroll) + .px_2() .pb_2() - .child( - v_flex() - .gap_2() - .children(groups.into_iter().map(|(title, notes)| { - v_flex() - .gap_1() - // Only worth saying whose note this is when the selection - // holds more than one item to confuse it with. - .when(several, |group| { - group.child( - div().text_xs().truncate().child(title.clone()), - ) - }) - .children(notes.into_iter().map(|(text, meta)| { - v_flex() - .gap_0p5() - .p_1p5() - .rounded(cx.theme().radius) - .border_1() - .border_color(cx.theme().border) - .bg(cx.theme().muted.opacity(0.4)) - .child(div().text_xs().child(text)) - .children(meta.map(|meta| { - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(meta) - })) - })) - })) - .when(total == 0, |list| { - list.child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child("No notes on this selection."), - ) - }), - ), + .gap_1p5() + .on_action(cx.listener(|this, _: &Escape, window, cx| { + if this.note_edit.take().is_some() { + window.focus(&this.focus_handle, cx); + cx.notify(); + } else { + cx.propagate(); + } + })) + .children(entries) + .when(shown == 0, |list| { + list.child(div().text_xs().text_color(theme.muted_foreground).child( + match total { + 0 => "No notes on this selection.", + _ => "No notes match these filters.", + }, + )) + }), ) }) .into_any_element() } + /// Open note `id`'s words for rewording in its card; Ctrl+Enter saves, Escape drops it. + fn edit_note( + &mut self, + id: diagnostics::NoteId, + message: &SharedString, + window: &mut Window, + cx: &mut Context, + ) { + let text = cx.new(|cx| TextareaState::new(window, cx)); + text.update(cx, |text, cx| { + text.set_value(message.clone(), window, cx); + text.focus(window, cx); + }); + cx.subscribe_in( + &text, + window, + move |this, text, event: &InputEvent, window, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = event + { + let message = text.read(cx).value(); + let Some((_, _, kind)) = this.note_edit.take() else { + return; + }; + if !message.trim().is_empty() { + diagnostics::Diagnostics::edit_note( + id, + message.trim().to_string().into(), + kind, + Origin::Details, + cx, + ); + } + window.focus(&this.focus_handle, cx); + cx.notify(); + } + }, + ) + .detach(); + let kind = diagnostics::Diagnostics::note(id, cx).and_then(|note| note.note.as_ref()?.kind); + self.note_edit = Some((id, text, kind)); + cx.notify(); + } + + /// The pixel size of `path` as it is on disk now, read once per version of the file. + fn header(&self, path: &Path) -> Option<(u32, u32)> { + let version = preview::cache::key(path, 0, 0); + let mut headers = self.headers.borrow_mut(); + match headers.get(path) { + Some((seen, size)) if *seen == version => *size, + _ => { + let size = preview::dimensions(path); + headers.insert(path.to_path_buf(), (version, size)); + size + } + } + } + /// The History sub-panel: what has happened to the front item, newest first — each field's old /// value beside the new, with a way to put a saved one back. Unsaved changes lead, without that /// button: there is no saved entry yet for a restore to name. @@ -573,6 +1321,13 @@ impl DetailsPanel { let unsaved: &[settings::history::Entry] = state .as_ref() .map_or(&[], |state| state.read(cx).delegate().unsaved_history()); + let numbers = crate::viewer::marks(row_id, cx); + let file = state.as_ref().and_then(|state| { + let delegate = state.read(cx).delegate(); + delegate + .row_image(delegate.row_of(row_id)?) + .map(Path::to_path_buf) + }); let theme = cx.theme(); let (muted, border, background, radius) = ( theme.muted_foreground, @@ -581,12 +1336,14 @@ impl DetailsPanel { theme.radius, ); - // One line per change to this item: `(entry id if saved, what, meta, value to restore)`. + // One line per change to this item: `(entry id if saved, what, meta, value to restore, + // the crops of a region it is about)`. type Line = ( Option, String, String, - Option<(String, SharedString)>, + Option, + Option, ); let quote = |text: &str| match text.is_empty() { true => "(empty)".to_string(), @@ -612,24 +1369,40 @@ impl DetailsPanel { after, } if *row == row_id => ( format!("{column}: {} → {}", quote(before), quote(after)), - Some((column.clone(), SharedString::from(before.clone()))), + Some(change.clone()), ), Change::Note { row: Some(row), column, before, after, - } if *row == row_id => { - let verb = match (before, after) { - (None, _) => "added", - (_, None) => "removed", - _ => "edited", - }; - let on = column - .as_deref() - .map_or(String::new(), |c| format!(" on {c}")); - (format!("Note{on} {verb}"), None) - } + id: note, + .. + } if *row == row_id => match super::history::annotation(change) { + Some(what) => { + let number = numbers + .iter() + .find(|m| Some(m.id) == *note) + .map_or(String::new(), |m| format!(" #{}", m.number)); + ( + format!("{what}{number}"), + before.is_some().then(|| change.clone()), + ) + } + None => { + let verb = match (before, after) { + (None, _) => "added", + (_, None) => "removed", + _ => "edited", + }; + let on = column + .as_deref() + .map_or(String::new(), |c| format!(" on {c}")); + let restore = + (note.is_some() && before.is_some()).then(|| change.clone()); + (format!("Note{on} {verb}"), restore) + } + }, Change::RowAdded { row, .. } if *row == row_id => { ("Item added".into(), None) } @@ -639,7 +1412,40 @@ impl DetailsPanel { _ => return None, }; let restore = restore.filter(|_| id.is_some() && !dimmed); - Some((id, what, meta.clone(), restore)) + let body = + super::history::note_crops(change, file.as_deref(), cx).map(|crops| { + let Change::Note { before, after, .. } = change else { + return crops; + }; + v_flex() + .w_full() + .min_w_0() + .gap_1() + .child(crops) + .child(v_flex().w_full().min_w_0().gap_0p5().text_xs().map( + |text| { + match (before, after) { + (Some(before), Some(after)) if before == after => text + .child( + div() + .line_clamp(2) + .text_color(muted) + .child(after.clone()), + ), + _ => text + .children(before.clone().map(|before| { + div() + .line_through() + .text_color(muted) + .child(before) + })) + .children(after.clone()), + } + }, + )) + .into_any_element() + }); + Some((id, what, meta.clone(), restore, body)) }) .collect::>() }; @@ -692,6 +1498,8 @@ impl DetailsPanel { .debug_selector(|| "details-history-panel".into()) .size_full() .min_h_0() + .min_w_0() + .overflow_hidden() .border_t_1() .border_color(border) .child( @@ -740,59 +1548,102 @@ impl DetailsPanel { .when(open, |section| { section.child( div() + .id("details-history-list") + .relative() .flex_1() .min_h_0() - .overflow_y_scrollbar() + .min_w_0() + .overflow_y_scroll() + .track_scroll(&self.history_scroll) + .vertical_scrollbar(&self.history_scroll) .px_3() .pb_2() .child( v_flex() .gap_1() .children(lines.into_iter().enumerate().map( - |(ix, (id, what, meta, restore))| { - h_flex() + |(ix, (id, what, meta, restore, crops))| { + v_flex() + .w_full() + .min_w_0() + .flex_none() .gap_1() - .items_start() .p_1p5() .rounded(radius) .border_1() .border_color(border) .child( - v_flex() - .flex_1() + h_flex() .min_w_0() - .gap_0p5() - .child(div().text_xs().child(what)) + .items_start() + .gap_1() .child( div() + .flex_1() + .min_w_0() .text_xs() - .text_color(muted) - .child(meta), + .child(what), + ) + .when_some( + id.zip(restore), + |line, (id, change)| { + line.child( + Button::new(( + "details-history-restore", + ix, + )) + .flex_none() + .icon(IconName::Undo2) + .ghost() + .xsmall() + .tooltip( + match ( + &change, + super::history::annotation( + &change, + ), + ) { + (_, Some(_)) => { + "Restore this annotation" + } + (Change::Note { .. }, _) => { + "Restore this note" + } + _ => "Restore this value", + }, + ) + .on_click(move |_, _, cx| { + match &change { + Change::Cell { + column, + before, + .. + } => table::restore_value( + row_id, + column, + before.clone().into(), + id, + cx, + ), + note => { + table::restore_note( + note, id, cx, + ) + } + } + }), + ) + }, ), ) - .when_some( - id.zip(restore), - |line, (id, (column, before))| { - line.child( - Button::new(( - "details-history-restore", - ix, - )) - .icon(IconName::Undo2) - .ghost() - .xsmall() - .tooltip("Restore this value") - .on_click(move |_, _, cx| { - table::restore_value( - row_id, - &column, - before.clone(), - id, - cx, - ) - }), - ) - }, + .children(crops) + .child( + div() + .w_full() + .min_w_0() + .text_xs() + .text_color(muted) + .child(meta), ) }, )) @@ -988,6 +1839,7 @@ impl Panel for DetailsPanel { /// stack during type-checking instead of just hitting a slow compile. fn render_image_frame( image_path: Option, + row: Option, caption: Option, transport: Option, cx: &App, @@ -1049,6 +1901,7 @@ fn render_image_frame( .on_click(move |_, window, cx| { crate::open_viewer( path.clone(), + row, crate::ViewerScope::Workspace, window, cx, @@ -1229,8 +2082,10 @@ impl Render for DetailsPanel { let picked = self.picked(cx); let front = stack_front(&picked, self.stack); let count = picked.len(); + let mut front_id = None; let selection = self.state.as_ref().and_then(|w| w.upgrade()).map(|s| { let delegate = s.read(cx).delegate(); + front_id = front.and_then(|row| delegate.row_id(row)); let image = front.and_then(|row| delegate.row_image(row).map(Path::to_path_buf)); let lost = front.filter(|_| count == 1).and_then(|row| { table::file_links::missing_file(delegate, row, cx).map(|(_, name)| (row, name)) @@ -1317,7 +2172,7 @@ impl Render for DetailsPanel { // Built before the field rows below, which borrow `cx` for as long as they stay a lazy // iterator — this needs `&mut cx` and cannot wait for them. - let notes = (count > 0).then(|| self.notes_panel(&picked, cx)); + let notes = (count > 0).then(|| self.notes_panel(&picked, window, cx)); let history = (count == 1).then(|| self.history_panel(cx)); let notes_floor = section_footprint(notes.is_some(), self.notes_open); let history_floor = section_footprint(history.is_some(), self.history_open); @@ -1681,6 +2536,7 @@ impl Render for DetailsPanel { .map(|pane| match count > 1 { false => pane.child(render_image_frame( image_path, + front_id, self.caption.clone(), transport, cx, @@ -1727,6 +2583,7 @@ impl Render for DetailsPanel { .bottom(px(10.)) .child(render_image_frame( image_path, + front_id, self.caption.clone(), transport, cx, @@ -1828,7 +2685,7 @@ mod tests { impl Render for ImageFrameProbe { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let caption = self.0.as_deref().and_then(preview::describe); - render_image_frame(self.0.clone(), caption, None, cx) + render_image_frame(self.0.clone(), None, caption, None, cx) } } diff --git a/crates/workspace/src/panels/history.rs b/crates/workspace/src/panels/history.rs index 45271c3b..b22bac4c 100644 --- a/crates/workspace/src/panels/history.rs +++ b/crates/workspace/src/panels/history.rs @@ -1,6 +1,6 @@ use std::borrow::Cow; use std::collections::{HashMap, HashSet}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::rc::Rc; use std::time::{SystemTime, UNIX_EPOCH}; @@ -10,9 +10,9 @@ use gpui_component::dock::{BasePanel, DockPlacement, Panel, PanelEvent}; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, PopupMenuItem}; use gpui_component::{ - ActiveTheme as _, IconName, Sizable as _, VirtualListScrollHandle, + ActiveTheme as _, Icon, IconName, Sizable as _, button::{Button, ButtonVariants as _}, - h_flex, v_flex, v_virtual_list, + h_flex, v_flex, }; use settings::history::{Change, Entry, EntryId, Listed, Origin, ShowCellHistory}; use settings::project::{CurrentProject, RowId}; @@ -36,8 +36,8 @@ const PAGE: i64 = 200; /// one burst of work and collapse under one row. const BURST_SECS: i64 = 5 * 60; -/// One line of the list, as the virtual list addresses it. Holds indices rather than text: the -/// whole list is walked every render to place it, but only the rows on screen are ever formatted. +/// One dynamically measured list row; its text is formatted only when it is rendered. +#[derive(PartialEq)] enum Row { Header(SharedString), /// Into [`HistoryPanel::pending`]. @@ -55,18 +55,6 @@ enum Row { More, } -/// The heights the list places rows by, which are also the heights the rows are given. Measuring -/// instead would mean building every row to find out how tall it is, which is the whole cost -/// virtualizing is here to avoid — so a row is told its height rather than asked for it. Change -/// one of these and change the row it belongs to. -const HEADER_H: f32 = 28.; -const ROW_H: f32 = 48.; -/// The extra line a named version carries above its description. -const LABEL_H: f32 = 18.; -/// The box that appears while that name is being typed. -const NAMING_H: f32 = 28.; -const MORE_H: f32 = 40.; - #[derive(Clone, Copy, PartialEq, Eq)] enum Filter { All, @@ -116,7 +104,7 @@ pub struct HistoryPanel { /// Whether this is the panel its dock is showing, which is what [`Self::visible`] /// reports: a dock with one visible panel draws a title bar instead of a tab strip. active: bool, - list_scroll: VirtualListScrollHandle, + list_scroll: ListState, saved: Vec, /// Whether a "Load older" would find anything. more: bool, @@ -131,12 +119,8 @@ pub struct HistoryPanel { naming: Option<(EntryId, Entity, Subscription)>, /// One cell to show the changes of, as its row and every name its column has had. cell: Option<(RowId, Vec)>, - /// What the list is showing, rebuilt by [`Self::rebuild`] whenever `stale`: the flattened rows, - /// the saved entries they index into once filtered and narrowed, the entries not on disk yet - /// with the meta line each needs, and each grid row's position. On `self` because the virtual - /// list draws a range of them later, from a callback that is handed the panel and nothing else. + /// Flattened rows, rebuilt when the data, filters, expansion, or naming change. list: Rc>, - sizes: Rc>>, view: Rc>, pending: Rc>, positions: Rc>, @@ -161,7 +145,7 @@ impl HistoryPanel { let mut this = Self { focus_handle: cx.focus_handle(), active: false, - list_scroll: VirtualListScrollHandle::new(), + list_scroll: ListState::new(0, ListAlignment::Top, px(200.)), saved: Vec::new(), more: false, read_at: None, @@ -171,7 +155,6 @@ impl HistoryPanel { naming: None, cell: None, list: Rc::default(), - sizes: Rc::default(), view: Rc::default(), pending: Rc::default(), positions: Rc::default(), @@ -484,7 +467,7 @@ impl HistoryPanel { let answer = window.prompt( PromptLevel::Critical, "Clear this project's history?", - Some("Every recorded change is forgotten and nothing before now can be restored. The data itself is not changed."), + Some("Current edits are saved first. Every recorded change is forgotten and nothing before now can be restored. The data itself is not changed."), &["Clear History", "Cancel"], cx, ); @@ -493,10 +476,9 @@ impl HistoryPanel { return; } cx.update(|cx| { - if let Some(file) = cx.try_global::().map(|p| p.file.clone()) - && let Err(err) = settings::history::clear(&file) - { + if let Err(err) = table::clear_history(cx) { log::error!("couldn't clear the project history: {err}"); + return; } panel.update(cx, |this, cx| this.reload(true, cx)).ok(); }); @@ -533,6 +515,94 @@ fn narrowed<'a>(entry: &'a Entry, cell: Option<&(RowId, Vec)>) -> Option }) } +/// What a change to a region note did, or `None` for any other change. +pub(crate) fn annotation(change: &Change) -> Option<&'static str> { + let Change::Note { + before, + after, + region_before, + region_after, + .. + } = change + else { + return None; + }; + let resized = region_before + .zip(*region_after) + .is_some_and(|(a, b)| (a.w, a.h) != (b.w, b.h)); + Some(match (before, after) { + _ if region_before.is_none() && region_after.is_none() => return None, + (None, _) => "Annotation added", + (_, None) => "Annotation deleted", + _ if before == after && resized => "Annotation resized", + _ if before == after => "Annotation moved", + _ => "Annotation edited", + }) +} + +/// The region a note change is about, cut from `path`: both sides of a move or resize, else the +/// one it had. `None` for a change that is not about a region. +pub(crate) fn note_crops(change: &Change, path: Option<&Path>, cx: &App) -> Option { + let Change::Note { + region_before, + region_after, + .. + } = change + else { + return None; + }; + let moved = region_before.zip(*region_after).filter(|(a, b)| a != b); + let sides: Vec<_> = match moved { + Some((before, after)) => vec![before, after], + None => region_after.or(*region_before).into_iter().collect(), + }; + let first = sides.first()?; + let pages = path.and_then(preview::known_pages).unwrap_or(1); + let (muted, radius) = (cx.theme().muted, px(4.)); + let crop = |region: &diagnostics::Region| { + div() + .size(px(40.)) + .flex_none() + .rounded(radius) + .overflow_hidden() + .bg(muted) + .children(path.map(|path| { + img(preview::crop( + path, + region.page as usize, + [region.x, region.y, region.w, region.h], + )) + .size_full() + .object_fit(ObjectFit::Cover) + })) + }; + Some( + h_flex() + .flex_none() + .gap_1() + .child(crop(first)) + .children(sides.get(1).map(|after| { + h_flex() + .gap_1() + .child( + Icon::new(IconName::ArrowRight) + .xsmall() + .text_color(cx.theme().muted_foreground), + ) + .child(crop(after)) + })) + .when(pages > 1, |crops| { + crops.child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("p. {}", first.page + 1)), + ) + }) + .into_any_element(), + ) +} + /// `3 Sep 2026`, from a `YYYY-MM-DD` day. fn date_label(day: &str) -> String { const MONTHS: [&str; 12] = [ @@ -608,13 +678,14 @@ fn describe(entry: &Entry, rows: &HashMap) -> String { column, before, after, + .. }, ] => { - let what = match (before, after) { + let what = annotation(&changes[0]).unwrap_or(match (before, after) { (None, _) => "Added a note", (_, None) => "Removed a note", _ => "Edited a note", - }; + }); match (id, column) { (Some(id), Some(column)) => format!("{what} on {column}, {}", row(id)), (Some(id), None) => format!("{what} on {}", row(id)), @@ -753,37 +824,50 @@ impl Panel for HistoryPanel { cx: &mut Context, ) -> PopupMenu { let panel = cx.entity().downgrade(); - menu.item( - PopupMenuItem::new("Clear History…") - .on_click(move |_, window, cx| Self::confirm_clear(panel.clone(), window, cx)), - ) + let (filter, named_only) = (self.filter, self.named_only); + let filters = PopupMenu::build(_w, cx, |menu, _, _| { + let menu = Filter::ALL.into_iter().fold(menu, |menu, pick| { + let panel = panel.clone(); + menu.item( + PopupMenuItem::new(pick.label()) + .checked(!named_only && pick == filter) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.filter = pick; + this.named_only = false; + this.stale = true; + cx.notify(); + }) + .ok(); + }), + ) + }); + let panel = panel.clone(); + menu.separator().item( + PopupMenuItem::new("Named versions") + .checked(named_only) + .on_click(move |_, _, cx| { + panel + .update(cx, |this, cx| { + this.named_only = !this.named_only; + this.stale = true; + cx.notify(); + }) + .ok(); + }), + ) + }); + menu.item(PopupMenuItem::submenu("Show", filters)) + .separator() + .item( + PopupMenuItem::new("Clear History…") + .on_click(move |_, window, cx| Self::confirm_clear(panel.clone(), window, cx)), + ) } } impl HistoryPanel { - /// How tall the list places a row, which is also the height the row is given — see the - /// constants above. - fn row_size(&self, row: &Row) -> Size { - let height = match row { - Row::Header(_) => HEADER_H, - Row::More => MORE_H, - Row::Pending(_) | Row::Burst { .. } => ROW_H, - Row::Saved { ix, .. } => match self.view.get(*ix) { - None => ROW_H, - Some(listed) => { - let naming = self - .naming - .as_ref() - .is_some_and(|(id, _, _)| *id == listed.entry.id); - ROW_H - + if listed.label.is_some() { LABEL_H } else { 0. } - + if naming { NAMING_H } else { 0. } - } - }, - }; - size(px(0.), px(height)) - } - /// One row of the list, built only when it is on screen. `AnyElement` because the arms are /// different elements, which is the case the crate's style note allows it for. fn draw_row( @@ -792,6 +876,31 @@ impl HistoryPanel { positions: &HashMap, cx: &mut Context, ) -> AnyElement { + let crops = match row { + Row::Saved { ix, .. } => self.view.get(*ix).map(|listed| &listed.entry), + Row::Pending(ix) => self.pending.get(*ix).map(|(entry, _)| entry), + _ => None, + } + .and_then(|entry| { + let [change] = entry.changes.as_slice() else { + return None; + }; + let file = match change { + Change::Note { row: Some(id), .. } => cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + .and_then(|table| { + let row = *positions.get(id)?; + table + .read(cx) + .delegate() + .row_image(row) + .map(Path::to_path_buf) + }), + _ => None, + }; + note_crops(change, file.as_deref(), cx) + }); let theme = cx.theme(); let (muted, border, hover_bg, accent) = ( theme.muted_foreground, @@ -800,14 +909,12 @@ impl HistoryPanel { theme.accent_foreground, ); let panel = cx.entity().downgrade(); - let height = self.row_size(row).height; - // ponytail: always offered, since gpui cannot tell us whether the text was cut - let clipped = |text: String| { + let description = |text: String| { div() .id("text") - .overflow_hidden() - .text_ellipsis() + .min_w_0() + .whitespace_normal() .child(text.clone()) .tooltip(move |window, cx| { gpui_component::tooltip::Tooltip::new(text.clone()).build(window, cx) @@ -823,7 +930,7 @@ impl HistoryPanel { match row { Row::Header(text) => div() - .h(height) + .w_full() .px_2() .pt_2() .pb_1() @@ -834,7 +941,7 @@ impl HistoryPanel { .into_any_element(), Row::More => div() - .h(height) + .w_full() .p_2() .child( Button::new("history-older") @@ -854,8 +961,8 @@ impl HistoryPanel { "history-unsaved".into(), *ix as u64, )) - .h(height) .w_full() + .min_w_0() .gap_0p5() .px_2() .py_1() @@ -865,7 +972,14 @@ impl HistoryPanel { .when(matches!(entry.origin, Origin::Undo | Origin::Redo), |row| { row.opacity(0.6) }) - .child(clipped(describe(entry, positions)).text_sm()) + .child( + h_flex() + .min_w_0() + .items_start() + .gap_2() + .children(crops) + .child(description(describe(entry, positions)).flex_1().text_sm()), + ) .child( div() .text_xs() @@ -901,6 +1015,16 @@ impl HistoryPanel { ] => Some((*row, column.clone(), before.clone())), _ => None, }; + let note = match listed.entry.changes.as_slice() { + [ + change @ Change::Note { + id: Some(_), + before: Some(_), + .. + }, + ] => Some(change.clone()), + _ => None, + }; let (restore, name, unname) = (listed.clone(), panel.clone(), panel.clone()); let labelled = listed.label.is_some(); @@ -920,6 +1044,15 @@ impl HistoryPanel { }, )) }) + .when_some(note, |menu, change| { + let label = match annotation(&change) { + Some(_) => "Restore This Annotation", + None => "Restore This Note", + }; + menu.item(PopupMenuItem::new(label).on_click(move |_, _, cx| { + table::restore_note(&change, id, cx); + })) + }) .separator() .item(PopupMenuItem::new("Name This Version…").on_click( move |_, window, cx| { @@ -938,8 +1071,8 @@ impl HistoryPanel { }; v_flex() .id(ElementId::NamedInteger("history-entry".into(), id as u64)) - .h(height) .w_full() + .min_w_0() .gap_0p5() .px_2() .py_1() @@ -960,7 +1093,14 @@ impl HistoryPanel { ) }) .when_some(naming, |row, input| row.child(Input::new(&input).xsmall())) - .child(clipped(describe(entry, positions)).text_sm()) + .child( + h_flex() + .min_w_0() + .items_start() + .gap_2() + .children(crops) + .child(description(describe(entry, positions)).flex_1().text_sm()), + ) .child( div() .text_xs() @@ -987,8 +1127,8 @@ impl HistoryPanel { }; h_flex() .id(ElementId::NamedInteger("history-burst".into(), id as u64)) - .h(height) .w_full() + .min_w_0() .gap_1() .px_2() .py_1() @@ -1017,11 +1157,15 @@ impl HistoryPanel { ) .child( v_flex() + .flex_1() .min_w_0() .gap_0p5() .child( - clipped(format!("{len} edits · {}", describe(&summary, positions))) - .text_sm(), + description(format!( + "{len} edits · {}", + describe(&summary, positions) + )) + .text_sm(), ) .child(div().text_xs().text_color(muted).child(meta( &head.entry, @@ -1144,7 +1288,23 @@ impl HistoryPanel { self.pending = Rc::new(pending); self.view = Rc::new(view); self.positions = Rc::new(positions); - self.sizes = Rc::new(list.iter().map(|row| self.row_size(row)).collect()); + let prefix = self + .list + .iter() + .zip(&list) + .take_while(|(a, b)| a == b) + .count(); + let suffix = self.list[prefix..] + .iter() + .rev() + .zip(list[prefix..].iter().rev()) + .take_while(|(a, b)| a == b) + .count(); + self.list_scroll.splice( + prefix..self.list.len() - suffix, + list.len() - prefix - suffix, + ); + self.list_scroll.remeasure(); self.list = Rc::new(list); } } @@ -1166,10 +1326,12 @@ impl Render for HistoryPanel { format!("{}, {at}", names[0]) }); let empty = self.pending.is_empty() && self.view.is_empty(); - let sizes = self.sizes.clone(); v_flex() .size_full() + .min_h_0() + .min_w_0() + .relative() .track_focus(&self.focus_handle) .id("history-panel") .role(Role::Group) @@ -1229,21 +1391,21 @@ impl Render for HistoryPanel { .when(!empty, |panel| { panel .child( - v_virtual_list( - cx.entity(), - "history-list", - sizes, - move |this, range, _window, cx| { - let (list, positions) = (this.list.clone(), this.positions.clone()); - list[range] - .iter() - .map(|row| this.draw_row(row, &positions, cx)) - .collect::>() - }, - ) + list(self.list_scroll.clone(), { + let panel = cx.entity().downgrade(); + move |ix, _, cx| { + panel + .update(cx, |this, cx| { + this.draw_row(&this.list[ix], &this.positions, cx) + }) + .unwrap_or_else(|_| div().into_any_element()) + } + }) + .flex_1() + .min_h_0() + .w_full() .pr_2() - .pb(px(8.) + crop) - .track_scroll(&self.list_scroll), + .pb(px(8.) + crop), ) .child(gpui_component::scroll::Scrollbar::vertical( &self.list_scroll, @@ -1255,7 +1417,7 @@ impl Render for HistoryPanel { #[cfg(test)] mod tests { // Never `use super::*` here: the parent has `use gpui::*` in scope. - use super::{HistoryPanel, PAGE, day_label, describe, narrowed}; + use super::{HistoryPanel, PAGE, annotation, day_label, describe, narrowed}; use gpui::TestAppContext; use settings::history::{Change, Entry, Origin}; use std::collections::HashMap; @@ -1413,4 +1575,51 @@ mod tests { ); assert_eq!(describe(&many, &rows), "2 cells in Title"); } + + /// A change to a region note is named for what happened to the annotation; a move keeps the + /// size, a resize changes it, and a note with no region is not an annotation at all. + #[test] + fn annotation_changes_say_what_happened_to_the_region() { + let region = |x, w| settings::project::Region { + page: 0, + x, + y: 0, + w, + h: 100, + of: None, + }; + let note = |before: Option<&str>, after: Option<&str>, a, b| Change::Note { + row: Some(1), + column: None, + before: before.map(Into::into), + after: after.map(Into::into), + id: Some(1), + region_before: a, + region_after: b, + kind_before: None, + kind_after: None, + }; + let r = Some(region(0, 100)); + assert_eq!( + annotation(¬e(None, Some("a"), None, r)), + Some("Annotation added") + ); + assert_eq!( + annotation(¬e(Some("a"), None, r, None)), + Some("Annotation deleted") + ); + assert_eq!( + annotation(¬e(Some("a"), Some("a"), r, Some(region(50, 100)))), + Some("Annotation moved") + ); + assert_eq!( + annotation(¬e(Some("a"), Some("a"), r, Some(region(0, 200)))), + Some("Annotation resized") + ); + assert_eq!( + annotation(¬e(Some("a"), Some("b"), r, r)), + Some("Annotation edited") + ); + assert_eq!(annotation(¬e(None, Some("a"), None, None)), None); + } } diff --git a/crates/workspace/src/pop_out.rs b/crates/workspace/src/pop_out.rs index 9ab551b8..73e53eb2 100644 --- a/crates/workspace/src/pop_out.rs +++ b/crates/workspace/src/pop_out.rs @@ -299,15 +299,25 @@ impl PopOut { self.details .update(cx, |details, cx| details.show_rows(rows, cx)); - let file = self + let (file, row) = self .front() .zip(self.table()) - .and_then(|(row, state)| viewer::previewable(state.read(cx).delegate(), row)); - if self.viewer.as_ref().map(|viewer| &viewer.read(cx).path) != file.as_ref() { + .map(|(row, state)| { + let delegate = state.read(cx).delegate(); + (viewer::shown_file(delegate, row, cx), delegate.row_id(row)) + }) + .unwrap_or_default(); + let showing = self.viewer.as_ref().map(|viewer| { + let viewer = viewer.read(cx); + (viewer.path.clone(), viewer.row) + }); + if showing.as_ref().map(|(path, _)| path) != file.as_ref() + || showing.as_ref().is_some_and(|(_, shown)| *shown != row) + { if let Some(leaving) = &self.viewer { preview::playback::stop(leaving.entity_id(), cx); } - self.viewer = file.map(|file| viewer::build(file, Scope::PopOut, window, cx)); + self.viewer = file.map(|file| viewer::build(file, row, Scope::PopOut, window, cx)); self._viewer_sub = self.viewer.as_ref().map(|viewer| { // The viewer repaints itself on every pan and zoom; this window only draws // its controls, tabs and find results. @@ -1244,11 +1254,21 @@ mod tests { let (_, cx) = cx.add_window_view(|_, _| Blank); cx.update(|window, cx| { - let popped = - crate::viewer::build(shown.clone(), crate::viewer::Scope::PopOut, window, cx); + let popped = crate::viewer::build( + shown.clone(), + None, + crate::viewer::Scope::PopOut, + window, + cx, + ); // The same recording, open in the main window too. - let main = - crate::viewer::build(shown.clone(), crate::viewer::Scope::Workspace, window, cx); + let main = crate::viewer::build( + shown.clone(), + None, + crate::viewer::Scope::Workspace, + window, + cx, + ); preview::playback::play(&shown, main.entity_id(), cx); let before = preview::playback::playing(cx).map(|path| path.to_path_buf()); preview::playback::stop(popped.entity_id(), cx); @@ -1260,7 +1280,13 @@ mod tests { preview::playback::play(&shown, popped.entity_id(), cx); let before = preview::playback::playing(cx).map(|path| path.to_path_buf()); - crate::viewer::open_viewer(shown.clone(), crate::viewer::Scope::Workspace, window, cx); + crate::viewer::open_viewer( + shown.clone(), + None, + crate::viewer::Scope::Workspace, + window, + cx, + ); crate::viewer::close_viewer(window, cx); assert_eq!( preview::playback::playing(cx).map(|path| path.to_path_buf()), diff --git a/crates/workspace/src/skin.rs b/crates/workspace/src/skin.rs index 7c40d2cb..30664d72 100644 --- a/crates/workspace/src/skin.rs +++ b/crates/workspace/src/skin.rs @@ -1,5 +1,4 @@ -//! qrate's dock appearance: the library's `DockSkin`, with one change — a single panel's title -//! row is a strip of its own, tinted like the title bar and ruled off from the panel under it. +//! qrate's dock appearance: a ruled title strip and shared overflow behavior for every header. //! //! The library draws a lone panel's title straight on the panel's own background with nothing //! between the two, so the name, the view switcher and the panel's first row of content run @@ -13,13 +12,15 @@ use std::rc::Rc; use std::sync::Arc; +use gpui::prelude::FluentBuilder as _; use gpui::*; use gpui_base::ResizeHandleContext; use gpui_component::ActiveTheme as _; use gpui_component::dock::{ - DockArea, DockAreaRenderer, DockContext, DockSkin, DropIndicator, NodeId, PanelState, - PanelStyle, TabGroupContext, TabGroupRenderer, TilesRenderer, + DockArea, DockAreaRenderer, DockContext, DockSkin, DropIndicator, NodeId, PanelHandle, + PanelState, PanelStyle, TabGroupContext, TabGroupRenderer, TilesRenderer, }; +use gpui_component::menu::PopupMenuItem; pub(crate) struct QrateSkin { inner: Rc, @@ -145,17 +146,127 @@ impl TabGroupRenderer for QrateTabGroup { window: &mut Window, cx: &mut App, ) -> AnyElement { - let plain = self.draws_plain_title(group, cx); - let bar = self.inner.render_tab_bar(group, window, cx); - if !plain { - return bar; + if !group.panels().iter().any(|panel| panel.visible(cx)) { + return self.inner.render_tab_bar(group, window, cx); } + let plain = self.draws_plain_title(group, cx); + let inner = self.inner.clone(); + let expanded_group = group.clone(); + let title_group = group.clone(); + let menu_group = group.clone(); + let common_menu: window_wrapper::responsive_header::HeaderMenu = + Rc::new(move |menu, window, cx, _focus| { + let Some(panel) = menu_group.active_panel() else { + return menu; + }; + let handle = PanelHandle::of(panel); + + let menu = menu.when( + menu_group + .panels() + .iter() + .filter(|panel| panel.visible(cx)) + .count() + > 1, + |menu| { + let tabs_group = menu_group.clone(); + menu.submenu("Panels", window, cx, move |menu, _, cx| { + tabs_group + .panels() + .iter() + .enumerate() + .filter(|(_, panel)| panel.visible(cx)) + .fold(menu, |menu, (ix, panel)| { + let label = crate::panel_registry::PANELS + .iter() + .find(|meta| meta.name == panel.panel_name(cx)) + .map(|meta| SharedString::from(meta.label)) + .or_else(|| { + PanelHandle::of(panel) + .and_then(|handle| handle.tab_name(cx)) + }) + .unwrap_or_else(|| panel.panel_name(cx).into()); + let group = tabs_group.clone(); + menu.item( + PopupMenuItem::new(label) + .checked(ix == group.active_ix()) + .on_click(move |_, window, cx| { + group.select_tab(ix, window, cx) + }), + ) + }) + }) + }, + ); + let zoomed = menu_group.is_zoomed(); + let zoom_allowed = zoomed + || (panel.zoomable(cx) + && handle.and_then(|handle| handle.zoom_control(cx)).is_some()); + let group = menu_group.clone(); + let menu = menu.separator().item( + PopupMenuItem::new(if zoomed { "Zoom out" } else { "Zoom in" }) + .disabled(!zoom_allowed) + .on_click(move |_, window, cx| group.toggle_zoom(window, cx)), + ); + if menu_group.is_closable() { + let group = menu_group.clone(); + let id = panel.panel_id(cx); + menu.separator().item( + PopupMenuItem::new("Close panel") + .on_click(move |_, window, cx| group.close(id, window, cx)), + ) + } else { + menu + } + }); + let full_menu = common_menu.clone(); + let full_menu_group = group.clone(); + let bar = window_wrapper::responsive_header::ResponsiveHeader::new( + "dock-responsive-header", + px(30.), + move |window, cx| inner.render_tab_bar(&expanded_group, window, cx), + move |window, cx| { + let title = title_group + .active_panel() + .map(|panel| match PanelHandle::of(panel) { + Some(handle) => { + let label = crate::panel_registry::PANELS + .iter() + .find(|meta| meta.name == panel.panel_name(cx)) + .map(|meta| SharedString::from(meta.label)) + .or_else(|| handle.tab_name(cx)); + match label { + Some(label) => label.into_any_element(), + None => handle.title(window, cx), + } + } + None => SharedString::from(panel.panel_name(cx)).into_any_element(), + }); + div() + .min_w_0() + .truncate() + .children(title) + .into_any_element() + }, + move |menu, window, cx, focus| { + let menu = match full_menu_group.active_panel().and_then(PanelHandle::of) { + Some(handle) => handle.dropdown_menu(menu, window, cx), + None => menu, + }; + full_menu(menu, window, cx, focus) + }, + ); + let bar = match group.active_panel() { + Some(panel) => bar.panel_stages(panel.panel_name(cx), panel.view(), common_menu, cx), + None => bar, + }; div() .flex_none() .w_full() .bg(cx.theme().tab_bar) - .border_b_1() - .border_color(cx.theme().border) + .when(plain, |bar| { + bar.border_b_1().border_color(cx.theme().border) + }) .child(bar) .into_any_element() } diff --git a/crates/workspace/src/viewer/annotate.rs b/crates/workspace/src/viewer/annotate.rs new file mode 100644 index 00000000..11b52b37 --- /dev/null +++ b/crates/workspace/src/viewer/annotate.rs @@ -0,0 +1,1518 @@ +//! Region notes on the viewer: the row's regions drawn over its file, the card a hovered one opens, +//! and annotate mode, which marks new ones and writes their notes. Positions come from [`regions`], +//! measured against the content box the page is drawn in. + +use std::time::Duration; + +use diagnostics::{DATASET_MAIN, Diagnostics, Location, NoteId, NoteKind, Region}; +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_component::{ + ActiveTheme as _, Disableable as _, Icon, IconName, Selectable as _, Sizable as _, + button::{Button, ButtonVariants as _}, + h_flex, + input::{Input, InputEvent, InputState, Textarea, TextareaState}, + v_flex, +}; +use settings::history::Origin; +use settings::project::RowId; + +use crate::viewer::regions::{self, Grip, Rect}; +use crate::viewer::{Scope, Viewer}; + +/// Annotate mode, the tool in hand, and whether regions are hidden. App-wide, since stepping to the +/// next row builds a new viewer and should not put the pen down. +#[derive(Default)] +pub(crate) struct Annotating { + pub on: bool, + pub tool: Tool, + pub hidden: bool, +} + +impl Global for Annotating {} + +/// The note whose Notes card the pointer is over, lit in whichever viewer shows it. +#[derive(Default)] +pub(crate) struct Lit(pub Option); + +impl Global for Lit {} + +/// The region a Notes card was clicked for, which the viewer showing its row turns to and zooms on. +#[derive(Default)] +pub(crate) struct Reveal(pub Option); + +impl Global for Reveal {} + +/// Show region `id` in a viewer: the pop-out's when the click came from there, else the open +/// overlay if it shows the row, else a new one over the workspace. +pub(crate) fn reveal(id: NoteId, in_pop_out: bool, window: &mut Window, cx: &mut App) { + let Some(row_id) = Diagnostics::note(id, cx).and_then(|note| note.location.row_id) else { + return; + }; + let shown = crate::viewer::viewer_in(Scope::Workspace, cx) + .or_else(|| crate::viewer::viewer_in(Scope::Centre, cx)) + .is_some_and(|viewer| viewer.read(cx).row == Some(row_id)); + if !in_pop_out && !shown { + let file = cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + .and_then(|table| { + let delegate = table.read(cx).delegate(); + crate::viewer::shown_file(delegate, delegate.row_of(row_id)?, cx) + }); + let Some(file) = file else { + return; + }; + crate::viewer::open_viewer(file, Some(row_id), Scope::Workspace, window, cx); + } + if hidden(cx) { + cx.default_global::().hidden = false; + } + cx.set_global(Reveal(Some(id))); +} + +/// One of the row's region notes, numbered in the order they were filed. +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct Mark { + pub id: NoteId, + pub number: usize, + pub region: Region, +} + +pub(crate) fn marks(row: RowId, cx: &App) -> Vec { + let mut notes: Vec<(NoteId, Region)> = Diagnostics::regions_in_row(row, cx) + .filter_map(|d| { + let note = d.note.as_ref()?; + Some((note.id, note.region?)) + }) + .collect(); + notes.sort_by_key(|(id, _)| *id); + notes + .into_iter() + .enumerate() + .map(|(ix, (id, region))| Mark { + id, + number: ix + 1, + region, + }) + .collect() +} + +/// The tool annotate mode marks with. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(crate) enum Tool { + Select, + #[default] + Rect, + Pin, +} + +/// A new region being dragged out, corner to corner, in fractions of the page as shown. +pub(super) struct Draw { + pub(super) from: (f32, f32), + pub(super) to: (f32, f32), +} + +/// A selected region being moved or resized, by one of its [`Grip`]s. +pub(super) struct Grab { + id: NoteId, + grip: Grip, + rect: Rect, + from: (f32, f32), + to: (f32, f32), +} + +impl Grab { + fn rect(&self) -> Rect { + regions::drag( + self.rect, + self.grip, + (self.to.0 - self.from.0, self.to.1 - self.from.1), + ) + } +} + +/// The region selected in the viewer, which the Notes panel brings into view. +#[derive(Default)] +pub(crate) struct Picked(pub Option); + +impl Global for Picked {} + +/// The card a new region's note is written in, beside the region until it is saved or dropped. +pub(super) struct Composer { + region: Region, + text: Entity, + kind: Option, + /// The name field, shown the first time someone with no name set saves. + sign: Option>, +} + +/// The least of the page a zoom to a region takes in. +const PIN_EXTENT: f32 = 0.15; + +fn state(cx: &App) -> (bool, Tool, bool) { + cx.try_global::() + .map_or((false, Tool::default(), false), |a| { + (a.on, a.tool, a.hidden) + }) +} + +/// Whether the composer still has to ask who is signing. +fn unasked(cx: &App) -> bool { + settings::history::author(cx).is_none() + && !cx.try_global::().is_some_and(|s| { + s.values + .get(settings::NOTE_AUTHOR_ASKED_KEY) + .is_some_and(|v| v.bool()) + }) +} + +fn hidden(cx: &App) -> bool { + state(cx).2 +} + +fn area(b: &Bounds) -> f32 { + f32::from(b.size.width) * f32::from(b.size.height) +} + +impl Viewer { + pub(super) fn row_marks(&self, cx: &App) -> Vec { + self.row.map_or_else(Vec::new, |row| marks(row, cx)) + } + + /// Whether the page's shape is known, so regions land where they belong. + fn ready(&self) -> bool { + self.pixels.is_some() + } + + fn aspect(&self) -> f32 { + self.fit() + .map_or(0.75, |(image, _)| image.width / image.height) + } + + /// The page in content-box coordinates, which is where every region is drawn. + pub(super) fn page_box(&self) -> Bounds { + let area = Bounds { + origin: Point::default(), + size: self.frame.get().size, + }; + regions::page(area, self.zoom, self.offset, self.aspect()) + } + + pub(super) fn local(&self, position: Point) -> Point { + position - self.frame.get().origin + } + + /// This page's marks and where each sits on screen, larger first so smaller ones paint over. + fn placed(&self, marks: &[Mark]) -> Vec<(Mark, Rect, Bounds)> { + let page = self.page_box(); + let mut placed: Vec<_> = marks + .iter() + .filter(|m| m.region.page as usize == self.page) + .map(|m| { + let rect = match self.grab.as_ref().filter(|grab| grab.id == m.id) { + Some(grab) => grab.rect(), + None => regions::shown(&m.region, self.shown), + }; + (*m, rect, regions::on_screen(page, rect)) + }) + .collect(); + placed.sort_by(|a, b| area(&b.2).total_cmp(&area(&a.2))); + placed + } + + /// Track the region under the pointer, for its fill and its card. + pub(super) fn hover(&mut self, position: Point, cx: &mut Context) { + let hit = match self.ready() && !hidden(cx) { + true => { + let placed = self.placed(&self.row_marks(cx)); + let boxes: Vec<_> = placed.iter().map(|(m, _, b)| (m.id, *b)).collect(); + regions::hit(&boxes, self.local(position)) + } + false => None, + }; + if hit != self.hovered { + self.hovered = hit; + cx.notify(); + } + } + + /// Show `text` at the foot of the stage for a moment. + pub(super) fn hint(&mut self, text: impl Into, cx: &mut Context) { + let gone = cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(2500)) + .await; + this.update(cx, |this, cx| { + this.hint = None; + cx.notify(); + }) + .ok(); + }); + self.hint = Some((text.into(), gone)); + cx.notify(); + } + + /// Whether this viewer can mark: annotate mode is on and the file belongs to a row. + pub(super) fn annotating(&self, cx: &App) -> bool { + self.row.is_some() && !self.missing && state(cx).0 + } + + pub(super) fn toggle_annotate(&mut self, window: &mut Window, cx: &mut Context) { + if self.row.is_none() || self.missing || self.exit_annotate(window, cx) { + return; + } + cx.default_global::().on = true; + cx.notify(); + } + + pub(super) fn exit_annotate(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if !self.annotating(cx) { + return false; + } + cx.default_global::().on = false; + self.composer = None; + self.deleting = None; + self.draw = None; + self.grab = None; + self.stepped = None; + self.revealing = None; + self.drag_from = None; + self.select(None, cx); + window.focus(&self.focus_handle, cx); + true + } + + /// Start a gesture under the pointer. `false` leaves the press to panning. + pub(super) fn press(&mut self, position: Point, cx: &mut Context) -> bool { + if self.composer.is_some() { + return true; + } + let (_, tool, hidden) = state(cx); + if !self.annotating(cx) || !self.ready() { + return false; + } + let at = regions::at(self.page_box(), self.local(position)); + if tool == Tool::Select { + let local = self.local(position); + let placed = match hidden { + true => Vec::new(), + false => self.placed(&self.row_marks(cx)), + }; + let handle = self + .selected + .and_then(|id| placed.iter().find(|(m, ..)| m.id == id)) + .and_then(|(m, rect, b)| Some((m.id, Grip::under(*b, local)?, *rect))); + let boxes: Vec<_> = placed.iter().map(|(m, _, b)| (m.id, *b)).collect(); + let grabbed = handle.or_else(|| { + let id = regions::hit(&boxes, local)?; + let (_, rect, _) = placed.iter().find(|(m, ..)| m.id == id)?; + Some((id, Grip::Move, *rect)) + }); + self.select(grabbed.map(|(id, ..)| id), cx); + self.grab = grabbed.map(|(id, grip, rect)| Grab { + id, + grip, + rect, + from: at, + to: at, + }); + return grabbed.is_some(); + } + if !self.page_box().contains(&self.local(position)) { + return false; + } + if hidden { + cx.default_global::().hidden = false; + } + self.draw = Some(Draw { from: at, to: at }); + cx.notify(); + true + } + + /// Follow the pointer with the gesture under way. `false` when there is none. + pub(super) fn drag_to(&mut self, position: Point, cx: &mut Context) -> bool { + let at = regions::at(self.page_box(), self.local(position)); + if let Some(grab) = self.grab.as_mut() { + grab.to = at; + cx.notify(); + return true; + } + match self.draw.as_mut() { + Some(draw) => { + draw.to = at; + cx.notify(); + true + } + None => false, + } + } + + /// End the gesture: a drag marks a box, a click a pin, and either opens the composer. + pub(super) fn release(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(grab) = self.grab.take() { + let moved = grab.rect(); + let was = self.row_marks(cx).into_iter().find(|m| m.id == grab.id); + if let Some(was) = was.filter(|_| moved != grab.rect) { + let of = self.header.or(was.region.of); + let region = regions::upright(moved, self.shown, self.page as u32, of); + self.undo.push((grab.id, Some(was.region))); + Diagnostics::move_note(grab.id, region, Origin::Drawn, cx); + } + cx.notify(); + return; + } + let Some(Draw { from, to }) = self.draw.take() else { + return; + }; + let page = self.page_box(); + let far = (to.0 - from.0).abs() * f32::from(page.size.width) > 4. + || (to.1 - from.1).abs() * f32::from(page.size.height) > 4.; + let rect = match (state(cx).1, far) { + (Tool::Rect, true) => regions::span(from, to), + _ => [to.0, to.1, to.0, to.1], + }; + let region = regions::upright(rect, self.shown, self.page as u32, self.header); + let text = cx.new(|cx| TextareaState::new(window, cx).placeholder("Note")); + cx.subscribe_in(&text, window, |this, _, event: &InputEvent, window, cx| { + if let InputEvent::PressEnter { + secondary: true, .. + } = event + { + this.save(false, window, cx); + } + }) + .detach(); + text.update(cx, |text, cx| text.focus(window, cx)); + self.composer = Some(Composer { + region, + text, + kind: None, + sign: None, + }); + cx.notify(); + } + + /// File the composed note. The first time someone with no name saves, ask once instead. + pub(super) fn save(&mut self, skip: bool, window: &mut Window, cx: &mut Context) { + let Some(composer) = self.composer.as_mut() else { + return; + }; + let message = composer.text.read(cx).value(); + if message.trim().is_empty() { + return; + } + if !skip && composer.sign.is_none() && unasked(cx) { + let sign = cx.new(|cx| InputState::new(window, cx).placeholder("Your name")); + cx.subscribe_in(&sign, window, |this, _, event: &InputEvent, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.save(false, window, cx); + } + }) + .detach(); + sign.update(cx, |sign, cx| sign.focus(window, cx)); + composer.sign = Some(sign); + cx.notify(); + return; + } + if let Some(sign) = composer.sign.as_ref() { + let name = sign.read(cx).value().trim().to_string(); + if !skip && !name.is_empty() { + settings::AppSettings::set_text(settings::NOTE_AUTHOR_KEY, name.into(), cx); + } + settings::AppSettings::set_bool(settings::NOTE_AUTHOR_ASKED_KEY, true, cx); + } + let Some(composer) = self.composer.take() else { + return; + }; + let filed = self.location(cx).and_then(|location| { + Diagnostics::file_note( + location, + Some(composer.region), + composer.kind, + message.trim().to_string().into(), + Origin::Drawn, + cx, + ) + }); + self.undo.extend(filed.map(|id| (id, None))); + window.focus(&self.focus_handle, cx); + cx.notify(); + } + + /// Take back the last region this viewer added, moved or resized. + pub(super) fn undo_mark(&mut self, cx: &mut Context) { + let Some((id, was)) = self.undo.pop() else { + return; + }; + match was { + Some(region) => { + Diagnostics::move_note(id, region, Origin::Undo, cx); + self.hint("Annotation put back", cx); + } + None => { + Diagnostics::edit_note(id, SharedString::default(), None, Origin::Undo, cx); + if self.selected == Some(id) { + self.select(None, cx); + } + self.hint("Annotation removed", cx); + } + } + } + + pub(super) fn select(&mut self, id: Option, cx: &mut Context) { + self.selected = id; + cx.set_global(Picked(id)); + cx.notify(); + } + + /// Ask before deleting the selected region. + pub(super) fn ask_delete(&mut self, cx: &mut Context) { + if self.annotating(cx) && state(cx).1 == Tool::Select { + self.deleting = self.selected; + cx.notify(); + } + } + + pub(super) fn delete(&mut self, window: &mut Window, cx: &mut Context) { + if let Some(id) = self.deleting.take() { + Diagnostics::edit_note(id, SharedString::default(), None, Origin::Drawn, cx); + self.select(None, cx); + } + window.focus(&self.focus_handle, cx); + cx.notify(); + } + + /// Put away whatever annotate mode has in hand, innermost first: the composer, a confirmation, + /// a drag, then the selection. `false` if there was nothing. + pub(super) fn cancel(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let had = self.composer.take().is_some() + || self.deleting.take().is_some() + || self.draw.take().is_some() + || self.grab.take().is_some() + || self.stepped.take().is_some(); + if !had && self.selected.is_some() { + self.select(None, cx); + return true; + } + if had { + window.focus(&self.focus_handle, cx); + cx.notify(); + } + had + } + + /// Where a new note on this row is filed: the row itself, at its current position. + pub(super) fn location(&self, cx: &App) -> Option { + let row_id = self.row?; + let row = cx + .try_global::() + .and_then(|handle| handle.0.upgrade()) + .and_then(|table| table.read(cx).delegate().row_of(row_id)); + Some(Location { + dataset: DATASET_MAIN.into(), + row, + row_id: Some(row_id), + column: None, + }) + } + + /// What the foot of the stage says while annotate mode waits for a first region on this page. + pub(super) fn standing_hint(&self, marks: &[Mark], cx: &App) -> Option { + let (_, tool, hidden) = state(cx); + let on_page: Vec<_> = marks + .iter() + .filter(|m| m.region.page as usize == self.page) + .collect(); + if let Some(at) = self + .stepped + .and_then(|id| on_page.iter().position(|m| m.id == id)) + .filter(|_| !hidden) + { + return Some( + format!( + "{} of {} on this page · [ previous · ] next", + at + 1, + on_page.len() + ) + .into(), + ); + } + (self.annotating(cx) && on_page.is_empty() && !hidden && self.composer.is_none()) + .then_some(match tool { + Tool::Rect => "Drag to mark a region, click to drop a pin", + Tool::Pin => "Click to drop a pin", + Tool::Select => return None, + }) + .map(SharedString::from) + } + + /// Turn to the region the Notes panel asked for, zooming once the page is laid out. + pub(super) fn follow_reveal(&mut self, cx: &mut Context) { + let Some(id) = cx.try_global::().and_then(|reveal| reveal.0) else { + return; + }; + let Some(mark) = self.row_marks(cx).into_iter().find(|m| m.id == id) else { + return; + }; + if mark.region.page as usize != self.page { + self.show_page(mark.region.page as usize); + } + self.stepped = Some(id); + self.revealing = Some(mark.region); + cx.notify(); + } + + /// Zoom to the previous or next region on this page, stopping at either end as pages do. + pub(super) fn step_mark(&mut self, delta: isize, cx: &mut Context) { + let mut on_page = self.row_marks(cx); + on_page.retain(|m| m.region.page as usize == self.page); + let last = on_page.len() as isize - 1; + if last < 0 || hidden(cx) { + return; + } + let at = self + .stepped + .and_then(|id| on_page.iter().position(|m| m.id == id)); + let next = match at { + Some(at) => (at as isize + delta).clamp(0, last), + None if delta < 0 => last, + None => 0, + }; + let mark = on_page[next as usize]; + self.stepped = Some(mark.id); + self.zoom_to(&mark.region); + cx.notify(); + } + + /// Centre `region` and zoom until it spans half the frame, the way W fits the width. A pin + /// zooms as though it marked [`PIN_EXTENT`] of the page. + pub(super) fn zoom_to(&mut self, region: &Region) { + let Some((image, fit)) = self.fit() else { + return; + }; + let [l, t, r, b] = regions::shown(region, self.shown); + let (width, height) = (image.width * fit, image.height * fit); + let frame = self.frame.get().size; + let zoom = (f32::from(frame.width) * 0.5 / (width * (r - l).max(PIN_EXTENT))) + .min(f32::from(frame.height) * 0.5 / (height * (b - t).max(PIN_EXTENT))) + .clamp(1., 8.); + self.zoom = zoom; + self.offset = point( + px(width * zoom * (0.5 - (l + r) / 2.)), + px(height * zoom * (0.5 - (t + b) / 2.)), + ); + self.clamp_pan(); + } + + pub(super) fn toggle_hidden(&mut self, cx: &mut Context) { + let now = !hidden(cx); + cx.default_global::().hidden = now; + let count = self.row_marks(cx).len(); + if now && count > 0 { + let noun = if count == 1 { + "annotation" + } else { + "annotations" + }; + self.hint(format!("{count} {noun} hidden. Press N to show them."), cx); + } + cx.notify(); + } +} + +/// Annotate and show/hide, first in the viewer's toolbar. +pub(super) fn buttons(this: &Viewer, cx: &mut Context) -> AnyElement { + let (on, _, hidden) = state(cx); + h_flex() + .gap_1() + .child( + Button::new("toggle-annotate") + .icon(Icon::empty().path("icons/square-pen.svg")) + .ghost() + .small() + .selected(on && this.row.is_some() && !this.missing) + .disabled(this.row.is_none() || this.missing) + .tooltip("Annotate (A)") + .on_click(cx.listener(|this, _, window, cx| this.toggle_annotate(window, cx))), + ) + .child( + Button::new("toggle-annotations") + .icon(match hidden { + true => IconName::EyeOff, + false => IconName::Eye, + }) + .ghost() + .small() + .disabled(this.row.is_none()) + .tooltip(match hidden { + true => "Show annotations (N)", + false => "Hide annotations (N)", + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_hidden(cx))), + ) + .into_any_element() +} + +/// The tool pill on the stage's left edge, while annotating. +pub(super) fn tools(this: &Viewer, cx: &mut Context) -> Option { + let (_, current, _) = state(cx); + this.annotating(cx).then(|| { + v_flex() + .gap(px(2.)) + .p_1() + .rounded(cx.theme().radius) + .bg(cx.theme().background.opacity(0.8)) + .occlude() + .children( + [ + (Tool::Select, "icons/mouse-pointer-2.svg", "Select"), + (Tool::Rect, "icons/rectangle.svg", "Rectangle"), + (Tool::Pin, "icons/map-pin.svg", "Pin"), + ] + .map(|(tool, icon, tip)| { + Button::new(tip) + .icon(Icon::empty().path(icon)) + .ghost() + .small() + .selected(tool == current) + .tooltip(tip) + .on_click(cx.listener(move |this, _, _, cx| { + cx.default_global::().tool = tool; + this.draw = None; + cx.notify(); + })) + }), + ) + .into_any_element() + }) +} + +/// Everything drawn over the page: each region, its badge, and the card of the one hovered. +/// Laid over the content box, in its coordinates. +pub(super) fn layer(this: &Viewer, marks: &[Mark], cx: &mut Context) -> AnyElement { + let frame = this.frame.get().size; + let shown = !hidden(cx) && this.ready() && frame.width > px(0.); + let placed = match shown { + true => this.placed(marks), + false => Vec::new(), + }; + let lit = cx.try_global::().and_then(|lit| lit.0); + let focus = this.hovered.or(this.stepped).or(lit).or(this.selected); + let focus_box = focus.and_then(|id| placed.iter().find(|(m, ..)| m.id == id).map(|p| p.2)); + let crowded = focus_box.is_some_and(|f| { + placed + .iter() + .any(|(m, _, b)| Some(m.id) != focus && f.intersects(b)) + }); + let dim = |id: NoteId| crowded && Some(id) != focus; + let (white, dark) = (white(), rgb(0x161616)); + + let mut boxes: Vec<_> = placed + .iter() + .filter(|(_, rect, _)| rect[0] != rect[2] || rect[1] != rect[3]) + .map(|(m, _, b)| (m.id, m.number, *b)) + .collect(); + boxes.sort_by_key(|(_, number, _)| *number); + let badges = regions::badges(&boxes); + + let card = focus + .filter(|id| Some(*id) == this.hovered.or(this.stepped)) + .and_then(|id| { + Some(( + Diagnostics::note(id, cx)?, + placed.iter().find(|p| p.0.id == id)?, + )) + }) + .map(|(note, (_, _, b))| { + let pin = b.size.width == px(0.); + let below = match pin { + true => b.origin.y + px(14.), + false => b.origin.y + b.size.height + px(8.), + }; + let top = match below + px(110.) > frame.height - px(12.) { + true => b.origin.y - px(118.), + false => below, + }; + let left = b + .origin + .x + .min(frame.width - px(diagnostics::note_card::WIDTH + 12.)) + .max(px(12.)); + div() + .absolute() + .left(left) + .top(top) + .child(diagnostics::note_card::note_card( + note, + None, + Vec::new(), + cx, + )) + }); + + let page = this.page_box(); + let tool = state(cx).1; + let theme = cx.theme().clone(); + let drawing = this + .draw + .as_ref() + .filter(|_| tool == Tool::Rect) + .map(|draw| regions::on_screen(page, regions::span(draw.from, draw.to))) + .filter(|b| b.size.width > px(0.) || b.size.height > px(0.)); + let composing = this.composer.as_ref().map(|composer| { + let rect = regions::shown(&composer.region, this.shown); + (rect, regions::on_screen(page, rect)) + }); + let composer = + this.composer + .as_ref() + .zip(composing) + .map(|(composer, (rect, b))| { + let height = if composer.sign.is_some() { 262. } else { 180. }; + let right = b.origin.x + b.size.width + px(12.); + let left = match right + px(280.) > frame.width - px(12.) { + true => b.origin.x - px(292.), + false => right, + } + .max(px(12.)); + let top = b.origin.y.min(frame.height - px(height + 12.)).max(px(12.)); + let row = this.location(cx).and_then(|l| l.row); + let strip = [ + Some(row.map_or("Note".to_string(), |row| format!("Note on row {}", row + 1))), + this.paged().then(|| format!("page {}", this.page + 1)), + Some(match rect[0] == rect[2] && rect[1] == rect[3] { + true => "pin".to_string(), + false => "region".to_string(), + }), + ] + .into_iter() + .flatten() + .collect::>() + .join(" · "); + let save_key = match cfg!(target_os = "macos") { + true => "⌘Enter to save", + false => "Ctrl+Enter to save", + }; + div() + .absolute() + .left(left) + .top(top) + .w(px(280.)) + .occlude() + .on_action(cx.listener( + |this, _: &gpui_component::input::Escape, window, cx| { + if !this.cancel(window, cx) { + this.exit_annotate(window, cx); + } + }, + )) + .child( + v_flex() + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.primary) + .rounded(px(6.)) + .shadow_md() + .overflow_hidden() + .text_size(px(13.)) + .line_height(px(18.)) + .child( + div() + .px_2() + .py(px(3.)) + .bg(theme.muted) + .text_xs() + .text_color(theme.muted_foreground) + .child(strip), + ) + .child( + div().px_1().py_1().child( + Textarea::new(&composer.text).appearance(false).h(px(54.)), + ), + ) + .child(div().px_2().pb_2().child({ + let viewer = cx.entity().downgrade(); + diagnostics::note_card::kind_chips( + composer.kind, + move |kind, _, cx| { + viewer + .update(cx, |this, cx| { + if let Some(composer) = this.composer.as_mut() { + composer.kind = kind; + } + cx.notify(); + }) + .ok(); + }, + cx, + ) + })) + .children(composer.sign.as_ref().map(|sign| { + v_flex() + .gap_1p5() + .p_2() + .border_t_1() + .border_color(theme.border) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("Sign your notes as…"), + ) + .child(Input::new(sign).small()) + .child( + div() + .id("skip-signing") + .text_xs() + .text_color(theme.link) + .cursor_pointer() + .child("Skip, leave unsigned") + .on_click(cx.listener(|this, _, window, cx| { + this.save(true, window, cx) + })), + ) + })) + .child( + h_flex() + .items_center() + .gap_1p5() + .px_2() + .py_1p5() + .border_t_1() + .border_color(theme.border) + .child( + div() + .flex_1() + .text_xs() + .text_color(theme.muted_foreground) + .whitespace_nowrap() + .child(save_key), + ) + .child( + Button::new("composer-cancel") + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(window, cx); + })), + ) + .child( + Button::new("composer-save") + .label("Save") + .primary() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.save(false, window, cx) + })), + ), + ), + ) + }); + + let deleting = this + .deleting + .and_then(|id| { + Some(( + Diagnostics::note(id, cx)?, + placed.iter().find(|p| p.0.id == id)?, + )) + }) + .map(|(note, (mark, _, b))| { + let right = b.origin.x + b.size.width + px(12.); + let left = match right + px(252.) > frame.width - px(12.) { + true => b.origin.x - px(264.), + false => right, + } + .max(px(12.)); + let top = b.origin.y.min(frame.height - px(112.)).max(px(12.)); + let author = note + .note + .as_ref() + .and_then(|n| n.filed.as_ref()?.author.clone()); + let question = match author { + Some(author) => format!("Delete annotation #{} by {author}?", mark.number), + None => format!("Delete annotation #{}?", mark.number), + }; + div() + .absolute() + .left(left) + .top(top) + .w(px(252.)) + .occlude() + .child( + v_flex() + .gap_1p5() + .px_2p5() + .pt_2p5() + .pb_2() + .bg(theme.popover) + .text_color(theme.popover_foreground) + .border_1() + .border_color(theme.border) + .rounded(px(6.)) + .shadow_md() + .text_size(px(13.)) + .line_height(px(18.)) + .child(question) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child("History keeps a copy you can restore."), + ) + .child( + h_flex() + .justify_end() + .gap_1p5() + .mt_0p5() + .child( + Button::new("delete-cancel") + .label("Cancel") + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.cancel(window, cx); + })), + ) + .child( + Button::new("delete-confirm") + .label("Delete") + .danger() + .small() + .on_click(cx.listener(|this, _, window, cx| { + this.delete(window, cx) + })), + ), + ), + ) + }); + + div() + .absolute() + .top_0() + .left_0() + .size_full() + .children(drawing.map(|b| { + let shade = black().opacity(0.3); + let (right, bottom) = (b.origin.x + b.size.width, b.origin.y + b.size.height); + let page_right = page.origin.x + page.size.width; + let page_bottom = page.origin.y + page.size.height; + let strip = |left: Pixels, top: Pixels, width: Pixels, height: Pixels| { + div() + .absolute() + .left(left) + .top(top) + .w(width.max(px(0.))) + .h(height.max(px(0.))) + .bg(shade) + }; + div() + .absolute() + .size_full() + .child(strip( + page.origin.x, + page.origin.y, + page.size.width, + b.origin.y - page.origin.y, + )) + .child(strip( + page.origin.x, + bottom, + page.size.width, + page_bottom - bottom, + )) + .child(strip( + page.origin.x, + b.origin.y, + b.origin.x - page.origin.x, + b.size.height, + )) + .child(strip(right, b.origin.y, page_right - right, b.size.height)) + .child( + div() + .absolute() + .left(b.origin.x) + .top(b.origin.y) + .w(b.size.width) + .h(b.size.height) + .border(px(1.5)) + .border_dashed() + .border_color(white), + ) + })) + .children(composing.map(|(rect, b)| { + match rect[0] == rect[2] && rect[1] == rect[3] { + true => div() + .absolute() + .left(b.origin.x - px(9.)) + .top(b.origin.y - px(9.)) + .size(px(18.)) + .rounded_full() + .bg(theme.primary) + .border(px(1.5)) + .border_color(white), + false => div() + .absolute() + .left(b.origin.x) + .top(b.origin.y) + .w(b.size.width) + .h(b.size.height) + .border(px(1.5)) + .border_color(black().opacity(0.7)) + .child( + div() + .size_full() + .border(px(1.5)) + .border_color(white.opacity(0.92)), + ), + } + })) + .children(placed.iter().map(|(m, rect, b)| { + let pin = rect[0] == rect[2] && rect[1] == rect[3]; + let opacity = match (dim(m.id), this.missing) { + (true, _) => 0.4, + (false, true) => 0.5, + (false, false) => 1., + }; + match pin { + true => div() + .absolute() + .left(b.origin.x - px(9.)) + .top(b.origin.y - px(9.)) + .size(px(18.)) + .rounded_full() + .flex() + .items_center() + .justify_center() + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(white) + .bg(match this.selected == Some(m.id) { + true => theme.primary, + false => dark.into(), + }) + .border(px(1.5)) + .border_color(white) + .shadow_sm() + .opacity(opacity) + .child(m.number.to_string()), + false => div() + .absolute() + .left(b.origin.x) + .top(b.origin.y) + .w(b.size.width) + .h(b.size.height) + .rounded(px(1.)) + .border(px(1.5)) + .border_color(match this.missing { + true => transparent_black(), + false => black().opacity(0.7), + }) + .opacity(opacity) + .child( + div() + .size_full() + .border(px(1.5)) + .when(this.missing, |inner| inner.border_dashed()) + .border_color(white.opacity(0.92)) + .when(focus == Some(m.id), |inner| inner.bg(white.opacity(0.18))), + ) + .when(this.selected == Some(m.id), |region| { + region.children(Grip::HANDLES.map(|grip| { + let (u, v) = grip.anchor(); + div() + .absolute() + .left(b.size.width * u - px(regions::HANDLE / 2. + 1.5)) + .top(b.size.height * v - px(regions::HANDLE / 2. + 1.5)) + .size(px(regions::HANDLE)) + .bg(white) + .border(px(1.5)) + .border_color(rgb(0x111111)) + })) + }), + } + })) + .children(badges.into_iter().map(|badge| { + let faded = badge.members.iter().all(|id| dim(*id)); + let picked = badge.members.iter().any(|id| this.selected == Some(*id)); + div() + .absolute() + .left(badge.at.x - px(1.5)) + .top(badge.at.y - px(1.5)) + .h(px(regions::BADGE)) + .min_w(px(regions::BADGE)) + .px(px(4.)) + .rounded_br(px(4.)) + .flex() + .items_center() + .justify_center() + .whitespace_nowrap() + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(white) + .bg(match picked { + true => theme.primary, + false => black().opacity(0.9), + }) + .opacity(if faded { 0.4 } else { 1. }) + .child(badge.label) + })) + .children(card.filter(|_| { + this.draw.is_none() + && this.grab.is_none() + && this.composer.is_none() + && this.deleting.is_none() + })) + .children(composer) + .children(deleting) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use diagnostics::{DATASET_MAIN, Diagnostics, Location, Region}; + use gpui::TestAppContext; + use settings::history::Origin; + + use crate::viewer::Scope; + use crate::viewer::annotate::{Annotating, Tool, marks, unasked}; + + /// A row's regions are numbered in the order they were filed, and nothing else on the row — + /// its plain notes, another row's regions — takes a number. + #[gpui::test] + fn a_rows_regions_are_numbered_in_filing_order(cx: &mut TestAppContext) { + cx.update(|cx| { + let row = |id| Location { + dataset: DATASET_MAIN.into(), + row: Some(id as usize - 1), + row_id: Some(id), + column: None, + }; + let region = |x| Region { + page: 0, + x, + y: 0, + w: 1000, + h: 1000, + of: None, + }; + let file = |at, region, text: &str, cx: &mut gpui::App| { + Diagnostics::file_note(row(at), region, None, text.into(), Origin::Drawn, cx) + }; + let first = file(1, Some(region(100)), "stamp", cx); + file(1, None, "faded", cx); + file(2, Some(region(200)), "elsewhere", cx); + let second = file(1, Some(region(300)), "signature", cx); + + let numbered: Vec<_> = marks(1, cx) + .iter() + .map(|m| (Some(m.id), m.number)) + .collect(); + assert_eq!(numbered, vec![(first, 1), (second, 2)]); + }); + } + + /// A box dragged out in annotate mode opens the composer; the first save with no name set asks + /// for one instead, and skipping files the note unsigned and never asks again. + #[gpui::test] + fn a_dragged_box_is_filed_after_asking_once_for_a_name(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + gpui_component::init(cx); + cx.set_global(settings::AppSettings::default()); + cx.set_global(settings::SettingsPersistence::default()); + cx.default_global::().on = true; + let viewer = crate::viewer::build( + "/nonexistent/qrate-annotate.png".into(), + Some(7), + Scope::Workspace, + window, + cx, + ); + viewer.update(cx, |viewer, cx| { + viewer.frame.set(gpui::Bounds { + origin: gpui::point(gpui::px(0.), gpui::px(0.)), + size: gpui::size(gpui::px(400.), gpui::px(300.)), + }); + viewer.pixels = Some((400, 300)); + assert!(viewer.press(gpui::point(gpui::px(100.), gpui::px(75.)), cx)); + viewer.drag_to(gpui::point(gpui::px(200.), gpui::px(150.)), cx); + viewer.release(window, cx); + assert!(viewer.composer.is_some(), "the composer opens"); + viewer.composer = None; + viewer.zoom = 0.5; + assert!( + !viewer.press(gpui::point(gpui::px(10.), gpui::px(10.)), cx), + "a press beside the page marks nothing" + ); + viewer.zoom = 1.; + assert!(viewer.press(gpui::point(gpui::px(100.), gpui::px(75.)), cx)); + viewer.drag_to(gpui::point(gpui::px(200.), gpui::px(150.)), cx); + viewer.release(window, cx); + let text = viewer + .composer + .as_ref() + .expect("the composer opens") + .text + .clone(); + text.update(cx, |text, cx| text.set_value("customs stamp", window, cx)); + + viewer.save(false, window, cx); + assert!( + viewer.composer.as_ref().is_some_and(|c| c.sign.is_some()), + "the first save asks for a name" + ); + assert!(marks(7, cx).is_empty(), "and files nothing yet"); + + viewer.save(true, window, cx); + let filed = marks(7, cx); + assert_eq!(filed.len(), 1); + let region = filed[0].region; + assert_eq!( + (region.x, region.y, region.w, region.h), + (2500, 2500, 2500, 2500) + ); + assert!(!unasked(cx), "skipping counts as asked"); + }); + }); + } + + /// The Select tool picks a region up by its middle and moves it; Delete asks first, and only + /// the confirmation removes it. + #[gpui::test] + fn a_selected_region_moves_and_deletes_on_confirmation(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + gpui_component::init(cx); + let state = cx.default_global::(); + state.on = true; + state.tool = Tool::Select; + let location = Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(3), + column: None, + }; + let stamp = Region { + page: 0, + x: 2500, + y: 2500, + w: 2500, + h: 2500, + of: None, + }; + let id = Diagnostics::file_note( + location, + Some(stamp), + None, + "stamp".into(), + Origin::Drawn, + cx, + ) + .unwrap(); + let viewer = crate::viewer::build( + { + let path = std::env::temp_dir().join("qrate-select.png"); + std::fs::write(&path, b"").unwrap(); + path + }, + Some(3), + Scope::Workspace, + window, + cx, + ); + viewer.update(cx, |viewer, cx| { + viewer.frame.set(gpui::Bounds { + origin: gpui::point(gpui::px(0.), gpui::px(0.)), + size: gpui::size(gpui::px(400.), gpui::px(300.)), + }); + viewer.pixels = Some((400, 300)); + assert!(viewer.press(gpui::point(gpui::px(150.), gpui::px(110.)), cx)); + assert_eq!(viewer.selected, Some(id)); + viewer.drag_to(gpui::point(gpui::px(190.), gpui::px(110.)), cx); + viewer.release(window, cx); + let moved = marks(3, cx)[0].region; + assert_eq!((moved.x, moved.y, moved.w), (3500, 2500, 2500)); + viewer.undo_mark(cx); + assert_eq!(marks(3, cx)[0].region.x, 2500, "undo puts the move back"); + + viewer.ask_delete(cx); + assert_eq!(viewer.deleting, Some(id)); + assert_eq!(marks(3, cx).len(), 1, "asking deletes nothing"); + viewer.delete(window, cx); + assert!(marks(3, cx).is_empty()); + assert_eq!(viewer.selected, None); + }); + }); + } + + /// `]` zooms to the first region on the page and centres it; `[` from there stays put at the + /// first, as a page turn stops at the first page. + #[gpui::test] + fn stepping_zooms_to_each_region_on_the_page(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + gpui_component::init(cx); + let location = Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(4), + column: None, + }; + let corner = Region { + page: 0, + x: 0, + y: 0, + w: 2500, + h: 2500, + of: None, + }; + let first = Diagnostics::file_note( + location.clone(), + Some(corner), + None, + "a".into(), + Origin::Drawn, + cx, + ); + Diagnostics::file_note( + location, + Some(Region { x: 5000, ..corner }), + None, + "b".into(), + Origin::Drawn, + cx, + ); + let viewer = crate::viewer::build( + { + let path = std::env::temp_dir().join("qrate-step.png"); + std::fs::write(&path, b"").unwrap(); + path + }, + Some(4), + Scope::Workspace, + window, + cx, + ); + viewer.update(cx, |viewer, cx| { + viewer.frame.set(gpui::Bounds { + origin: gpui::point(gpui::px(0.), gpui::px(0.)), + size: gpui::size(gpui::px(400.), gpui::px(300.)), + }); + viewer.pixels = Some((400, 300)); + viewer.step_mark(1, cx); + assert_eq!(viewer.stepped, first); + assert_eq!(viewer.zoom, 2.); + assert_eq!( + (viewer.offset.x, viewer.offset.y), + (gpui::px(200.), gpui::px(150.)) + ); + viewer.step_mark(-1, cx); + assert_eq!(viewer.stepped, first); + viewer.zoom_to(&Region { + w: 0, + h: 0, + ..corner + }); + assert!(viewer.zoom < 4., "a pin does not zoom to the limit"); + viewer.step_mark(1, cx); + assert_ne!(viewer.stepped, first); + assert!( + viewer + .standing_hint(&marks(4, cx), cx) + .is_some_and(|h| h.starts_with("2 of 2")) + ); + }); + }); + } + + /// A row whose file is gone still shows its regions, on a page of the size they were marked + /// on, but cannot take new ones. + #[gpui::test] + fn a_missing_file_keeps_its_regions_and_turns_annotating_off(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + cx.update(|window, cx| { + gpui_component::init(cx); + cx.default_global::().on = true; + let location = Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(5), + column: None, + }; + let region = Region { + page: 0, + x: 0, + y: 0, + w: 2500, + h: 2500, + of: Some((4000, 3000)), + }; + Diagnostics::file_note(location, Some(region), None, "a".into(), Origin::Drawn, cx); + let viewer = crate::viewer::build( + "/nonexistent/qrate-gone.tif".into(), + Some(5), + Scope::Workspace, + window, + cx, + ); + viewer.update(cx, |viewer, cx| { + assert!(viewer.missing); + assert_eq!(viewer.pixels, Some((4000, 3000))); + assert!(!viewer.annotating(cx)); + assert!(!viewer.press(gpui::point(gpui::px(100.), gpui::px(75.)), cx)); + assert!(viewer.draw.is_none()); + assert!(!viewer.has_controls()); + }); + }); + } + + /// Clicking a Notes card for a row the overlay already shows turns it to the region's page and + /// zooms there once the page has a size. + #[gpui::test] + fn revealing_a_region_turns_to_its_page_and_zooms(cx: &mut TestAppContext) { + let cx = cx.add_empty_window(); + let (id, region) = cx.update(|window, cx| { + gpui_component::init(cx); + let location = Location { + dataset: DATASET_MAIN.into(), + row: Some(0), + row_id: Some(6), + column: None, + }; + let region = Region { + page: 2, + x: 2500, + y: 2500, + w: 2500, + h: 2500, + of: None, + }; + let id = + Diagnostics::file_note(location, Some(region), None, "a".into(), Origin::Drawn, cx) + .unwrap(); + let path = std::env::temp_dir().join("qrate-reveal.png"); + std::fs::write(&path, b"").unwrap(); + crate::viewer::open_viewer(path, Some(6), Scope::Workspace, window, cx); + crate::viewer::reveal(id, false, window, cx); + (id, region) + }); + cx.update(|window, cx| { + let viewer = crate::viewer::viewer_in(Scope::Workspace, cx).unwrap(); + viewer.update(cx, |viewer, _| { + assert_eq!(viewer.page, 2); + assert_eq!(viewer.stepped, Some(id)); + assert_eq!(viewer.revealing, Some(region)); + viewer.frame.set(gpui::Bounds { + origin: gpui::point(gpui::px(0.), gpui::px(0.)), + size: gpui::size(gpui::px(400.), gpui::px(300.)), + }); + viewer.pixels = Some((400, 300)); + viewer.zoom_to(®ion); + assert_eq!(viewer.zoom, 2.); + }); + crate::viewer::close_viewer(window, cx); + }); + } +} diff --git a/crates/workspace/src/viewer/highlight.rs b/crates/workspace/src/viewer/highlight.rs index 2b622828..9e81507d 100644 --- a/crates/workspace/src/viewer/highlight.rs +++ b/crates/workspace/src/viewer/highlight.rs @@ -56,7 +56,7 @@ pub fn quad( } /// The page image's own rectangle: its size, and where its top-left corner sits. -fn drawn( +pub(super) fn drawn( area: Bounds, zoom: f32, offset: Point, diff --git a/crates/workspace/src/viewer/mod.rs b/crates/workspace/src/viewer/mod.rs index fac89225..d2c1aaa3 100644 --- a/crates/workspace/src/viewer/mod.rs +++ b/crates/workspace/src/viewer/mod.rs @@ -10,8 +10,11 @@ //! The two neighbours hold the parts with rules in them: [`find`] owns the search state, and //! [`highlight`] turns a hit's position on the page into a position on the screen. +mod annotate; +pub(crate) use annotate::{Lit, Picked, marks, reveal}; pub(crate) mod find; mod highlight; +mod regions; pub(crate) mod transport; use std::cell::Cell; @@ -28,8 +31,11 @@ use gpui_component::{ input::{Input, InputEvent, InputState}, resizable::{ResizableState, h_resizable, resizable_panel}, slider::{Slider, SliderEvent, SliderState, SliderValue}, + v_flex, }; +use settings::project::RowId; + use crate::viewer::find::Find; use crate::viewer::transport::Transport; @@ -87,14 +93,21 @@ pub fn viewer_in(scope: Scope, cx: &App) -> Option> { (viewer.read(cx).scope == scope).then_some(viewer) } -/// Opens `path` in the shared viewer overlay, replacing any viewer already open. -pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut App) { +/// Opens `path` in the shared viewer overlay, replacing any viewer already open. `row` is the item +/// it belongs to, whose region notes are drawn over it. +pub fn open_viewer( + path: PathBuf, + row: Option, + scope: Scope, + window: &mut Window, + cx: &mut App, +) { let return_focus = cx .try_global::() .and_then(|active| active.return_focus.clone()) .or_else(|| window.focused(cx)); stop_active(cx); - let viewer = build(path, scope, window, cx); + let viewer = build(path, row, scope, window, cx); cx.set_global(ActiveViewer { viewer: Some(viewer), return_focus, @@ -104,14 +117,27 @@ pub fn open_viewer(path: PathBuf, scope: Scope, window: &mut Window, cx: &mut Ap /// A viewer for `path`, not yet mounted anywhere. pub(crate) fn build( path: PathBuf, + row: Option, scope: Scope, window: &mut Window, cx: &mut App, ) -> Entity { let document = preview::has_text(&path); let video = preview::has_video(&path); - let details = preview::describe(&path); - let pixels = preview::dimensions(&path); + let shape = row.and_then(|row| { + let marks = annotate::marks(row, cx); + let of = marks.iter().find_map(|m| m.region.of); + (!marks.is_empty()).then_some(of) + }); + let missing = shape.is_some() && !path.exists(); + let details = match missing { + true => Some("File not found".to_string()), + false => preview::describe(&path), + }; + let pixels = match missing { + true => shape.flatten().or(Some((1500, 2000))), + false => preview::dimensions(&path), + }; let probe_path = path.clone(); let table = cx .try_global::() @@ -127,7 +153,7 @@ pub(crate) fn build( && this.needs.is_some() && preview::missing(&this.path).is_none() { - open_viewer(this.path.clone(), this.scope, window, cx); + open_viewer(this.path.clone(), this.row, this.scope, window, cx); } cx.notify(); }, @@ -138,15 +164,35 @@ pub(crate) fn build( window, |this: &mut Viewer, table, _: &table::TableChanged, window, cx| { let delegate = table.read(cx).delegate(); - let file = delegate - .cursor_row() - .and_then(|row| previewable(delegate, row)); - if let Some(file) = file.filter(|file| *file != this.path) { - open_viewer(file, this.scope, window, cx); + let Some(row) = delegate.cursor_row() else { + return; + }; + let id = delegate.row_id(row); + let file = shown_file(delegate, row, cx); + if let Some(file) = file.filter(|file| *file != this.path || id != this.row) { + open_viewer(file, id, this.scope, window, cx); } }, ) }), + _notes: [ + cx.observe_global::(|_, cx| cx.notify()), + cx.observe_global::(|_, cx| cx.notify()), + cx.observe_global::(|_, cx| cx.notify()), + cx.observe_global::(|this: &mut Viewer, cx| this.follow_reveal(cx)), + ], + row, + missing, + hovered: None, + stepped: None, + revealing: None, + draw: None, + selected: None, + grab: None, + undo: Vec::new(), + deleting: None, + composer: None, + hint: None, transport: Transport::new(path.clone(), cx), path, details, @@ -163,7 +209,7 @@ pub(crate) fn build( turns: 0, shown: 0, pixels, - header: pixels, + header: pixels.filter(|_| !missing), scale: 1.0, frame: Rc::default(), focus_handle: cx.focus_handle(), @@ -263,6 +309,26 @@ pub(crate) fn previewable(delegate: &table::QrateTableDelegate, row: usize) -> O .map(|file| file.to_path_buf()) } +/// What the viewer opens for `row`: its file, or, when that is missing and the row has regions, +/// where the file should be, so they are drawn on a stand-in page. +pub(crate) fn shown_file( + delegate: &table::QrateTableDelegate, + row: usize, + cx: &App, +) -> Option { + previewable(delegate, row).or_else(|| { + annotate::marks(delegate.row_id(row)?, cx).first()?; + let (_, name) = table::file_links::missing_file(delegate, row, cx)?; + let folder = cx + .try_global::()? + .data + .values + .get(settings::project::FILES_FOLDER_KEY)? + .text(); + Some(PathBuf::from(folder.as_ref()).join(name.trim())) + }) +} + /// The nearest view index past `from` in `delta`'s direction that `viewable` accepts, if any. fn next_row( from: usize, @@ -307,6 +373,34 @@ pub fn close_viewer(window: &mut Window, cx: &mut App) { pub struct Viewer { pub(crate) path: PathBuf, + /// The item the file belongs to, whose region notes are drawn over it. `None` for a file + /// opened on its own. + pub(crate) row: Option, + /// The file is gone but the row has regions, drawn on a stand-in page of their stored size. + pub(crate) missing: bool, + /// The region under the pointer. + hovered: Option, + /// The region `[` and `]` last zoomed to, whose card stays up until the page changes. + stepped: Option, + /// A new region being dragged out in annotate mode. + draw: Option, + /// The region the Select tool picked, and the move or resize under way on it. + selected: Option, + grab: Option, + /// What undo puts back, oldest first: a region as it was before a move or resize, or `None` + /// for one this viewer added. + undo: Vec<(diagnostics::NoteId, Option)>, + /// The region whose deletion is waiting to be confirmed. + deleting: Option, + /// The note being written for a region just marked. + composer: Option, + /// A passing word at the foot of the stage, gone when its timer fires. + hint: Option<(SharedString, Task<()>)>, + /// Repaints when a note, the annotation toggles, or the lit Notes card change, and turns to a + /// revealed region. + _notes: [Subscription; 4], + /// The region to zoom to once the frame has a size. + revealing: Option, /// File type and size, read once when the viewer opens rather than statting on every repaint. details: Option, scope: Scope, @@ -333,8 +427,8 @@ pub struct Viewer { zoom: f32, /// Pan translation from the centered position. offset: Point, - /// Last pointer position while dragging; `None` when not panning. - drag_from: Option>, + /// Button and last pointer position while panning. + drag_from: Option<(MouseButton, Point)>, /// Quarter turns clockwise, for a scan that was fed in sideways. A view, never saved. turns: u8, /// The turn on screen: the last one decoded, kept up while `turns` decodes so a turn never @@ -444,6 +538,9 @@ impl Viewer { /// The zoom at which one pixel of the image is one pixel of the screen. fn actual_size(&self) -> Option { + if self.missing { + return None; + } self.fit().map(|(_, fit)| 1.0 / (fit * self.scale)) } @@ -516,6 +613,7 @@ impl Viewer { /// lands on a page directly rather than by stepping to it, and must reset the same things. fn show_page(&mut self, page: usize) { self.page = page; + self.stepped = None; self.fit_view(); // A new page has no old turn decoded to keep up. self.shown = self.turns; @@ -574,7 +672,11 @@ impl Viewer { /// Whether the bottom pill has anything to hold: page controls, a transport or a scrubber. pub(crate) fn has_controls(&self) -> bool { - self.document || self.pages > 1 || self.transport.is_some() || self.scrubber.is_some() + !self.missing + && (self.document + || self.pages > 1 + || self.transport.is_some() + || self.scrubber.is_some()) } /// Put the find panel away and hand the keys back to the page. @@ -703,6 +805,15 @@ impl Render for Viewer { self.focused = true; } self.scale = window.scale_factor(); + if let Some(region) = self.revealing { + match self.fit() { + Some(_) => { + self.revealing = None; + self.zoom_to(®ion); + } + None => cx.on_next_frame(window, |_, _, cx| cx.notify()), + } + } let (zoom, offset, page, pages) = (self.zoom, self.offset, self.page, self.pages); let name: SharedString = self .path @@ -724,7 +835,7 @@ impl Render for Viewer { let pill = cx.theme().background.opacity(0.8); let accent = cx.theme().primary.opacity(0.55); // A hit's box is measured on the upright page, so a turned page shows none. - let marks: Vec = match self.turns { + let hits: Vec = match self.turns { 0 => self.find.on_page(page).cloned().collect(), _ => Vec::new(), }; @@ -745,10 +856,15 @@ impl Render for Viewer { false => preview::source(&self.path, cap, page, self.shown), }; // gpui on Windows has no grab cursors and falls back to the arrow; the hand is its nearest. + let marking = self.annotating(cx) + && cx.try_global::().map(|a| a.tool) + != Some(annotate::Tool::Select); let cursor = match (self.drag_from.is_some(), self.slack() != Point::default()) { + (true, _) if cfg!(windows) => CursorStyle::PointingHand, + (true, _) => CursorStyle::ClosedHand, + _ if marking => CursorStyle::Crosshair, (false, false) => CursorStyle::Arrow, _ if cfg!(windows) => CursorStyle::PointingHand, - (true, _) => CursorStyle::ClosedHand, (false, true) => CursorStyle::OpenHand, }; let readout = match self.actual_size() { @@ -767,6 +883,24 @@ impl Render for Viewer { (self.has_controls() && self.transport.is_none() && self.scrubber.is_none()) .then(|| self.page_input(window, cx)); let paged = self.paged(); + let marks = self.row_marks(cx); + let layer = annotate::layer(self, &marks, cx); + let stand_in = self.missing.then(|| self.page_box()); + let changed = self.header.and_then(|header| { + let stale = marks.iter().find(|m| !m.region.fits(header))?; + Some((header, stale.region.of?)) + }); + let tools = annotate::tools(self, cx); + let hint = self + .hint + .as_ref() + .map(|(hint, _)| hint.clone()) + .or_else(|| self.standing_hint(&marks, cx)); + let marking = annotate::buttons(self, cx); + let (marking, popped_marking) = match self.scope == Scope::PopOut { + true => (None, Some(marking)), + false => (Some(marking), None), + }; let strip_width = match paged && self.strip_open { true => STRIP, false => px(0.), @@ -775,6 +909,12 @@ impl Render for Viewer { // ponytail: they share PDFium's one lock with the page itself, so a jump can wait behind a // screenful of thumbnails; render the page first if that shows up. let strip = (strip_width > px(0.)).then(|| { + let mut counts = vec![0usize; pages]; + for mark in &marks { + if let Some(count) = counts.get_mut(mark.region.page as usize) { + *count += 1; + } + } let (primary, muted, radius, tile) = ( cx.theme().primary, cx.theme().muted_foreground, @@ -815,6 +955,7 @@ impl Render for Viewer { preview::decoded(&source, window, cx).flatten(); let frame = strip_frame(shape); div() + .relative() .w(frame.width) .h(frame.height) .flex_none() @@ -827,6 +968,28 @@ impl Render for Viewer { .overflow_hidden() .bg(tile) .child(img(source).size_full().rounded(radius)) + .when(counts[index] > 0, |thumb| { + thumb.child( + div() + .absolute() + .top_1() + .right_1() + .h(px(16.)) + .min_w(px(16.)) + .px_1() + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(white()) + .bg(black().opacity(0.9)) + .text_color(white()) + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .child(counts[index].to_string()), + ) + }) }) .child( div() @@ -856,7 +1019,8 @@ impl Render for Viewer { // The find panel is the inner layer, so Escape dismisses it before the viewer. // The pop-out has no overlay to close: its window is what the viewer is. .on_action(cx.listener(|this, _: &CloseViewerLayer, window, cx| { - if this.find_open { + if this.cancel(window, cx) || this.exit_annotate(window, cx) { + } else if this.find_open { this.close_find(window, cx); } else if this.scope != Scope::PopOut { close_viewer(window, cx); @@ -931,6 +1095,12 @@ impl Render for Viewer { this.rotate(if ev.keystroke.modifiers.shift { -1 } else { 1 }); cx.notify(); } + "z" if reading && ev.keystroke.modifiers.secondary() => this.undo_mark(cx), + "n" if reading && this.row.is_some() => this.toggle_hidden(cx), + "a" if reading => this.toggle_annotate(window, cx), + "[" if reading => this.step_mark(-1, cx), + "]" if reading => this.step_mark(1, cx), + "delete" | "backspace" if reading => this.ask_delete(cx), "up" | "down" if reading && overlay => { step_row(if ev.keystroke.key == "up" { -1 } else { 1 }, cx); } @@ -946,8 +1116,14 @@ impl Render for Viewer { resizable_panel().child( // Padding is the only sizing input; pan/zoom stay off the find panel. div() + .id("viewer-stage") .size_full() .p_8() + .on_hover(cx.listener(|this, over: &bool, _, cx| { + if !over && this.hovered.take().is_some() { + cx.notify(); + } + })) .on_scroll_wheel(cx.listener( |this, ev: &ScrollWheelEvent, _, cx| { // Any scroll zooms — trackpad pixel deltas and wheel line @@ -965,27 +1141,50 @@ impl Render for Viewer { .on_mouse_down( MouseButton::Left, cx.listener(|this, ev: &MouseDownEvent, _, cx| { + if matches!(this.drag_from, Some((MouseButton::Middle, _))) { + return; + } + if this.press(ev.position, cx) { + return; + } if ev.click_count == 2 { let anchor = ev.position - this.frame.get().center(); this.toggle_zoom(anchor); } - this.drag_from = Some(ev.position); + this.drag_from = Some((MouseButton::Left, ev.position)); + cx.notify(); + }), + ) + .on_mouse_down( + MouseButton::Middle, + cx.listener(|this, ev: &MouseDownEvent, _, cx| { + this.draw = None; + this.grab = None; + this.drag_from = Some((MouseButton::Middle, ev.position)); cx.notify(); }), ) .on_mouse_move(cx.listener(|this, ev: &MouseMoveEvent, _, cx| { - let Some(last) = this.drag_from else { + if this.drag_from.is_none() && this.drag_to(ev.position, cx) { + return; + } + let Some((button, last)) = this.drag_from else { + this.hover(ev.position, cx); return; }; this.offset.x += ev.position.x - last.x; this.offset.y += ev.position.y - last.y; this.clamp_pan(); - this.drag_from = Some(ev.position); + this.drag_from = Some((button, ev.position)); cx.notify(); })) .on_mouse_up( MouseButton::Left, - cx.listener(|this, _: &MouseUpEvent, _, cx| { + cx.listener(|this, _: &MouseUpEvent, window, cx| { + if matches!(this.drag_from, Some((MouseButton::Middle, _))) { + return; + } + this.release(window, cx); this.drag_from = None; cx.notify(); }), @@ -993,11 +1192,33 @@ impl Render for Viewer { // Released over a panel or outside the window, the drag still ends. .on_mouse_up_out( MouseButton::Left, - cx.listener(|this, _: &MouseUpEvent, _, cx| { + cx.listener(|this, _: &MouseUpEvent, window, cx| { + if matches!(this.drag_from, Some((MouseButton::Middle, _))) { + return; + } + this.release(window, cx); this.drag_from = None; cx.notify(); }), ) + .on_mouse_up( + MouseButton::Middle, + cx.listener(|this, _: &MouseUpEvent, _, cx| { + if matches!(this.drag_from, Some((MouseButton::Middle, _))) { + this.drag_from = None; + cx.notify(); + } + }), + ) + .on_mouse_up_out( + MouseButton::Middle, + cx.listener(|this, _: &MouseUpEvent, _, cx| { + if matches!(this.drag_from, Some((MouseButton::Middle, _))) { + this.drag_from = None; + cx.notify(); + } + }), + ) .child( // The positioned content box. The overlay measures *this* // element, so the highlight maths never has to know about the @@ -1016,7 +1237,7 @@ impl Render for Viewer { .left_0() .size_full() }) - .children((!bare).then(|| { + .children((!bare && !self.missing).then(|| { // `flex_shrink_0` keeps `relative(zoom)` past 1. // The id is what lets gpui keep a GIF's frame clock. img(picture) @@ -1029,12 +1250,24 @@ impl Render for Viewer { .top(offset.y) .object_fit(ObjectFit::Contain) })) + .children(stand_in.map(|page| { + div() + .absolute() + .left(page.origin.x) + .top(page.origin.y) + .w(page.size.width) + .h(page.size.height) + .border_2() + .border_dashed() + .border_color(white().opacity(0.45)) + })) // Hit boxes over the page, for the hits that are on it. - .when(!marks.is_empty(), |area| { + .when(!hits.is_empty(), |area| { area.child(highlight::overlay( - marks, zoom, offset, accent, + hits, zoom, offset, accent, )) - }), + }) + .child(layer), ), ), ) @@ -1078,6 +1311,107 @@ impl Render for Viewer { .justify_center() .child(div().max_w(px(560.)).occlude().child(banner)) })) + .when(self.missing, |viewer| { + let count = marks.len(); + let row = self.location(cx).and_then(|location| location.row); + viewer.child( + div() + .absolute() + .top_0() + .left_0() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .items_center() + .gap_2() + .max_w(px(340.)) + .px_5() + .py_4() + .rounded(px(8.)) + .bg(pill) + .text_center() + .occlude() + .child( + Icon::empty() + .path("icons/file-x.svg") + .size(px(40.)) + .text_color(cx.theme().warning), + ) + .child("File not found") + .child( + div() + .text_xs() + .font_family("monospace") + .text_color(cx.theme().muted_foreground) + .child(self.path.display().to_string()), + ) + .child( + div() + .text_size(px(13.)) + .text_color(cx.theme().muted_foreground) + .child(format!( + "{count} {} kept with this item. They line up again once the file is found.", + match count { + 1 => "annotation is", + _ => "annotations are", + } + )), + ) + .children(row.map(|row| { + Button::new("locate-file") + .label("Locate file…") + .small() + .on_click(move |_, window, cx| { + table::TablePanelHandle::update(cx, |table, cx| { + table.locate_file(row, window, cx) + }) + }) + })), + ), + ) + }) + .children(changed.map(|((width, height), (was_width, was_height))| { + div() + .absolute() + .top(px(68.)) + .left_0() + .right_0() + .flex() + .justify_center() + .child( + h_flex() + .gap_2() + .max_w(px(520.)) + .px_3() + .py_1p5() + .rounded(px(6.)) + .bg(cx.theme().popover) + .text_color(cx.theme().popover_foreground) + .border_1() + .border_color(cx.theme().border) + .shadow_md() + .text_size(px(13.)) + .occlude() + .child( + Icon::new(IconName::TriangleAlert) + .small() + .text_color(cx.theme().warning), + ) + .child("This image changed since it was annotated. Regions may not line up.") + .child( + div() + .text_xs() + .whitespace_nowrap() + .text_color(cx.theme().muted_foreground) + .child(format!( + "{was_width} × {was_height} → {width} × {height}" + )), + ), + ) + })) // Bottom pill: page controls (even for 1 page, or a TIFF stack), transport or scrubber. .when( self.has_controls(), @@ -1275,6 +1609,9 @@ impl Render for Viewer { .p_1() .rounded(cx.theme().radius) .bg(pill) + .occlude() + .children(marking) + .child(div().w_px().h(px(16.)).mx_0p5().bg(cx.theme().border)) // Only for a format that can carry text at all — offered on a photo it would // open a panel that can never find anything. .when(self.document, |group| { @@ -1357,6 +1694,48 @@ impl Render for Viewer { .on_click(cx.listener(|_, _, window, cx| close_viewer(window, cx))), ) })) + .children(popped_marking.map(|marking| { + div() + .absolute() + .top_4() + .right_4() + .p_1() + .rounded(cx.theme().radius) + .bg(pill) + .occlude() + .child(marking) + })) + .children(tools.map(|tools| { + div() + .absolute() + .left(px(16.) + strip_width) + .top_0() + .bottom_0() + .flex() + .items_center() + .child(tools) + })) + .children(hint.map(|hint| { + div() + .absolute() + .left_0() + .right_0() + .map(|slot| match self.has_controls() { + true => slot.bottom(px(64.)), + false => slot.bottom_5(), + }) + .flex() + .justify_center() + .child( + div() + .px_3() + .py_1() + .rounded(px(6.)) + .bg(pill) + .text_size(px(13.)) + .child(hint), + ) + })) } } @@ -1401,12 +1780,12 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-scope-test.jpg"); cx.update(|window, cx| { - open_viewer(path.clone(), Scope::Centre, window, cx); + open_viewer(path.clone(), None, Scope::Centre, window, cx); assert!(viewer_in(Scope::Centre, cx).is_some()); assert!(viewer_in(Scope::Workspace, cx).is_none()); // Opening in the other scope replaces rather than stacks. - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); assert!(viewer_in(Scope::Workspace, cx).is_some()); assert!(viewer_in(Scope::Centre, cx).is_none()); @@ -1424,11 +1803,18 @@ mod tests { cx.update(|window, cx| { open_viewer( "/nonexistent/overlay.jpg".into(), + None, Scope::Workspace, window, cx, ); - let popped = build("/nonexistent/popped.jpg".into(), Scope::PopOut, window, cx); + let popped = build( + "/nonexistent/popped.jpg".into(), + None, + Scope::PopOut, + window, + cx, + ); let overlay = viewer_in(Scope::Workspace, cx).expect("the overlay is still open"); assert_ne!(overlay.entity_id(), popped.entity_id()); assert!( @@ -1453,6 +1839,7 @@ mod tests { caller.focus(window, cx); open_viewer( "/nonexistent/qrate-focus-test.jpg".into(), + None, Scope::Workspace, window, cx, @@ -1474,6 +1861,7 @@ mod tests { cx.update(|window, cx| { open_viewer( "/nonexistent/qrate-reentrant-close-test.jpg".into(), + None, Scope::Workspace, window, cx, @@ -1492,7 +1880,7 @@ mod tests { let path = std::env::temp_dir().join("qrate-viewer-details.jpg"); std::fs::write(&path, b"abc").unwrap(); cx.update(|window, cx| { - open_viewer(path.clone(), Scope::Workspace, window, cx); + open_viewer(path.clone(), None, Scope::Workspace, window, cx); std::fs::remove_file(&path).unwrap(); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); assert!(viewer.read(cx).details.is_some()); @@ -1507,7 +1895,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-zoom-test.png"); cx.update(|window, cx| { - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { let cursor = gpui::point(gpui::px(120.), gpui::px(-40.)); @@ -1531,7 +1919,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-pan-test.png"); cx.update(|window, cx| { - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { // A 2000×1000 picture in a 1000×500 frame: fitted at half scale. @@ -1593,7 +1981,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-fit-width.pdf"); cx.update(|window, cx| { - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { viewer.frame.set(gpui::Bounds::new( @@ -1629,7 +2017,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-tiny.png"); cx.update(|window, cx| { - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { viewer.pixels = Some((32, 32)); @@ -1707,7 +2095,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-paging-test.pdf"); cx.update(|window, cx| { - open_viewer(path, Scope::Workspace, window, cx); + open_viewer(path, None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); viewer.update(cx, |viewer, _| { @@ -1747,7 +2135,7 @@ mod tests { let cx = with_window(cx); let path = std::path::PathBuf::from("/nonexistent/qrate-single-page.jpg"); cx.update(|window, cx| { - open_viewer(path, Scope::Centre, window, cx); + open_viewer(path, None, Scope::Centre, window, cx); let viewer = viewer_in(Scope::Centre, cx).expect("just opened"); viewer.update(cx, |viewer, _| { assert_eq!( @@ -1769,19 +2157,37 @@ mod tests { fn only_a_recording_gets_a_transport(cx: &mut TestAppContext) { let cx = with_window(cx); cx.update(|window, cx| { - open_viewer("/nonexistent/take.wav".into(), Scope::Workspace, window, cx); + open_viewer( + "/nonexistent/take.wav".into(), + None, + Scope::Workspace, + window, + cx, + ); let recording = viewer_in(Scope::Workspace, cx).expect("just opened"); recording.update(cx, |viewer, _| { assert!(viewer.transport.is_some(), "a WAV is a recording"); assert!(!viewer.document); }); - open_viewer("/nonexistent/scan.pdf".into(), Scope::Workspace, window, cx); + open_viewer( + "/nonexistent/scan.pdf".into(), + None, + Scope::Workspace, + window, + cx, + ); let document = viewer_in(Scope::Workspace, cx).expect("just opened"); document.update(cx, |viewer, _| assert!(viewer.transport.is_none())); // A video's positions are seconds, which are the scrubber's and not a page strip's. - open_viewer("/nonexistent/clip.mp4".into(), Scope::Workspace, window, cx); + open_viewer( + "/nonexistent/clip.mp4".into(), + None, + Scope::Workspace, + window, + cx, + ); let video = viewer_in(Scope::Workspace, cx).expect("just opened"); video.update(cx, |viewer, _| { viewer.pages = 6; @@ -1807,7 +2213,7 @@ mod tests { std::fs::write(&path, &wav).unwrap(); cx.update(|window, cx| { - open_viewer(path.clone(), Scope::Workspace, window, cx); + open_viewer(path.clone(), None, Scope::Workspace, window, cx); let viewer = viewer_in(Scope::Workspace, cx).expect("just opened"); preview::playback::play(&path, viewer.entity_id(), cx); close_viewer(window, cx); @@ -1843,7 +2249,7 @@ mod tests { // An emitted event is delivered when the update flushes, so each half is its own update. let viewer = cx.update(|window, cx| { - open_viewer(path.clone(), Scope::Workspace, window, cx); + open_viewer(path.clone(), None, Scope::Workspace, window, cx); viewer_in(Scope::Workspace, cx).expect("just opened") }); cx.executor().advance_clock(super::SETTLE); @@ -1887,6 +2293,7 @@ mod tests { cx.update(|window, cx| { open_viewer( "/nonexistent/one-page.pdf".into(), + None, Scope::Workspace, window, cx, @@ -1897,7 +2304,13 @@ mod tests { assert!(viewer.document, "a PDF is a document at any length"); }); - open_viewer("/nonexistent/scan.jpg".into(), Scope::Workspace, window, cx); + open_viewer( + "/nonexistent/scan.jpg".into(), + None, + Scope::Workspace, + window, + cx, + ); let photo = viewer_in(Scope::Workspace, cx).expect("just opened"); photo.update(cx, |viewer, _| assert!(!viewer.document)); diff --git a/crates/workspace/src/viewer/regions.rs b/crates/workspace/src/viewer/regions.rs new file mode 100644 index 00000000..5b6f2f63 --- /dev/null +++ b/crates/workspace/src/viewer/regions.rs @@ -0,0 +1,371 @@ +//! Where a row's region notes sit on screen, and which region a drag on screen marks. +//! +//! A [`Region`] lives on the upright page in [`Region::SCALE`]ths, and the viewer may show that +//! page turned. Everything here works in fractions of the page *as shown*, converting at the edges, +//! and measures the page with the same letterbox a search hit uses. No gpui context, so it tests. + +use diagnostics::Region; +use gpui::{Bounds, Pixels, Point, point, px, size}; + +use crate::viewer::highlight::drawn; + +/// Left, top, right, bottom in fractions of the page as shown. Equal edges are a pin. +pub type Rect = [f32; 4]; + +/// How near a pin's centre the pointer has to be: half the dot that is drawn. +pub const PIN_RADIUS: f32 = 9.; +/// A badge's height, and the side of a resize handle. +pub const BADGE: f32 = 16.; +pub const HANDLE: f32 = 8.; +/// The smallest box a resize leaves, so a region can never be dragged down to a pin. +const SMALLEST: f32 = 0.005; + +fn turn((x, y): (f32, f32), turns: u8) -> (f32, f32) { + (0..turns % 4).fold((x, y), |(x, y), _| (1. - y, x)) +} + +pub fn span(a: (f32, f32), b: (f32, f32)) -> Rect { + [a.0.min(b.0), a.1.min(b.1), a.0.max(b.0), a.1.max(b.1)] +} + +/// Where `region` sits on its page turned `turns` quarter turns clockwise. +pub fn shown(region: &Region, turns: u8) -> Rect { + let scale = f32::from(Region::SCALE); + let a = (f32::from(region.x) / scale, f32::from(region.y) / scale); + let b = ( + a.0 + f32::from(region.w) / scale, + a.1 + f32::from(region.h) / scale, + ); + span(turn(a, turns), turn(b, turns)) +} + +/// The region to store for `rect`, marked on `page` while it was shown at `turns`. +pub fn upright(rect: Rect, turns: u8, page: u32, of: Option<(u32, u32)>) -> Region { + let back = (4 - turns % 4) % 4; + let [l, t, r, b] = span( + turn((rect[0], rect[1]), back), + turn((rect[2], rect[3]), back), + ); + let q = |v: f32| (v.clamp(0., 1.) * f32::from(Region::SCALE)).round() as u16; + Region { + page, + x: q(l), + y: q(t), + w: q(r) - q(l), + h: q(b) - q(t), + of, + } +} + +/// The page as drawn in `area`, the box every region and pointer position is measured against. +pub fn page(area: Bounds, zoom: f32, offset: Point, aspect: f32) -> Bounds { + let (size, origin) = drawn(area, zoom, offset, aspect); + Bounds { origin, size } +} + +/// `rect` over `page` on screen. +pub fn on_screen(page: Bounds, rect: Rect) -> Bounds { + let (width, height) = (page.size.width, page.size.height); + Bounds { + origin: point( + page.origin.x + width * rect[0], + page.origin.y + height * rect[1], + ), + size: size(width * (rect[2] - rect[0]), height * (rect[3] - rect[1])), + } +} + +/// The point of `page` under `position`, clamped onto the page. +pub fn at(page: Bounds, position: Point) -> (f32, f32) { + let along = |p: Pixels, from: Pixels, length: Pixels| match length > px(0.) { + true => (f32::from(p - from) / f32::from(length)).clamp(0., 1.), + false => 0., + }; + ( + along(position.x, page.origin.x, page.size.width), + along(position.y, page.origin.y, page.size.height), + ) +} + +/// The region under `position`: a pin before any box, then the smallest box, so a detail marked +/// inside a larger region stays reachable. +pub fn hit(boxes: &[(T, Bounds)], position: Point) -> Option { + let pin = boxes.iter().find(|(_, b)| { + b.size.width == px(0.) + && b.size.height == px(0.) + && f32::from((b.origin - position).x).hypot(f32::from((b.origin - position).y)) + <= PIN_RADIUS + }); + pin.or_else(|| { + boxes + .iter() + .filter(|(_, b)| b.size.width > px(0.) && b.contains(&position)) + .min_by(|(_, a), (_, b)| { + let area = |b: &Bounds| f32::from(b.size.width) * f32::from(b.size.height); + area(a).total_cmp(&area(b)) + }) + }) + .map(|(id, _)| *id) +} + +/// One badge or merged pill: where it sits, how wide it is, what it reads and whose it is. +#[derive(Clone, Debug, PartialEq)] +pub struct Badge { + pub at: Point, + pub width: Pixels, + pub label: String, + pub members: Vec, +} + +fn badge_width(label: &str) -> Pixels { + px((label.len() as f32 * 6. + 8.).max(BADGE)) +} + +/// A badge at each box's top-left corner, given in number order. One that would cover a badge +/// already placed slides right along its own top edge; out of edge, it joins that badge's pill. +pub fn badges(boxes: &[(T, usize, Bounds)]) -> Vec> { + let mut placed: Vec> = Vec::new(); + for &(id, number, b) in boxes { + let label = number.to_string(); + let width = badge_width(&label); + let y = b.origin.y; + let mut x = b.origin.x; + loop { + let covers = |other: &Badge| { + x < other.at.x + other.width + && other.at.x < x + width + && f32::from(y - other.at.y).abs() < BADGE + }; + let Some(ix) = placed.iter().position(covers) else { + placed.push(Badge { + at: point(x, y), + width, + label, + members: vec![id], + }); + break; + }; + let next = placed[ix].at.x + placed[ix].width + px(1.); + if next + width <= b.origin.x + b.size.width { + x = next; + continue; + } + let pill = &mut placed[ix]; + pill.label = format!("{} {label}", pill.label); + pill.width = badge_width(&pill.label); + pill.members.push(id); + break; + } + } + placed +} + +/// What a drag on a selected region takes hold of: the whole box, or one of its eight handles. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Grip { + Move, + N, + NE, + E, + SE, + S, + SW, + W, + NW, +} + +impl Grip { + pub const HANDLES: [Grip; 8] = [ + Grip::NW, + Grip::N, + Grip::NE, + Grip::E, + Grip::SE, + Grip::S, + Grip::SW, + Grip::W, + ]; + + /// Where the handle sits along the box's width and height. + pub fn anchor(self) -> (f32, f32) { + match self { + Grip::Move => (0.5, 0.5), + Grip::N => (0.5, 0.), + Grip::NE => (1., 0.), + Grip::E => (1., 0.5), + Grip::SE => (1., 1.), + Grip::S => (0.5, 1.), + Grip::SW => (0., 1.), + Grip::W => (0., 0.5), + Grip::NW => (0., 0.), + } + } + + /// The handle of `rect` under `position`, if any. Pins have none: they only move. + pub fn under(rect: Bounds, position: Point) -> Option { + if rect.size.width == px(0.) { + return None; + } + Grip::HANDLES.into_iter().find(|grip| { + let (u, v) = grip.anchor(); + let centre = point( + rect.origin.x + rect.size.width * u, + rect.origin.y + rect.size.height * v, + ); + f32::from(position.x - centre.x).abs() <= HANDLE / 2. + 2. + && f32::from(position.y - centre.y).abs() <= HANDLE / 2. + 2. + }) + } +} + +/// `rect` after `grip` was dragged by `delta` fractions of the page. A move keeps its size and +/// stops at the page's edges; a handle stops there too, and never makes a box smaller than a pin. +pub fn drag(rect: Rect, grip: Grip, delta: (f32, f32)) -> Rect { + let [l, t, r, b] = rect; + if grip == Grip::Move { + let dx = delta.0.clamp(-l, 1. - r); + let dy = delta.1.clamp(-t, 1. - b); + return [l + dx, t + dy, r + dx, b + dy]; + } + let (u, v) = grip.anchor(); + let edge = |near: f32, far: f32, along: f32, d: f32| match along { + 0. => ((near + d).clamp(0., (far - SMALLEST).max(0.)), far), + 1. => (near, (far + d).clamp((near + SMALLEST).min(1.), 1.)), + _ => (near, far), + }; + let (l, r) = edge(l, r, u, delta.0); + let (t, b) = edge(t, b, v, delta.1); + [l, t, r, b] +} + +#[cfg(test)] +mod tests { + use diagnostics::Region; + use gpui::{Bounds, point, px, size}; + + use crate::viewer::regions::{Grip, badges, drag, hit, on_screen, page, shown, upright}; + + fn region(x: u16, y: u16, w: u16, h: u16) -> Region { + Region { + page: 0, + x, + y, + w, + h, + of: None, + } + } + + fn square(x: f32, y: f32, w: f32, h: f32) -> Bounds { + Bounds { + origin: point(px(x), px(y)), + size: size(px(w), px(h)), + } + } + + /// A region drawn on a turned page is stored upright and comes back where it was drawn, at + /// every turn: a note must not wander when the archivist presses R. + #[test] + fn a_region_survives_every_turn() { + let stamp = region(7000, 7000, 2000, 1700); + for turns in 0..4 { + let on_screen = shown(&stamp, turns); + assert_eq!( + upright(on_screen, turns, 0, None), + stamp, + "at {turns} turns" + ); + } + // A quarter turn clockwise takes the bottom-right stamp to the bottom-left. + let [l, t, r, b] = shown(&stamp, 1); + assert!((l - 0.13).abs() < 1e-4 && (t - 0.7).abs() < 1e-4); + assert!((r - 0.3).abs() < 1e-4 && (b - 0.9).abs() < 1e-4); + } + + /// A pin turns as a point and stays a pin. + #[test] + fn a_pin_turns_as_a_point() { + let pin = region(800, 9000, 0, 0); + let [l, t, r, b] = shown(&pin, 1); + assert_eq!((l, t), (r, b)); + assert!((l - 0.1).abs() < 1e-4 && (t - 0.08).abs() < 1e-4); + assert_eq!(upright([l, t, r, b], 1, 0, None), pin); + } + + /// The region sits on the page as drawn, letterbox and all, not on the area around it. + #[test] + fn a_region_is_placed_on_the_letterboxed_page() { + let area = square(0., 0., 400., 200.); + let drawn = page(area, 1.0, point(px(0.), px(0.)), 1.0); + assert_eq!(drawn, square(100., 0., 200., 200.)); + let on = on_screen(drawn, shown(®ion(5000, 0, 2500, 5000), 0)); + assert_eq!(on, square(200., 0., 50., 100.)); + } + + /// Hover goes to a pin first, then to the smallest box: a face inside a group stays + /// reachable even though the group's box covers it. + #[test] + fn the_smallest_region_under_the_pointer_wins() { + let boxes = [ + (1, square(0., 0., 300., 200.)), + (2, square(50., 50., 40., 40.)), + (3, square(60., 60., 0., 0.)), + ]; + assert_eq!(hit(&boxes, point(px(70.), px(80.))), Some(2)); + assert_eq!(hit(&boxes, point(px(62.), px(58.))), Some(3)); + assert_eq!(hit(&boxes, point(px(200.), px(150.))), Some(1)); + assert_eq!(hit(&boxes, point(px(320.), px(150.))), None); + } + + /// A badge that would cover another slides along its own top edge; with no edge left it joins + /// the other's pill rather than stacking on top of it. + #[test] + fn crowded_badges_slide_then_merge() { + let placed = badges(&[ + (5, 5, square(100., 50., 30., 40.)), + (6, 6, square(104., 52., 60., 40.)), + (7, 7, square(108., 54., 20., 40.)), + ]); + assert_eq!(placed.len(), 2); + assert_eq!(placed[1].at.x, px(117.), "#6 slid right of #5"); + assert_eq!( + placed[0].label, "5 7", + "#7 had no room to slide, so it joined #5" + ); + assert_eq!(placed[0].members, vec![5, 7]); + } + + /// A move stops at the page's edge without squashing the box; a handle stops there too and + /// cannot turn the box inside out. + #[test] + fn dragging_keeps_the_box_on_the_page() { + let rect = [0.1, 0.1, 0.3, 0.4]; + let moved = drag(rect, Grip::Move, (-0.5, 0.0)); + assert_eq!(moved[0], 0.0, "stopped at the left edge"); + assert!( + (moved[2] - moved[0] - 0.2).abs() < 1e-6, + "and kept its width" + ); + assert_eq!(drag(rect, Grip::E, (2.0, 0.0)), [0.1, 0.1, 1.0, 0.4]); + let [l, _, r, _] = drag(rect, Grip::W, (0.5, 0.0)); + assert!(r - l > 0.0, "the left edge stops short of the right"); + assert_eq!( + drag(rect, Grip::N, (0.3, 0.0)), + rect, + "N only moves the top" + ); + } + + #[test] + fn tiny_regions_at_page_edges_can_be_resized() { + for rect in [[0., 0., 0.001, 0.002], [0.999, 0.998, 1., 1.]] { + for grip in Grip::HANDLES { + for delta in [(-1., -1.), (0., 0.), (1., 1.)] { + let [l, t, r, b] = drag(rect, grip, delta); + assert!(0. <= l && l < r && r <= 1.); + assert!(0. <= t && t < b && b <= 1.); + } + } + } + } +} diff --git a/crates/workspace/src/views/gallery.rs b/crates/workspace/src/views/gallery.rs index 0152dc1b..23ef16a3 100644 --- a/crates/workspace/src/views/gallery.rs +++ b/crates/workspace/src/views/gallery.rs @@ -3,7 +3,7 @@ use gpui::*; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable as _, menu::ContextMenuExt as _, table::TableState, }; -use preview::{can_preview, thumb}; +use preview::thumb; use table::QrateTableDelegate; pub(super) const COLS_MIN: f32 = 2.; @@ -84,6 +84,7 @@ fn card( return div().w(px(card_w)).into_any_element(); }; let path = delegate.row_image(source).map(std::path::Path::to_path_buf); + let row_id = delegate.row_id(source); let caption = delegate .row_fields(source) .into_iter() @@ -92,10 +93,16 @@ fn card( .unwrap_or_default(); let selected = delegate.is_row_selected(source); let pages = path.as_deref().and_then(preview::known_pages).unwrap_or(1); - let notes = - diagnostics::Diagnostics::notes_in_row(diagnostics::DATASET_MAIN, source, cx).count(); + let (regions, notes) = + diagnostics::Diagnostics::notes_in_row(diagnostics::DATASET_MAIN, source, cx).fold( + (0, 0), + |(regions, notes), note| match note.note.as_ref().is_some_and(|n| n.region.is_some()) { + true => (regions + 1, notes), + false => (regions, notes + 1), + }, + ); - let viewable = path.clone().filter(|path| can_preview(path)); + let viewable = crate::viewer::shown_file(delegate, source, cx); let radius = cx.theme().radius; let badge = move |icon: IconName, count: usize| { @@ -151,6 +158,31 @@ fn card( .when(pages > 1, |frame| { frame.child(badge(IconName::Copy, pages).top_1()) }) + .when(regions > 0, |frame| { + frame.child( + div() + .absolute() + .left_1() + .bottom_1() + .flex() + .items_center() + .gap_1() + .h(px(16.)) + .px_1() + .rounded(px(4.)) + .bg(black().opacity(0.9)) + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(white()) + .child( + Icon::empty() + .path("icons/message-square.svg") + .xsmall() + .text_color(white()), + ) + .child(regions.to_string()), + ) + }) .when(notes > 0, |frame| { frame.child(badge(IconName::Menu, notes).bottom_1()) }), @@ -205,7 +237,13 @@ fn card( return; } if let Some(path) = viewable.clone() { - crate::viewer::open_viewer(path, crate::ViewerScope::Centre, window, cx); + crate::viewer::open_viewer( + path, + row_id, + crate::ViewerScope::Centre, + window, + cx, + ); } } }) diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 0ea9db36..3c5482f6 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -20,6 +20,7 @@ use gpui_component::{ dock::{BasePanel, DockArea, Panel, PanelEvent}, h_flex, input::Escape, + menu::{PopupMenu, PopupMenuItem}, slider::{Slider, SliderEvent, SliderState}, tab::{Tab, TabBar}, v_flex, @@ -364,6 +365,81 @@ impl BasePanel for ViewsPanel { } impl Panel for ViewsPanel { + fn tab_name(&self, _cx: &App) -> Option { + Some(self.view.label().into()) + } + + fn dropdown_menu( + &mut self, + menu: PopupMenu, + window: &mut Window, + cx: &mut Context, + ) -> PopupMenu { + let panel = cx.entity().downgrade(); + let views = PopupMenu::build(window, cx, |menu, _, _| { + ViewMode::ALL.into_iter().fold(menu, |menu, mode| { + let panel = panel.clone(); + menu.item( + PopupMenuItem::new(mode.label()) + .checked(self.view == mode) + .on_click(move |_, window, cx| { + if let Some(panel) = panel.upgrade() { + switch(&panel, mode, window, cx); + } + }), + ) + }) + }); + let menu = menu.item(PopupMenuItem::submenu("View", views)); + let table = self.table.downgrade(); + let menu = menu.item( + PopupMenuItem::new("Find in table") + .icon(IconName::Search) + .on_click(move |_, window, cx| { + table + .update(cx, |table, cx| table.toggle_search(window, cx)) + .ok(); + }), + ); + menu.when(self.view == ViewMode::Gallery, |menu| { + let selected = self.thumb.read(cx).value().start().round() as usize; + let thumb = self.thumb.clone(); + let density = PopupMenu::build(window, cx, |menu, _, _| { + (gallery::COLS_MIN as usize..=gallery::COLS_MAX as usize).fold( + menu, + |menu, count| { + let thumb = thumb.clone(); + menu.item( + PopupMenuItem::new(count.to_string()) + .checked(count == selected) + .on_click(move |_, window, cx| { + thumb.update(cx, |thumb, cx| { + thumb.set_value(count as f32, window, cx) + }); + let value = SharedString::from(count.to_string()); + if cx.has_global::() { + settings::project::CurrentProject::set_text( + gallery::COLUMNS_KEY, + value, + cx, + ); + } else { + settings::AppSettings::set_text( + gallery::COLUMNS_KEY, + value, + cx, + ); + } + cx.refresh_windows(); + }), + ) + }, + ) + }); + menu.item(PopupMenuItem::submenu("Cards per row", density)) + }) + } + /// The centre has no name worth showing, so the title cell carries the view switcher instead — /// which puts it at the far left of the same row as the Find button and the ⋯ menu. /// diff --git a/docs/dev/responsive-headers.md b/docs/dev/responsive-headers.md new file mode 100644 index 00000000..b24f8845 --- /dev/null +++ b/docs/dev/responsive-headers.md @@ -0,0 +1,79 @@ +# Responsive headers + +## Design + +A header has an ordered list of stages supplied by its owner. Each stage contains: + +1. The controls that remain visible inline. +2. An optional builder for standard dropdown items representing the hidden controls. +3. An optional measurement representation and truncation policy. + +`window-wrapper::responsive_header::ResponsiveHeader` handles measurement and switching. +`HeaderStage` describes each candidate. `ResponsiveHeader::new` keeps the default two stages: +the full header, followed by a compact title and dropdown. `from_stages` accepts a custom list. +Dropdowns accept menu items rather than the original header as popup content. + +## Panel policy + +Panels can implement `window-wrapper::panel_headers::PanelHeaders` and register their type +with `PanelHeaderRegistry::register`, using their `Panel::panel_name`. The dock skin passes +the default stages and a shared menu builder for tab selection, zoom, and close commands. +The panel supplies its stages and combines that builder with its own hidden controls. +The registry stores function pointers, not panel entities; content closures can use weak +panel references. Panels without a registered policy retain the default two stages. + +Problems supplies four stages, from widest to narrowest: + +1. Named severity tabs, source picker, and ignored toggle. +2. Named severity tabs and hamburger; source and ignored controls move into the menu. +3. Severity icons and counts with label tooltips; secondary controls stay in the menu. +4. Plain Problems title and hamburger; severity choices also move into the menu. + +Intermediate menus omit the severity choices that remain visible inline. All stages share +the same filter state and callbacks. + +## Layout + +- Measure each stage's intrinsic width, including controls inside the title slot and the + hamburger's width, gaps, and padding. Choose the first stage that fits. +- Stretch the visible header to its parent's full width so existing alignment stays intact. +- Collapse immediately when controls stop fitting. Require eight extra pixels to restore a + wider stage, preventing oscillation around a breakpoint. +- Use the final stage as the fallback. Truncate its title and keep the menu button fixed. +- Measurement probes have separate element IDs and a stateless box in place of the + hamburger. Only the selected stage creates a live dropdown and receives paint/input. + Content builders also run during measurement and must not mutate application state. +- Changing stages dismisses an open dropdown and restores the captured action focus. +- The main window measures its controls with a three-rem title placeholder. Long project and + author names truncate before the controls collapse. Native window buttons remain outside + this layout, and macOS retains its traffic-light padding. + +The dock skin applies the wrapper centrally. Existing panels and newly registered panels +receive the same collapse behavior. + +## Menu contract + +Panel owners expose their controls through `Panel::dropdown_menu`. Every inline control must +have a menu equivalent, using the same choices and current state. Use checked items for view +and filter choices, checkboxes for independent source choices, and disabled items for commands +that cannot run. Sliders use a submenu of discrete values when that matches their range. + +The dock skin adds tab selection, zoom, and close commands. Compact panel labels come from +`PanelMeta` or `Panel::tab_name`, so filter tabs do not reappear inside the compact title. + +The main titlebar reads the same owned app menus as `AppMenuBar`, preserving nested actions, +checks, and disabled states. Its other entries cover feedback, dock toggles, plugin commands, +and visible update controls. Plugin commands use the existing dispatcher. + +Use the component library's `DropdownMenu` and `PopupMenu`, including their keyboard and +dismissal behavior. The wrapper remembers the focus target before opening and passes it to +the menu builder; nested app menus inherit it so actions still reach the document. + +Occlusion belongs around the dropdown host. An occluding trigger blocks the host's own +mouse-down handler and prevents the menu from opening. + +## Review when adding a control + +Check its expanded and collapsed forms at narrow and wide widths, including selected and +disabled state, keyboard activation, Escape, action dispatch, and focus after dismissal. +Confirm that a resize restores the original alignment and keeps the window buttons visible. diff --git a/docs/files-and-photos.md b/docs/files-and-photos.md index 0bd317b0..14f7ea2f 100644 --- a/docs/files-and-photos.md +++ b/docs/files-and-photos.md @@ -151,6 +151,50 @@ A PDF or a multi-page TIFF shows its pages in the bar at the bottom of the viewe its top. - The search button, or **Ctrl+F**, opens a panel to search the document's text. +### Annotations + +An annotation is a note about part of a file: a box around a stamp, or a pin on a face. It +belongs to the row, like any other note, and is kept in the `.qrate` file. The file itself is +never changed. + +- Press **A**, or the annotate button at the top of the viewer, to turn annotate mode on. The + tools appear on the left edge: **Select**, **Rectangle**, and **Pin**. +- With **Rectangle**, drag to mark a box, or click to drop a pin. With **Pin**, click. +- Write the note in the card that opens beside it. You can mark it as a **Note**, a + **Transcription**, or a **Question**. Press **Ctrl+Enter**, or click **Save**. +- The first time you save without an author name set, qrate asks how to sign your notes. You + can skip this; the note is then saved unsigned, and qrate does not ask again. +- With **Select**, click an annotation to select it. Drag it to move it, or drag a handle to + resize it. **Delete** asks before it removes the annotation, and History keeps a copy you can + restore. +- **Ctrl+Z** takes back the last annotation you added, moved, or resized since you opened the + file. History keeps every step either way. +- Hold the middle mouse button and drag to pan while annotating. Release it to return to the + annotation tool. +- **Esc** cancels an unfinished note or drag, or drops the selection. Press it again to exit + annotate mode. + +Annotations are numbered in the order they were made. Point at one to read its note. Press +**N**, or the eye button, to hide them all and see the file underneath. **[** and **]** step to +the previous or next annotation on the page and zoom to it. + +On a PDF or a multi-page TIFF, an annotation belongs to its page. The page thumbnails show how +many annotations each page has. + +If a file is replaced with one of a different shape, the viewer says so: annotations stay where +their coordinates put them, and you can move or resize them by hand. An annotation you have +put back in place no longer counts as out of date, and the same picture at another resolution +never does. If the file is missing, +the viewer still draws the annotations on an outline of the page, with a **Locate file…** +button. You cannot add annotations until the file is found. + +The **Notes** section of the Details panel lists the selected items' notes, with a small crop +of the part each annotation marks. Point at a card to light its annotation in the viewer, and +click it to zoom the viewer to it. Hover a card to edit, delete, or show the history of its +note. Editing changes its words and its kind. Deleting asks first. The buttons above the list show **All** notes, only those **On image**, or only those +**On fields**, and **Mine** shows only the notes you signed. In the gallery, a badge in the +bottom-left corner of a tile counts its annotations. + ### The pop-out window The **Open in new window** button on the Details preview opens the file in a second window, diff --git a/docs/grid.md b/docs/grid.md index 0f38f6a5..680af87b 100644 --- a/docs/grid.md +++ b/docs/grid.md @@ -111,5 +111,12 @@ Right-click a cell and choose **Notes ▸ Add note…**, use **Insert ▸ Note Notes tab of the Problems panel. If you set an author name in **Settings ▸ Application**, each new note records it. +Point at a cell, a row number, or a column header that has a note to read it. The card shows the +whole note, who wrote it, and when; a note that has been reworded says "edited". The column's +description and any problems found in the cell follow under a line. + +A note can also point at part of the row's file. See +[Annotations](files-and-photos.md#annotations). + Excel exports keep notes as cell comments, and Google Sheets exports add them as cell notes. CSV, JSON-LD, CSL-JSON, and ZIP exports leave them out. diff --git a/docs/history.md b/docs/history.md index 13a6ef7d..3c71b2b8 100644 --- a/docs/history.md +++ b/docs/history.md @@ -43,6 +43,12 @@ The button in the panel's title bar filters the list: - **Fixes**, which includes spelling fixes - **Named versions**, which shows only the changes that you named +When a panel is too narrow for its header controls, open the **☰** menu to reach them. + +Changes to annotations appear as **Annotation added**, **moved**, **resized**, **edited**, and +**deleted**, with a crop of the part of the file they mark. A move or resize shows the crop +before and after. + ## One cell's history Right-click a cell in the grid and choose **Show edit history**. The History panel opens and @@ -73,11 +79,18 @@ Right-click a saved change for these commands: a new change in the history, so **Ctrl+Z** undoes it. - **Restore This Value** puts one cell back to the value it had before the change. This command shows only for a change to one cell. +- **Restore This Annotation** puts an annotation back the way it was before the change: its + place on the file, its words, and its kind. A deleted annotation comes back. The change that + added an annotation has nothing earlier to restore, so it does not show the command. For a + note on a cell or row, the command is **Restore This Note**. + +The **History** section of the Details panel has a restore button on each saved change to a +cell, a note, or an annotation. -Neither command deletes history. The changes that a restore reverses stay in the list. +None of these commands deletes history. The changes that a restore reverses stay in the list. ## Clear the history -Open the panel's menu and choose **Clear History…**. qrate asks you to confirm. After you -clear the history, you cannot restore the project to any point before that moment. Clearing -the history does not change your data. +Open the panel's menu and choose **Clear History…**. qrate asks you to confirm, then saves your +current edits before clearing the history. You cannot restore the project to any point before +that moment. Clearing the history does not change your data. diff --git a/docs/projects.md b/docs/projects.md index 667ecb7f..2cbacd68 100644 --- a/docs/projects.md +++ b/docs/projects.md @@ -80,6 +80,9 @@ settings, layout, and other project settings always save as you change them. Press **Ctrl+S**, or choose **File ▸ Save**, to save at any time, whatever the autosave setting. A dot in the title bar means the project has changes that are not saved yet. +In a narrow window, the title bar's menus and controls move into the **☰** menu on the left. +The project name and author shorten to fit; the window buttons stay visible. + If a save fails, for example because the drive is full or the file is read-only, qrate tells you and keeps your changes open. Autosave tries again after your next edit, and **Ctrl+S** tries at once and shows the reason if it fails again. diff --git a/docs/shortcuts.md b/docs/shortcuts.md index 83080e68..a4fc705b 100644 --- a/docs/shortcuts.md +++ b/docs/shortcuts.md @@ -66,7 +66,14 @@ These work in the fullscreen viewer and in the pop-out window. | **Ctrl+F** | Find in the document | | **Up** / **Down** | Previous / next row | | **Alt+Left** / **Alt+Right** | Previous / next selected item (pop-out window) | -| **Esc** | Close the fullscreen viewer, or go back to Details in the pop-out window | +| **A** | Turn annotate mode on or off | +| **Middle mouse drag** | Pan the viewer, including while annotating | +| **N** | Hide or show annotations | +| **[** / **]** | Previous / next annotation on the page | +| **Delete** | Delete the selected annotation (asks first) | +| **Ctrl+Z** | Take back the last annotation you added, moved, or resized since the file was opened | +| **Ctrl+Enter** | Save the note being written | +| **Esc** | Cancel an unfinished note or drag, or drop the selected annotation; then exit annotate mode, dismiss the find panel, and close the fullscreen viewer or return to Details in the pop-out window | See [The grid](grid.md) for what each command does, and [Projects](projects.md#saving) for how saving works. diff --git a/nix/devshell.nix b/nix/devshell.nix index 488d28b0..cdb29815 100644 --- a/nix/devshell.nix +++ b/nix/devshell.nix @@ -44,6 +44,9 @@ pkgs.mkShell { LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath runtimeDeps; shellHook = '' + # Link against the shell's libc and embed its matching runtime path. + export NIX_CFLAGS_LINK="-L${pkgs.glibc}/lib $NIX_CFLAGS_LINK" + export NIX_LDFLAGS="-L${pkgs.glibc}/lib $NIX_LDFLAGS" echo "qrate dev shell — cargo $(cargo --version | cut -d' ' -f2)" echo "PDF and video preview need ./scripts/fetch-binaries.sh" ''; diff --git a/scripts/build-store-shell.ps1 b/scripts/build-store-shell.ps1 index ad433d39..ce926902 100644 --- a/scripts/build-store-shell.ps1 +++ b/scripts/build-store-shell.ps1 @@ -9,14 +9,8 @@ $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer if (-not (Test-Path -LiteralPath $vswhere)) { throw 'Visual Studio vswhere.exe was not found.' } $installation = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath if (-not $installation) { throw 'The Visual Studio x64 C++ build tools are required for qrate-shell.dll.' } -$devcmd = Join-Path $installation 'Common7\Tools\VsDevCmd.bat' -$environment = & $env:ComSpec /d /s /c "`"`"$devcmd`" -no_logo -arch=x64 -host_arch=x64 >nul && set`"" -if ($LASTEXITCODE -ne 0) { throw 'Could not initialize the x64 Visual Studio environment.' } -foreach ($line in $environment) { - if ($line -match '^([^=]+)=(.*)$') { - [Environment]::SetEnvironmentVariable($Matches[1], $Matches[2], 'Process') - } -} +Import-Module (Join-Path $installation 'Common7\Tools\Microsoft.VisualStudio.DevShell.dll') +Enter-VsDevShell -VsInstallPath $installation -SkipAutomaticLocation -DevCmdArguments '-arch=x64 -host_arch=x64 -no_logo' $output = [IO.Path]::GetFullPath($OutputPath) New-Item -ItemType Directory -Path (Split-Path -Parent $output) -Force | Out-Null