diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index a82d48c9c..04c03271d 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -16,11 +16,10 @@ //! ### Move STATE, not RENDER code //! //! Many of these types are *declared* under shell-core's `widgets/` -//! module — `ExportFormat` in `widgets/export_dialog.rs`, -//! `FileMenuChoice` in `widgets/file_menu.rs`. They are data/state -//! enums, not rendering code, so their type definitions belong in -//! the state layer. The widget *painting / hit-test* code stays in -//! shell-core untouched. +//! module — for example `ExportFormat` in `widgets/export_dialog.rs`. +//! They are data/state enums, not rendering code, so their type +//! definitions belong in the state layer. The widget *painting / +//! hit-test* code stays in shell-core untouched. //! //! All types here are plain data (enums + structs of primitives / //! strings / ids), so `op-editor-core` stays wasm32-clean. @@ -54,19 +53,6 @@ pub use crate::property_panel_state::{ PaddingEditMode, PropertyTab, }; -/// File-menu choices. State enum ported from shell-core's -/// `widgets/file_menu::FileMenuChoice`. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FileMenuChoice { - NewFile, - OpenFile, - Save, - SaveAs, - ExportImage, - OpenRecent(usize), - ClearRecent, -} - /// File-menu actions the host runner has to handle (rfd dialogs + /// serde live host-side, not here). `ExportImage` opens the picker; /// `ExportImageConfirm` commits. @@ -504,6 +490,8 @@ pub struct GitPanelState { /// (switch-tracked / clear-author / remote-settings › / SSH-keys › / /// close-repo). Mirrors the TS header's local `overflowOpen`. pub overflow_open: bool, + /// Shared interaction state for the top-level overflow menu rows. + pub overflow_menu: jian_widgets::components::menu::MenuState, /// Which view the overflow popover is showing — the top-level menu /// or one of its subviews (remote settings). Resets to `Menu` each /// time the popover closes. Mirrors the TS header's `overflowView`. @@ -511,6 +499,8 @@ pub struct GitPanelState { /// Ready-state header: whether the branch-picker dropdown (opened /// from the `⎇ ▾` button) is open. pub branch_picker_open: bool, + /// Shared interaction state for branch-picker dropdown rows. + pub branch_picker_menu: jian_widgets::components::menu::MenuState, /// Current branch name of that repository. pub branch: Option, /// All local branch names, sorted — the panel lists them for @@ -803,8 +793,9 @@ pub struct EditorUiState { // --- File menu -------------------------------------------------- /// File-menu dropdown open (anchored under folder + chevron). pub file_menu_open: bool, - /// File-menu row currently hovered — drives the per-row tint. - pub file_menu_hover: Option, + /// Shared file-menu interaction state; `hover = None` means no + /// actionable row hovered. + pub file_menu: jian_widgets::components::menu::MenuState, /// Pending file-menu action for the host runner to handle. pub pending_file_action: Option, /// Recent files (head = newest, cap 10). @@ -1194,7 +1185,7 @@ impl Default for EditorUiState { locale: Locale::ZhCn, locale_picker: jian_widgets::components::select::SelectState::default(), file_menu_open: false, - file_menu_hover: None, + file_menu: Default::default(), pending_file_action: None, recent_files: Vec::new(), file_name_display: None, diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 7b4b9ecb3..28d65906e 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -156,7 +156,7 @@ pub use design_md::{extract_design_md_from_document, generate_design_md, parse_d pub use design_md_button_state::DesignMdButton; pub use editor_ui_state::{ BooleanOp, CloneField, CloneFormState, CommitDiffPatch, CommitDiffSummary, CommitDiffView, - DesignMdRequest, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, FlexLayout, + DesignMdRequest, EditorUiState, ExportFormat, FileAction, FillType, FlexLayout, GitBranchPickerMode, GitCandidateFile, GitCommitSummary, GitDiffTarget, GitDiffView, GitFileEntry, GitOverflowView, GitPanelAction, GitPanelState, ImageAdjustmentField, ImageFillMode, LayerContextMenuState, Locale, MergeConflictRow, MergeResolveFile, diff --git a/crates/op-editor-ui/src/widgets/editor_state_ext.rs b/crates/op-editor-ui/src/widgets/editor_state_ext.rs index 464588c15..85f80430a 100644 --- a/crates/op-editor-ui/src/widgets/editor_state_ext.rs +++ b/crates/op-editor-ui/src/widgets/editor_state_ext.rs @@ -27,25 +27,6 @@ pub fn translate(ui: &EditorUiState, key: &'static str) -> &'static str { crate::i18n::translate(ui.locale, key) } -/// Map an `op_editor_core::FileMenuChoice` onto the widget-layer -/// `widgets::file_menu::FileMenuChoice`. Variant-identical; bridges -/// the file-menu hover state. -pub fn doc_file_menu_choice( - c: op_editor_core::FileMenuChoice, -) -> crate::widgets::file_menu::FileMenuChoice { - use crate::widgets::file_menu::FileMenuChoice as D; - use op_editor_core::FileMenuChoice as O; - match c { - O::NewFile => D::NewFile, - O::OpenFile => D::OpenFile, - O::Save => D::Save, - O::SaveAs => D::SaveAs, - O::ExportImage => D::ExportImage, - O::OpenRecent(i) => D::OpenRecent(i), - O::ClearRecent => D::ClearRecent, - } -} - /// Map an `op_editor_core::ExportFormat` onto the widget-layer /// `widgets::export_dialog::ExportFormat`. Variant-identical. pub fn doc_export_format( @@ -67,29 +48,8 @@ pub fn doc_export_format( // The host feeds widget hit-test results back into `EditorState`'s // `editor_ui_state`. Most widget hit-tests already emit canonical // `op_editor_core` types (`Tool`, `AlignAction`, `PropertyFocus`, …) -// so no conversion is needed. The three enums below stay widget-local -// (`file_menu` / `shape_picker` / `export_dialog` own them) and so -// still need a one-arm-per-variant bridge into the canonical -// `editor_ui_state` enums the hover / format state fields hold. - -/// Map the widget-layer `widgets::file_menu::FileMenuChoice` onto the -/// canonical `op_editor_core::FileMenuChoice`. Reverse of -/// [`doc_file_menu_choice`]. -pub fn file_menu_choice( - c: crate::widgets::file_menu::FileMenuChoice, -) -> op_editor_core::FileMenuChoice { - use crate::widgets::file_menu::FileMenuChoice as W; - use op_editor_core::FileMenuChoice as O; - match c { - W::NewFile => O::NewFile, - W::OpenFile => O::OpenFile, - W::Save => O::Save, - W::SaveAs => O::SaveAs, - W::ExportImage => O::ExportImage, - W::OpenRecent(i) => O::OpenRecent(i), - W::ClearRecent => O::ClearRecent, - } -} +// so no conversion is needed. The remaining widget-local enums below +// still need a one-arm-per-variant bridge into canonical state fields. /// Map the widget-layer `widgets::toolbar::ToolbarAction` onto the /// canonical `op_editor_core::ToolbarAction`. Variant-identical; diff --git a/crates/op-editor-ui/src/widgets/file_menu.rs b/crates/op-editor-ui/src/widgets/file_menu.rs index 4544f50d7..c244c5d63 100644 --- a/crates/op-editor-ui/src/widgets/file_menu.rs +++ b/crates/op-editor-ui/src/widgets/file_menu.rs @@ -5,10 +5,11 @@ //! "Recent files" header + entries, finally Clear history. use crate::theme::Theme; -use crate::widgets::editor_state_ext::{doc_file_menu_choice, theme_for}; +use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; +pub use jian_widgets::components::menu::MenuHit; use op_editor_core::editor_ui_state::EditorUiState; /// Resolve a file-menu row label via `op-i18n`. The Rust file menu @@ -54,11 +55,9 @@ pub struct FileMenu<'a> { pub theme: Theme, ui: &'a EditorUiState, pub recent: Vec, - /// Mirrors `Document.ui.file_menu_hover` — populated by the - /// host on cursor-move so paint can tint the row under the - /// cursor. `None` = no hover (cursor outside the menu, or no - /// movement since the menu opened). - pub hovered: Option, + /// Shared interaction state populated by the host on cursor-move. + /// `hover` stores an actionable row index. + pub menu: jian_widgets::components::menu::MenuState, } #[derive(Debug, Clone)] @@ -74,7 +73,7 @@ impl<'a> FileMenu<'a> { theme: theme_for(ui), ui, recent, - hovered: ui.file_menu_hover.map(doc_file_menu_choice), + menu: ui.file_menu.clone(), } } @@ -123,51 +122,81 @@ impl<'a> FileMenu<'a> { /// Convenience alias: `hit_test` is reused for hover dispatch /// (same row geometry, no separate code path needed). - pub fn hovered_at(&self, panel: Rect, point: Point2D) -> Option { - self.hit_test(panel, point) + pub fn hovered_at(&self, panel: Rect, point: Point2D) -> Option { + match self.hit(panel, point) { + MenuHit::Row(idx) => Some(idx), + MenuHit::Inside | MenuHit::Outside => None, + } } - /// `point` is in screen space; return the activated row, or None - /// for clicks on dividers / headers / outside the menu. - pub fn hit_test(&self, panel: Rect, point: Point2D) -> Option { - if !(panel).contains(point) { - return None; + pub fn choice_for_row(&self, row: usize) -> Option { + match row { + 0 => Some(FileMenuChoice::NewFile), + 1 => Some(FileMenuChoice::OpenFile), + 2 => Some(FileMenuChoice::Save), + 3 => Some(FileMenuChoice::SaveAs), + 4 => Some(FileMenuChoice::ExportImage), + row if row < 5 + self.recent.len() => Some(FileMenuChoice::OpenRecent(row - 5)), + row if !self.recent.is_empty() && row == 5 + self.recent.len() => { + Some(FileMenuChoice::ClearRecent) + } + _ => None, } + } + + pub fn hit(&self, panel: Rect, point: Point2D) -> MenuHit { + if !(panel).contains(point) { + return MenuHit::Outside; + } + let mut row = 0usize; let mut y = panel.origin.y + PAD_Y; - for choice in [FileMenuChoice::NewFile, FileMenuChoice::OpenFile] { + for _ in 0..2 { if row_hit(panel.origin.x, y, point) { - return Some(choice); + return MenuHit::Row(row); } y += ROW_HEIGHT; + row += 1; } y += DIVIDER_GAP * 2.0 + 1.0; - for choice in [FileMenuChoice::Save, FileMenuChoice::SaveAs] { + for _ in 0..2 { if row_hit(panel.origin.x, y, point) { - return Some(choice); + return MenuHit::Row(row); } y += ROW_HEIGHT; + row += 1; } y += DIVIDER_GAP * 2.0 + 1.0; if row_hit(panel.origin.x, y, point) { - return Some(FileMenuChoice::ExportImage); + return MenuHit::Row(row); } y += ROW_HEIGHT; + row += 1; y += DIVIDER_GAP * 2.0 + 1.0; y += HEADER_HEIGHT; - for (i, _) in self.recent.iter().enumerate() { + for _ in self.recent.iter() { if row_hit(panel.origin.x, y, point) { - return Some(FileMenuChoice::OpenRecent(i)); + return MenuHit::Row(row); } y += ROW_HEIGHT; + row += 1; } if self.recent.is_empty() { y += ROW_HEIGHT; } y += DIVIDER_GAP * 2.0 + 1.0; if !self.recent.is_empty() && row_hit(panel.origin.x, y, point) { - return Some(FileMenuChoice::ClearRecent); + return MenuHit::Row(row); + } + MenuHit::Inside + } + + /// `point` is in screen space; return the activated row, or None + /// for clicks on dividers / headers / outside the menu. + pub fn hit_test(&self, panel: Rect, point: Point2D) -> Option { + match self.hit(panel, point) { + MenuHit::Row(idx) => self.choice_for_row(idx), + MenuHit::Inside | MenuHit::Outside => None, } - None } } @@ -212,7 +241,7 @@ impl<'a> Widget for FileMenu<'a> { cx.backend.fill_round_rect(rect, 10.0, self.theme.card); cx.backend .stroke_round_rect(rect, 10.0, self.theme.border, 1.0); - let h = |c: FileMenuChoice| self.hovered == Some(c); + let h = |row: usize| self.menu.hover == Some(row); let mut y = rect.origin.y + PAD_Y; paint_row( cx, @@ -222,7 +251,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::Plus, t(self.ui, "new"), "⌘N", - h(FileMenuChoice::NewFile), + h(0), ); y += ROW_HEIGHT; paint_row( @@ -233,7 +262,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::FolderOpen, t(self.ui, "open"), "⌘O", - h(FileMenuChoice::OpenFile), + h(1), ); y += ROW_HEIGHT; y = paint_divider(cx, &self.theme, rect, y); @@ -245,7 +274,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::Save, t(self.ui, "save"), "⌘S", - h(FileMenuChoice::Save), + h(2), ); y += ROW_HEIGHT; paint_row( @@ -256,7 +285,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::Save, t(self.ui, "saveAs"), "⌘⇧S", - h(FileMenuChoice::SaveAs), + h(3), ); y += ROW_HEIGHT; y = paint_divider(cx, &self.theme, rect, y); @@ -268,7 +297,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::Download, t(self.ui, "exportImage"), "⌘⇧P", - h(FileMenuChoice::ExportImage), + h(4), ); y += ROW_HEIGHT; y = paint_divider(cx, &self.theme, rect, y); @@ -285,14 +314,7 @@ impl<'a> Widget for FileMenu<'a> { y += ROW_HEIGHT; } else { for (i, entry) in self.recent.iter().enumerate() { - paint_recent_row( - cx, - &self.theme, - rect.origin.x, - y, - entry, - h(FileMenuChoice::OpenRecent(i)), - ); + paint_recent_row(cx, &self.theme, rect.origin.x, y, entry, h(5 + i)); y += ROW_HEIGHT; } } @@ -316,7 +338,7 @@ impl<'a> Widget for FileMenu<'a> { Icon::Trash, t(self.ui, "clearHistory"), "", - h(FileMenuChoice::ClearRecent), + h(5 + self.recent.len()), ); } } @@ -576,3 +598,64 @@ fn format_age(ui: &EditorUiState, elapsed_secs: u64) -> String { .replace("{{count}}", &(elapsed_secs / 86400).to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + use jian_widgets::components::menu::MenuHit; + + fn menu_panel(menu: &FileMenu<'_>) -> Rect { + Rect { + origin: Point2D::new(100.0, 50.0), + size: Point2D::new(MENU_WIDTH, menu.height()), + } + } + + #[test] + fn hit_uses_shared_menu_state_protocol() { + let mut ui = EditorUiState::default(); + ui.file_menu.hover = Some(5); + let menu = FileMenu::for_editor_ui( + &ui, + vec![ + RecentEntry { + name: "one.op".to_string(), + age: "now".to_string(), + }, + RecentEntry { + name: "two.op".to_string(), + age: "now".to_string(), + }, + ], + ); + assert_eq!(menu.menu.hover, Some(5)); + + let panel = menu_panel(&menu); + let divider = DIVIDER_GAP * 2.0 + 1.0; + let recent_y = panel.origin.y + + PAD_Y + + ROW_HEIGHT * 2.0 + + divider + + ROW_HEIGHT * 2.0 + + divider + + ROW_HEIGHT + + divider + + HEADER_HEIGHT + + ROW_HEIGHT * 0.5; + assert_eq!( + menu.hit(panel, Point2D::new(panel.origin.x + 20.0, recent_y)), + MenuHit::Row(5) + ); + assert_eq!(menu.choice_for_row(5), Some(FileMenuChoice::OpenRecent(0))); + + let header_y = recent_y - ROW_HEIGHT * 0.5 - HEADER_HEIGHT * 0.5; + assert_eq!( + menu.hit(panel, Point2D::new(panel.origin.x + 20.0, header_y)), + MenuHit::Inside + ); + assert_eq!( + menu.hit(panel, Point2D::new(panel.origin.x - 1.0, header_y)), + MenuHit::Outside + ); + } +} diff --git a/crates/op-editor-ui/src/widgets/git_panel.rs b/crates/op-editor-ui/src/widgets/git_panel.rs index 375c3b900..d17c2406d 100644 --- a/crates/op-editor-ui/src/widgets/git_panel.rs +++ b/crates/op-editor-ui/src/widgets/git_panel.rs @@ -15,6 +15,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; +pub use jian_widgets::components::menu::MenuHit; pub use jian_widgets::components::select::SelectHit; use op_editor_core::{EditorState, GitButton, GitPanelState}; diff --git a/crates/op-editor-ui/src/widgets/git_panel_menus.rs b/crates/op-editor-ui/src/widgets/git_panel_menus.rs index 9ca0adb8b..48ab7c42b 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_menus.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_menus.rs @@ -14,6 +14,7 @@ use crate::widgets::git_panel::{contains, truncate, GitPanel, GitPanelHit, PAD}; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect}; +use jian_widgets::components::menu::MenuHit; use op_editor_core::{GitBranchPickerMode, GitOverflowView}; /// Dropdown row height (TS menu item ≈ 28 px). @@ -298,6 +299,9 @@ impl GitPanel<'_> { for (i, row) in rows.iter().enumerate() { let bi = if merging { candidates[i] } else { i }; let is_current = self.state.branches.get(bi) == self.state.branch.as_ref(); + if self.state.branch_picker_menu.hover == Some(i) && !is_current { + cx.backend.fill_round_rect(*row, 6.0, t.button_hover); + } let name = truncate( self.state .branches @@ -477,6 +481,22 @@ impl GitPanel<'_> { } } + pub fn branch_picker_menu_hit(&self, panel_rect: Rect, point: Point2D) -> MenuHit { + let panel = self.branch_picker_panel(panel_rect); + if !contains(panel, point) { + return MenuHit::Outside; + } + if self.state.branch_picker_mode == GitBranchPickerMode::Create { + return MenuHit::Inside; + } + for (i, row) in self.branch_picker_row_rects(panel_rect).iter().enumerate() { + if contains(*row, point) { + return MenuHit::Row(i); + } + } + MenuHit::Inside + } + // ── Overflow menu ──────────────────────────────────────────────── /// The overflow `…` menu rect, anchored below the overflow button @@ -520,8 +540,10 @@ impl GitPanel<'_> { cx.backend.fill_round_rect(panel, 8.0, t.popover); cx.backend.stroke_round_rect(panel, 8.0, t.border, 1.0); let rows = self.overflow_row_rects(panel_rect); - for (item, row) in self.overflow_items().iter().zip(rows.iter()) { - self.wash_if_hovered(cx, *row, 6.0, item.hit); + for (i, (item, row)) in self.overflow_items().iter().zip(rows.iter()).enumerate() { + if self.state.overflow_menu.hover == Some(i) { + cx.backend.fill_round_rect(*row, 6.0, t.button_hover); + } // Leaf icon (TS size=13 strokeWidth=1.75, muted). draw_icon( cx.backend, @@ -569,20 +591,24 @@ impl GitPanel<'_> { /// Hit-test the overflow menu. `None` when the point is outside the /// popover (the caller then closes it + falls through). pub(super) fn overflow_hit(&self, panel_rect: Rect, point: Point2D) -> Option { + match self.overflow_menu_hit(panel_rect, point) { + MenuHit::Row(idx) => self.overflow_items().get(idx).map(|item| item.hit), + MenuHit::Inside => Some(GitPanelHit::Inside), + MenuHit::Outside => None, + } + } + + pub fn overflow_menu_hit(&self, panel_rect: Rect, point: Point2D) -> MenuHit { let panel = self.overflow_panel(panel_rect); if !contains(panel, point) { - return None; + return MenuHit::Outside; } - for (item, row) in self - .overflow_items() - .iter() - .zip(self.overflow_row_rects(panel_rect).iter()) - { + for (i, row) in self.overflow_row_rects(panel_rect).iter().enumerate() { if contains(*row, point) { - return Some(item.hit); + return MenuHit::Row(i); } } - Some(GitPanelHit::Inside) + MenuHit::Inside } /// Paint whichever overflow view is active — the menu or a subview. diff --git a/crates/op-editor-ui/src/widgets/git_panel_tests.rs b/crates/op-editor-ui/src/widgets/git_panel_tests.rs index b37eafa33..6cc6031a5 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_tests.rs @@ -453,6 +453,50 @@ fn overflow_menu_maps_its_entries() { ); } +#[test] +fn git_menus_use_shared_menu_state_protocol() { + use jian_widgets::components::menu::{MenuHit, MenuState}; + + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + overflow_open: true, + overflow_menu: MenuState { hover: Some(2) }, + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let rows = panel.overflow_row_rects(rect); + assert_eq!(panel.state.overflow_menu.hover, Some(2)); + assert_eq!( + panel.overflow_menu_hit(rect, centre(rows[2])), + MenuHit::Row(2) + ); + assert_eq!( + panel.overflow_menu_hit(rect, Point2D::new(rows[2].origin.x, rows[2].origin.y - 4.0)), + MenuHit::Inside + ); + + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + branches: vec!["main".to_string(), "feature".to_string()], + branch_picker_open: true, + branch_picker_menu: MenuState { hover: Some(1) }, + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let rows = panel.branch_picker_row_rects(rect); + assert_eq!(panel.state.branch_picker_menu.hover, Some(1)); + assert_eq!( + panel.branch_picker_menu_hit(rect, centre(rows[1])), + MenuHit::Row(1) + ); + assert_eq!( + panel.branch_picker_menu_hit(rect, Point2D::new(rows[0].origin.x, rows[0].origin.y - 4.0)), + MenuHit::Inside + ); +} + #[test] fn tracked_picker_maps_rows_and_actions() { let s = state_with(GitPanelState { diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index 4e69ff904..db7a1a375 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -891,7 +891,7 @@ impl ApplicationHandler for DesktopApp { ) { let eui = &mut self.host.editor_state_mut().editor_ui; eui.file_menu_open = false; - eui.file_menu_hover = None; + eui.file_menu.hover = None; eui.export_dialog_open = true; self.host.mark_editor_state_dirty(); self.request_redraw(true); diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index 30e53caa6..711c2fbf8 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -41,7 +41,11 @@ fn variable_row_input_keeps_resume_time_redraws_active() { app.host.set_now_ms(240); app.host.editor_state_mut().editor_ui.variable_row_focus = Some(op_editor_core::editor_ui_state::VariableRowFocus::Name(0)); - app.host.editor_state_mut().ui.property_caret_anchor_ms = 240; + app.host + .editor_state_mut() + .editor_ui + .variable_row_input + .touch(240); assert!(app.resume_time_needs_redraw()); assert_eq!(app.host.next_animation_deadline_ms(), Some(740)); diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index f6a3a2b25..fdbec317a 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -89,7 +89,7 @@ impl WidgetHostNative { { let ui = &mut self.editor_state.editor_ui; changed |= ui.canvas_hover_node.take().is_some(); - changed |= ui.file_menu_hover.take().is_some(); + changed |= ui.file_menu.hover.take().is_some(); changed |= ui.locale_picker.hover.take().is_some(); changed |= ui.shape_picker.hover.take().is_some(); changed |= ui.fill_type_picker.hover.take().is_some(); @@ -146,11 +146,9 @@ impl WidgetHostNative { .unwrap_or(0); let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, now_secs); let panel = menu.rect_at(anchor); - let new_hover = menu - .hovered_at(panel, Point2D::new(x, y)) - .map(op_editor_ui::widgets::editor_state_ext::file_menu_choice); - if new_hover != self.editor_state.editor_ui.file_menu_hover { - self.editor_state.editor_ui.file_menu_hover = new_hover; + let new_hover = menu.hovered_at(panel, Point2D::new(x, y)); + if new_hover != self.editor_state.editor_ui.file_menu.hover { + self.editor_state.editor_ui.file_menu.hover = new_hover; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs b/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs index 9c0ffafe8..be8c07280 100644 --- a/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs +++ b/crates/op-host-native/src/widget_host/git_panel_placement_tests.rs @@ -8,7 +8,7 @@ use super::helpers::GIT_PANEL_CARET_GAP; use super::{CursorHint, WidgetHostNative}; use op_editor_core::{GitFileEntry, GitPanelAction}; -use op_editor_ui::widgets::{GitPanel, TopBar, TOP_BAR_HEIGHT}; +use op_editor_ui::widgets::{GitPanel, GitPanelHit, TopBar, TOP_BAR_HEIGHT}; use op_editor_ui::{Point2D, Rect}; /// A host with the Git panel open in its no-repo onboarding state @@ -23,6 +23,24 @@ fn host_with_git_panel_open() -> WidgetHostNative { host } +fn find_git_hit(panel: &GitPanel<'_>, body: Rect, target: GitPanelHit) -> Point2D { + let mut y = body.origin.y; + let max_y = body.origin.y + body.size.y + 140.0; + while y <= max_y { + let mut x = body.origin.x; + let max_x = body.origin.x + body.size.x; + while x <= max_x { + let point = Point2D::new(x, y); + if panel.hit_test(body, point) == Some(target) { + return point; + } + x += 4.0; + } + y += 4.0; + } + panic!("could not find git hit target {target:?}"); +} + #[test] fn open_git_popover_is_modal_and_dismisses_on_any_outside_press() { let mut host = WidgetHostNative::new(); @@ -368,6 +386,49 @@ fn init_card_hover_tracks_the_card_index_and_not_allowed_cursor() { ); } +#[test] +fn git_popover_row_hover_uses_shared_menu_state() { + let mut host = WidgetHostNative::new(); + let (vw, vh) = (1400.0, 900.0); + host.last_viewport_w = vw; + host.last_viewport_h = vh; + { + let panel = &mut host.editor_state_mut().editor_ui.git_panel; + panel.open = true; + panel.loading = false; + panel.in_repo = true; + panel.branch = Some("main".to_string()); + panel.overflow_open = true; + } + let body = host.git_panel_rect(vw, vh).expect("panel open"); + let panel = GitPanel::for_editor(host.editor_state()).expect("panel widget"); + let point = find_git_hit(&panel, body, GitPanelHit::OverflowRemoteSettings); + assert!(host.update_git_panel_ready_hover(point.x, point.y)); + assert_eq!( + host.editor_state().editor_ui.git_panel.overflow_menu.hover, + Some(2) + ); + + { + let panel = &mut host.editor_state_mut().editor_ui.git_panel; + panel.overflow_open = false; + panel.overflow_menu.hover = None; + panel.branch_picker_open = true; + panel.branches = vec!["main".to_string(), "feature".to_string()]; + } + let panel = GitPanel::for_editor(host.editor_state()).expect("panel widget"); + let point = find_git_hit(&panel, body, GitPanelHit::SwitchBranch(1)); + assert!(host.update_git_panel_ready_hover(point.x, point.y)); + assert_eq!( + host.editor_state() + .editor_ui + .git_panel + .branch_picker_menu + .hover, + Some(1) + ); +} + #[test] fn clone_wizard_owns_keyboard_and_enter() { use op_editor_core::{CloneField, CloneFormState, GitPanelAction}; diff --git a/crates/op-host-native/src/widget_host/git_press.rs b/crates/op-host-native/src/widget_host/git_press.rs index 859b9c54e..f65bfbcba 100644 --- a/crates/op-host-native/src/widget_host/git_press.rs +++ b/crates/op-host-native/src/widget_host/git_press.rs @@ -185,7 +185,9 @@ impl WidgetHostNative { // Toggle the branch-picker dropdown; close the overflow // menu so only one ready-state popover is open at a time. panel.branch_picker_open = !panel.branch_picker_open; + panel.branch_picker_menu.hover = None; panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; panel.close_tracked_picker(); // Always (re)open on the branch list — a prior session's @@ -198,22 +200,27 @@ impl WidgetHostNative { // Always (re)open on the top-level menu so a prior // session's subview never leaks back in. panel.overflow_open = !panel.overflow_open; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; panel.close_tracked_picker(); } Some(GitPanelHit::OverflowRemoteSettings) => { + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::RemoteSettings; panel.close_tracked_picker(); } Some(GitPanelHit::OverflowSshKeys) => { // Open the SSH-keys subview (host enumerates the stored keys). + panel.overflow_menu.hover = None; panel.pending_action = Some(GitPanelAction::EnterSshKeys); panel.close_tracked_picker(); } Some(GitPanelHit::SshGenerateKey) => { panel.pending_action = Some(GitPanelAction::SetupSshAuth); panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; } Some(GitPanelHit::SshImportKey) => { @@ -229,18 +236,21 @@ impl WidgetHostNative { Some(GitPanelHit::OverflowSwitchTracked) => { // Host enumerates the repo's `.op` candidates, then flips the // subview to the tracked-file picker. + panel.overflow_menu.hover = None; panel.open_tracked_picker(); panel.pending_action = Some(GitPanelAction::EnterTrackedPicker); } Some(GitPanelHit::OverflowClearAuthor) => { panel.pending_action = Some(GitPanelAction::ClearAuthor); panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; panel.close_tracked_picker(); } Some(GitPanelHit::OverflowCloseRepo) => { panel.pending_action = Some(GitPanelAction::CloseRepo); panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; panel.close_tracked_picker(); } @@ -269,9 +279,11 @@ impl WidgetHostNative { Some(GitPanelHit::DismissPopover) => { // Click outside an open popover — close it + swallow. panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; panel.branch_picker_mode = GitBranchPickerMode::List; panel.branch_create_input.set_text(""); panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = GitOverflowView::Menu; panel.close_tracked_picker(); panel.defocus_text_inputs(); @@ -288,14 +300,17 @@ impl WidgetHostNative { } // Close the branch-picker dropdown after a pick. panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; } Some(GitPanelHit::MergeBranch(index)) => { if let Some(name) = panel.branches.get(index).cloned() { panel.pending_action = Some(GitPanelAction::MergeBranch(name)); } panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; } Some(GitPanelHit::BranchCreateMode) => { + panel.branch_picker_menu.hover = None; panel.branch_picker_mode = GitBranchPickerMode::Create; panel.branch_create_input.set_text(""); panel.branch_create_input.touch(now); @@ -305,6 +320,7 @@ impl WidgetHostNative { panel.https_focused = false; } Some(GitPanelHit::BranchMergeMode) => { + panel.branch_picker_menu.hover = None; panel.branch_picker_mode = GitBranchPickerMode::Merge; panel.branch_create_focused = false; } @@ -323,11 +339,13 @@ impl WidgetHostNative { panel.branch_create_input.set_text(""); panel.branch_create_focused = false; panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; } } Some(GitPanelHit::BranchPickerCancel) => { // Step a create / merge sub-mode back to the branch list. panel.branch_picker_mode = GitBranchPickerMode::List; + panel.branch_picker_menu.hover = None; panel.branch_create_input.set_text(""); panel.branch_create_focused = false; } @@ -478,7 +496,9 @@ impl WidgetHostNative { panel.open = false; panel.defocus_text_inputs(); panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; panel.overflow_open = false; + panel.overflow_menu.hover = None; panel.overflow_view = op_editor_core::GitOverflowView::Menu; panel.close_tracked_picker(); panel.diff = None; diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 23c9e920b..119471021 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -1208,6 +1208,7 @@ impl WidgetHostNative { panel.branch_create_input.set_text(""); panel.branch_create_focused = false; panel.branch_picker_open = false; + panel.branch_picker_menu.hover = None; } self.mark_dirty(); return true; @@ -1376,6 +1377,7 @@ impl WidgetHostNative { { let panel = &mut self.editor_state.editor_ui.git_panel; panel.branch_picker_mode = op_editor_core::GitBranchPickerMode::List; + panel.branch_picker_menu.hover = None; panel.branch_create_input.set_text(""); panel.branch_create_focused = false; self.mark_dirty(); @@ -1439,7 +1441,7 @@ impl WidgetHostNative { } if self.editor_state.editor_ui.file_menu_open { self.editor_state.editor_ui.file_menu_open = false; - self.editor_state.editor_ui.file_menu_hover = None; + self.editor_state.editor_ui.file_menu.hover = None; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/overlay_rects.rs b/crates/op-host-native/src/widget_host/overlay_rects.rs index a1c93c682..51d60ff5e 100644 --- a/crates/op-host-native/src/widget_host/overlay_rects.rs +++ b/crates/op-host-native/src/widget_host/overlay_rects.rs @@ -112,6 +112,34 @@ impl WidgetHostNative { } }) }); + let branch_picker_open = self.editor_state.editor_ui.git_panel.branch_picker_open; + let branch_picker_hover = panel_body.and_then(|body| { + GitPanel::for_editor(&self.editor_state).and_then(|p| { + if !branch_picker_open { + return None; + } + match p.branch_picker_menu_hit(body, point) { + op_editor_ui::widgets::git_panel::MenuHit::Row(idx) => Some(idx), + op_editor_ui::widgets::git_panel::MenuHit::Inside + | op_editor_ui::widgets::git_panel::MenuHit::Outside => None, + } + }) + }); + let overflow_menu_open = self.editor_state.editor_ui.git_panel.overflow_open + && self.editor_state.editor_ui.git_panel.overflow_view + == op_editor_core::GitOverflowView::Menu; + let overflow_menu_hover = panel_body.and_then(|body| { + GitPanel::for_editor(&self.editor_state).and_then(|p| { + if !overflow_menu_open { + return None; + } + match p.overflow_menu_hit(body, point) { + op_editor_ui::widgets::git_panel::MenuHit::Row(idx) => Some(idx), + op_editor_ui::widgets::git_panel::MenuHit::Inside + | op_editor_ui::widgets::git_panel::MenuHit::Outside => None, + } + }) + }); // The `⎇ ▾` trigger keeps its own bool wash; the plain // action buttons (pull / push / overflow / commit / milestone / // refresh) light up via `button_hover`. @@ -130,6 +158,25 @@ impl WidgetHostNative { self.editor_state.editor_ui.git_panel.tracked_picker.hover = tracked_picker_hover; changed = true; } + if branch_picker_hover + != self + .editor_state + .editor_ui + .git_panel + .branch_picker_menu + .hover + { + self.editor_state + .editor_ui + .git_panel + .branch_picker_menu + .hover = branch_picker_hover; + changed = true; + } + if overflow_menu_hover != self.editor_state.editor_ui.git_panel.overflow_menu.hover { + self.editor_state.editor_ui.git_panel.overflow_menu.hover = overflow_menu_hover; + changed = true; + } if changed { self.mark_dirty(); } diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 6db8f6fe4..a5966d43b 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -453,6 +453,7 @@ impl WidgetHostNative { } TopBarHit::ToggleFileMenu => { self.editor_state.editor_ui.file_menu_open ^= true; + self.editor_state.editor_ui.file_menu.hover = None; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index 1180508de..6c4430cac 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -587,7 +587,7 @@ impl WidgetHostNative { viewport_width: f32, ) { use op_editor_core::editor_ui_state::FileAction; - use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice}; + use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice, MenuHit}; use op_editor_ui::widgets::top_bar::TopBar; self.refresh_layout_scene(); let top_bar_rect = op_editor_ui::Rect { @@ -602,23 +602,34 @@ impl WidgetHostNative { .unwrap_or(0); let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, now_secs); let menu_rect = menu.rect_at(anchor); - if let Some(choice) = menu.hit_test(menu_rect, op_editor_ui::Point2D::new(x, y)) { - self.editor_state.editor_ui.pending_file_action = Some(match choice { - FileMenuChoice::NewFile => FileAction::New, - FileMenuChoice::OpenFile => FileAction::Open, - FileMenuChoice::Save => FileAction::Save, - FileMenuChoice::SaveAs => FileAction::SaveAs, - FileMenuChoice::ExportImage => FileAction::ExportImage, - FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i), - FileMenuChoice::ClearRecent => FileAction::ClearRecent, - }); - } else { - // Miss — the dismissing click is a blank press. - self.blur_text_inputs_on_blank_press(); + let point = op_editor_ui::Point2D::new(x, y); + match menu.hit(menu_rect, point) { + MenuHit::Row(row) => { + let Some(choice) = menu.choice_for_row(row) else { + return; + }; + self.editor_state.editor_ui.pending_file_action = Some(match choice { + FileMenuChoice::NewFile => FileAction::New, + FileMenuChoice::OpenFile => FileAction::Open, + FileMenuChoice::Save => FileAction::Save, + FileMenuChoice::SaveAs => FileAction::SaveAs, + FileMenuChoice::ExportImage => FileAction::ExportImage, + FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i), + FileMenuChoice::ClearRecent => FileAction::ClearRecent, + }); + self.editor_state.editor_ui.file_menu_open = false; + self.editor_state.editor_ui.file_menu.hover = None; + self.mark_dirty(); + } + MenuHit::Inside => {} + MenuHit::Outside => { + // Miss — the dismissing click is a blank press. + self.blur_text_inputs_on_blank_press(); + self.editor_state.editor_ui.file_menu_open = false; + self.editor_state.editor_ui.file_menu.hover = None; + self.mark_dirty(); + } } - self.editor_state.editor_ui.file_menu_open = false; - self.editor_state.editor_ui.file_menu_hover = None; - self.mark_dirty(); } /// Commit a pending effect-parameter edit (Effects section's diff --git a/crates/op-host-web/src/widget_host/chrome_menu_press.rs b/crates/op-host-web/src/widget_host/chrome_menu_press.rs index ebf4943fc..40b59c41f 100644 --- a/crates/op-host-web/src/widget_host/chrome_menu_press.rs +++ b/crates/op-host-web/src/widget_host/chrome_menu_press.rs @@ -17,7 +17,7 @@ impl WidgetHost { viewport_width: f32, ) { use op_editor_core::editor_ui_state::FileAction; - use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice}; + use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice, MenuHit}; use op_editor_ui::widgets::top_bar::TopBar; self.refresh_layout_scene(); let top_bar_rect = op_editor_ui::Rect { @@ -31,23 +31,34 @@ impl WidgetHost { // clock paints the same menu rows. let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, 0); let menu_rect = menu.rect_at(anchor); - if let Some(choice) = menu.hit_test(menu_rect, op_editor_ui::Point2D::new(x, y)) { - self.editor_state.editor_ui.pending_file_action = Some(match choice { - FileMenuChoice::NewFile => FileAction::New, - FileMenuChoice::OpenFile => FileAction::Open, - FileMenuChoice::Save => FileAction::Save, - FileMenuChoice::SaveAs => FileAction::SaveAs, - FileMenuChoice::ExportImage => FileAction::ExportImage, - FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i), - FileMenuChoice::ClearRecent => FileAction::ClearRecent, - }); - } else { - // Miss — the dismissing click is a blank press. - self.blur_text_inputs_on_blank_press(); + let point = op_editor_ui::Point2D::new(x, y); + match menu.hit(menu_rect, point) { + MenuHit::Row(row) => { + let Some(choice) = menu.choice_for_row(row) else { + return; + }; + self.editor_state.editor_ui.pending_file_action = Some(match choice { + FileMenuChoice::NewFile => FileAction::New, + FileMenuChoice::OpenFile => FileAction::Open, + FileMenuChoice::Save => FileAction::Save, + FileMenuChoice::SaveAs => FileAction::SaveAs, + FileMenuChoice::ExportImage => FileAction::ExportImage, + FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i), + FileMenuChoice::ClearRecent => FileAction::ClearRecent, + }); + self.editor_state.editor_ui.file_menu_open = false; + self.editor_state.editor_ui.file_menu.hover = None; + self.mark_dirty(); + } + MenuHit::Inside => {} + MenuHit::Outside => { + // Miss — the dismissing click is a blank press. + self.blur_text_inputs_on_blank_press(); + self.editor_state.editor_ui.file_menu_open = false; + self.editor_state.editor_ui.file_menu.hover = None; + self.mark_dirty(); + } } - self.editor_state.editor_ui.file_menu_open = false; - self.editor_state.editor_ui.file_menu_hover = None; - self.mark_dirty(); } /// Export-dialog press dispatcher. diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index f6e7399f5..7f007baa7 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -599,7 +599,7 @@ impl WidgetHost { } if self.editor_state.editor_ui.file_menu_open { self.editor_state.editor_ui.file_menu_open = false; - self.editor_state.editor_ui.file_menu_hover = None; + self.editor_state.editor_ui.file_menu.hover = None; self.mark_dirty(); return true; } diff --git a/crates/op-host-web/src/widget_host/overlay_cursor.rs b/crates/op-host-web/src/widget_host/overlay_cursor.rs index d13ba61b4..026e4286d 100644 --- a/crates/op-host-web/src/widget_host/overlay_cursor.rs +++ b/crates/op-host-web/src/widget_host/overlay_cursor.rs @@ -130,11 +130,9 @@ impl WidgetHost { // to age (see `dispatch_file_menu_press`). let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, 0); let panel = menu.rect_at(anchor); - let new_hover = menu - .hovered_at(panel, Point2D::new(x, y)) - .map(op_editor_ui::widgets::editor_state_ext::file_menu_choice); - if new_hover != self.editor_state.editor_ui.file_menu_hover { - self.editor_state.editor_ui.file_menu_hover = new_hover; + let new_hover = menu.hovered_at(panel, Point2D::new(x, y)); + if new_hover != self.editor_state.editor_ui.file_menu.hover { + self.editor_state.editor_ui.file_menu.hover = new_hover; self.mark_dirty(); return true; } diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index a97eb968e..17b78f344 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -323,6 +323,7 @@ impl WidgetHost { } TopBarHit::ToggleFileMenu => { self.editor_state.editor_ui.file_menu_open ^= true; + self.editor_state.editor_ui.file_menu.hover = None; } TopBarHit::OpenFigmaImport => { self.editor_state.editor_ui.figma_import_open = true;