refactor(editor): migrate file and git menus to shared menu

This commit is contained in:
Kayshen-X 2026-06-14 00:56:43 +08:00
parent b64a928bee
commit 94f8be6844
20 changed files with 421 additions and 162 deletions

View file

@ -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 `⎇ <branch> ▾` 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<String>,
/// 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<FileMenuChoice>,
/// 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<FileAction>,
/// 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,

View file

@ -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,

View file

@ -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;

View file

@ -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<RecentEntry>,
/// 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<FileMenuChoice>,
/// 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<FileMenuChoice> {
self.hit_test(panel, point)
pub fn hovered_at(&self, panel: Rect, point: Point2D) -> Option<usize> {
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<FileMenuChoice> {
if !(panel).contains(point) {
return None;
pub fn choice_for_row(&self, row: usize) -> Option<FileMenuChoice> {
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<FileMenuChoice> {
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
);
}
}

View file

@ -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};

View file

@ -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<GitPanelHit> {
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.

View file

@ -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 {

View file

@ -891,7 +891,7 @@ impl ApplicationHandler<DesktopEvent> 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);

View file

@ -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));

View file

@ -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;
}

View file

@ -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};

View file

@ -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;

View file

@ -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;
}

View file

@ -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 `⎇ <branch> ▾` 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();
}

View file

@ -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;
}

View file

@ -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

View file

@ -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.

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;