From 130a9b14a3ee1b9e50bec3745fb7b74e6a1cdae6 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 31 May 2026 01:02:47 +0800 Subject: [PATCH] fix(editor): guard flex children on drag + property-panel and desktop chrome fixes Canvas / layout: - Guard flex flow-children from free-drag and add a 4px drag threshold, so a first click no longer materializes x/y on a flow child and collapses the auto-layout frame. Property panel: - Gap/justify radios disable the gap input on space-between/around; compact gap rows with vertically-centered input text. - Padding-mode gear popover hover; the pin is anchor-scoped so it no longer leaks into the next selection. - Font-weight dropdown: full 100-900 numeric options + row hover. - Stroke hex seeds the displayed swatch colour (not #000000); stroke width now persists on commit. Desktop: - Traffic-light inset now applies on launch (casement windowDidBecomeKey). - File-drop overlay; git commit caret blink. Refactor: - Split property_dispatch / git_panel / property-panel tests under the 800-line cap; reorganise panel tests by topic. --- crates/op-editor-core/src/align.rs | 8 + crates/op-editor-core/src/command_tests.rs | 15 + crates/op-editor-core/src/editor_ui_state.rs | 70 +- crates/op-editor-core/src/lib.rs | 9 +- crates/op-editor-core/src/mutators.rs | 8 + crates/op-editor-core/src/pen_node_ext.rs | 17 + .../src/property_edit_mutators.rs | 79 ++- .../src/property_panel_state.rs | 36 ++ crates/op-editor-core/src/test_support.rs | 68 ++ crates/op-editor-core/src/tests_geometry.rs | 72 +++ crates/op-editor-core/src/walkers.rs | 39 +- .../src/widgets/file_drop_overlay.rs | 81 +++ crates/op-editor-ui/src/widgets/git_panel.rs | 96 +-- .../op-editor-ui/src/widgets/git_panel_hit.rs | 28 + .../src/widgets/git_panel_menus.rs | 478 ++++++++++++++ .../src/widgets/git_panel_ready.rs | 357 ++++++++++ .../src/widgets/git_panel_tests.rs | 210 +++++- .../src/widgets/git_panel_text.rs | 43 ++ crates/op-editor-ui/src/widgets/icons.rs | 3 + crates/op-editor-ui/src/widgets/icons_data.rs | 12 + crates/op-editor-ui/src/widgets/mod.rs | 12 +- .../src/widgets/property_panel.rs | 71 +- .../src/widgets/property_panel_action.rs | 95 +++ .../src/widgets/property_panel_export.rs | 2 + .../src/widgets/property_panel_fill.rs | 11 +- .../src/widgets/property_panel_flex.rs | 562 +++++++++++----- .../src/widgets/property_panel_icon_tests.rs | 14 + .../src/widgets/property_panel_image_fill.rs | 20 +- .../property_panel_image_fill_tests.rs | 235 +++++++ .../widgets/property_panel_input_layout.rs | 59 +- .../src/widgets/property_panel_inputs.rs | 18 +- .../src/widgets/property_panel_layout.rs | 41 +- .../property_panel_multi_select_tests.rs | 116 ++++ .../src/widgets/property_panel_sections.rs | 97 +-- .../src/widgets/property_panel_snapshot.rs | 22 + .../widgets/property_panel_test_support.rs | 120 ++++ .../src/widgets/property_panel_tests.rs | 611 ++++++------------ .../src/widgets/property_panel_text.rs | 236 ++++++- .../src/widgets/property_panel_visibility.rs | 15 + crates/op-host-desktop/src/app_handler.rs | 37 +- crates/op-host-desktop/src/git_host.rs | 69 ++ crates/op-host-desktop/src/main_tests.rs | 7 +- crates/op-host-native/src/widget_host.rs | 18 + .../widget_host/git_panel_placement_tests.rs | 54 ++ .../src/widget_host/git_press.rs | 49 +- .../op-host-native/src/widget_host/input.rs | 29 + .../src/widget_host/input_drag_tests.rs | 5 + .../src/widget_host/keyboard.rs | 6 +- .../op-host-native/src/widget_host/paint.rs | 26 +- .../op-host-native/src/widget_host/press.rs | 45 ++ .../src/widget_host/press_helpers.rs | 9 +- .../src/widget_host/property_dispatch.rs | 177 ++--- .../widget_host/property_layout_dispatch.rs | 12 + .../src/widget_host/property_popovers.rs | 288 +++++++++ crates/op-host-web/src/widget_host/press.rs | 40 ++ .../src/widget_host/property_dispatch.rs | 43 ++ crates/op-i18n/src/i18n/en.rs | 3 + crates/op-i18n/src/i18n/zh_cn.rs | 3 + crates/op-i18n/src/i18n/zh_tw.rs | 3 + vendor/casement | 2 +- 60 files changed, 4111 insertions(+), 900 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/file_drop_overlay.rs create mode 100644 crates/op-editor-ui/src/widgets/git_panel_menus.rs create mode 100644 crates/op-editor-ui/src/widgets/git_panel_ready.rs create mode 100644 crates/op-editor-ui/src/widgets/git_panel_text.rs create mode 100644 crates/op-editor-ui/src/widgets/property_panel_image_fill_tests.rs create mode 100644 crates/op-editor-ui/src/widgets/property_panel_multi_select_tests.rs create mode 100644 crates/op-editor-ui/src/widgets/property_panel_test_support.rs create mode 100644 crates/op-host-native/src/widget_host/property_popovers.rs diff --git a/crates/op-editor-core/src/align.rs b/crates/op-editor-core/src/align.rs index b808ebaf..21cc6cb7 100644 --- a/crates/op-editor-core/src/align.rs +++ b/crates/op-editor-core/src/align.rs @@ -115,6 +115,11 @@ fn apply_align( if is_ancestor_in_set(children, id, editable) { continue; } + // Engine-positioned flex children can't move independently; a + // translate would materialize x/y and detach them from flow. + if crate::walkers::is_flow_child_of_flex(children, id) { + continue; + } let Some(cur) = find_node(children, id).map(aggregate_bounds) else { continue; }; @@ -148,6 +153,9 @@ fn apply_distribute(children: &mut [PenNode], editable: &[NodeId], action: Align let filtered: Vec = editable .iter() .filter(|id| !is_ancestor_in_set(children, id, editable)) + // Engine-positioned flex children are excluded from distribution + // for the same reason as the align/translate paths. + .filter(|id| !crate::walkers::is_flow_child_of_flex(children, id)) .cloned() .collect(); let mut sorted: Vec<(NodeId, DocRect)> = filtered diff --git a/crates/op-editor-core/src/command_tests.rs b/crates/op-editor-core/src/command_tests.rs index d6ff9cd3..1a466534 100644 --- a/crates/op-editor-core/src/command_tests.rs +++ b/crates/op-editor-core/src/command_tests.rs @@ -471,6 +471,21 @@ fn set_node_stroke_width_zero_clears_stroke() { .is_none()); } +#[test] +fn commit_property_edit_writes_stroke_width() { + // Regression: committing the stroke-width input used to be a no-op + // in `commit_property_edit`, so the typed value reset on commit. + let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]); + assert!(s.apply(EditorCommand::SetNodeStrokeHex { + node_id: id("n1"), + hex: "#114194".into(), + })); + s.set_single_selection(id("n1")); + assert!(s.commit_property_edit(crate::ui_draft::PropertyFocus::StrokeWidth, 5.0)); + let w = crate::fills::node_stroke_width(find_node(s.active_children(), &id("n1")).unwrap()); + assert_eq!(w, Some(5.0)); +} + #[test] fn set_node_corner_radius_rejects_negative() { let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]); diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index a0dafdcf..840d7f7c 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -50,7 +50,8 @@ impl ThemeMode { } pub use crate::property_panel_state::{ - BooleanOp, ExportFormat, FillType, FlexLayout, ImageAdjustmentField, ImageFillMode, PropertyTab, + BooleanOp, ExportFormat, FillType, FlexLayout, ImageAdjustmentField, ImageFillMode, + PaddingEditMode, PropertyTab, }; /// File-menu choices. State enum ported from shell-core's @@ -238,6 +239,18 @@ impl MergeResolveState { } } +/// Which view the ready-state overflow `…` popover is showing. The +/// top-level menu opens subviews in place (mirrors the TS header's +/// `overflowView` state machine), resetting to `Menu` on close. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum GitOverflowView { + /// The top-level action list. + #[default] + Menu, + /// The remote-settings subview — origin URL + HTTPS credential. + RemoteSettings, +} + /// An interactive action requested from the Git panel. The desktop /// host drains it from [`GitPanelState::pending_action`] and runs it /// against its `GitSession` (the widget layer never calls git). @@ -258,6 +271,12 @@ pub enum GitPanelAction { /// Stage + commit the tracked document with the panel's /// `commit_message`. Commit, + /// Ready-view "Save milestone": save the current design to the + /// tracked `.op`, stage it, and commit with the panel's + /// `commit_message` — the TS `commitMilestone` flow. Unlike + /// [`GitPanelAction::Commit`] (which commits a pre-assembled staged + /// index) this snapshots the live editor state in one click. + CommitMilestone, /// Switch the working tree to the named branch. SwitchBranch(String), /// Add / re-point the `origin` remote to the given URL. @@ -306,6 +325,17 @@ pub struct GitPanelState { /// `0` is hovered with no saved file). Updated by the host on /// cursor-move. pub empty_hovered_card: Option, + /// Ready-state header: whether the `…` overflow popover is open + /// (switch-tracked / clear-author / remote-settings › / SSH-keys › / + /// close-repo). Mirrors the TS header's local `overflowOpen`. + pub overflow_open: bool, + /// 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`. + pub overflow_view: GitOverflowView, + /// Ready-state header: whether the branch-picker dropdown (opened + /// from the `⎇ ▾` button) is open. + pub branch_picker_open: bool, /// Current branch name of that repository. pub branch: Option, /// All local branch names, sorted — the panel lists them for @@ -338,6 +368,10 @@ pub struct GitPanelState { pub commit_message: String, /// Whether the commit-message input holds keyboard focus. pub commit_focused: bool, + /// Caret-blink anchor (ms) for the commit input — reset on focus + + /// each keystroke so the caret stays solid while typing, then + /// blinks (same cadence as the chat / property inputs). + pub commit_caret_anchor_ms: u64, /// Interactive action requested by a panel click / Enter — /// drained and executed by the desktop host. pub pending_action: Option, @@ -496,6 +530,10 @@ pub struct EditorUiState { /// desktop runner sets it when spawning the worker and clears it /// when the result lands. pub figma_import_in_progress: bool, + /// True while a file is being dragged over the window (between the + /// platform's `HoveredFile` and `HoveredFileCancelled` / drop). Drives + /// the full-canvas drop overlay so the user sees a clear drop target. + pub file_drop_active: bool, /// Imported Figma documents parsed in Preserve mode already carry /// authored parent-local geometry. The scene builder can use this /// flag to skip the expensive flex/text layout pass. @@ -566,6 +604,22 @@ pub struct EditorUiState { pub property_tab: PropertyTab, /// Active flex-layout mode for the property panel's row. pub flex_layout: FlexLayout, + /// Padding-section edit mode pinned via the gear popover. `None` + /// re-derives the mode from the node's values each frame (TS + /// default); `Some(_)` keeps the user's pick. The pin is scoped to + /// [`Self::padding_edit_mode_anchor`] so it never leaks into the + /// next selection — the panel ignores it once the anchor differs. + pub padding_edit_mode: Option, + /// Node id (anchor) the [`Self::padding_edit_mode`] pin was set for. + /// Empty when unset. The property panel honours the pin only while + /// the current selection anchor still matches, so selecting another + /// node falls back to deriving the mode from that node's values. + pub padding_edit_mode_anchor: String, + /// Whether the padding-mode gear popover is open. + pub padding_mode_popover_open: bool, + /// Index (into `PaddingEditMode::ALL`) of the popover row under the + /// cursor while the gear popover is open — drives the hover wash. + pub padding_mode_popover_hover: Option, pub size_fill_width: bool, pub size_fill_height: bool, pub size_hug_width: bool, @@ -577,6 +631,11 @@ pub struct EditorUiState { pub image_fill_popover_open: bool, /// Whether the text font-family picker is open. pub font_family_picker_open: bool, + /// Whether the typography font-weight dropdown is open. + pub font_weight_picker_open: bool, + /// Index (into `FontWeightChoice::ALL`) of the weight-dropdown row + /// under the cursor while it's open — drives the hover wash. + pub font_weight_picker_hover: Option, /// Active-theme axis whose value picker is open; `None` = closed. pub axis_dropdown_open: Option, /// Editor focus for a non-color variable row (Number / String). @@ -675,7 +734,7 @@ impl Default for EditorUiState { Self { sidebar_open: true, layer_panel_width: 240.0, - property_panel_width: 280.0, + property_panel_width: 256.0, theme_mode: ThemeMode::Dark, locale: Locale::ZhCn, locale_picker_open: false, @@ -696,6 +755,7 @@ impl Default for EditorUiState { layer_layers_scroll: 0.0, figma_import_open: false, figma_import_in_progress: false, + file_drop_active: false, preserve_authored_geometry: false, agent_settings_open: false, agent_settings: crate::agent_settings::AgentSettings::default(), @@ -721,6 +781,10 @@ impl Default for EditorUiState { align_toolbar_hover: None, property_tab: PropertyTab::Design, flex_layout: FlexLayout::Free, + padding_edit_mode: None, + padding_edit_mode_anchor: String::new(), + padding_mode_popover_open: false, + padding_mode_popover_hover: None, size_fill_width: false, size_fill_height: false, size_hug_width: false, @@ -729,6 +793,8 @@ impl Default for EditorUiState { fill_type_picker_open: false, image_fill_popover_open: false, font_family_picker_open: false, + font_weight_picker_open: false, + font_weight_picker_hover: None, axis_dropdown_open: None, variable_row_focus: None, effect_param_focus: None, diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index fb406e87..9ddd0958 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -86,10 +86,11 @@ pub use components::{Component, ComponentLibrary}; pub use design_md::parse_design_md; pub use editor_ui_state::{ BooleanOp, DesignMdRequest, EditorUiState, ExportFormat, FileAction, FileMenuChoice, FillType, - FlexLayout, GitCommitSummary, GitDiffTarget, GitDiffView, GitFileEntry, GitPanelAction, - GitPanelState, ImageAdjustmentField, ImageFillMode, LayerContextMenuState, Locale, - MergeConflictRow, MergeResolveFile, MergeResolveState, PageRenameState, PropertyTab, - RecentFile, ShapeChoice, ThemeMode, UpdateStatus, VariableRowFocus, + FlexLayout, GitCommitSummary, GitDiffTarget, GitDiffView, GitFileEntry, GitOverflowView, + GitPanelAction, GitPanelState, ImageAdjustmentField, ImageFillMode, LayerContextMenuState, + Locale, MergeConflictRow, MergeResolveFile, MergeResolveState, PaddingEditMode, + PageRenameState, PropertyTab, RecentFile, ShapeChoice, ThemeMode, UpdateStatus, + VariableRowFocus, }; pub use fills::{ first_fill_type, first_image_fill_summary, first_solid_fill_hex, first_solid_fill_opacity, diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index 0b57fd6c..074cf60a 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -420,6 +420,14 @@ impl EditorState { } let children = self.active_children_mut(); for target in &editable { + // A child of an auto-layout (flex) parent is engine-positioned; + // materializing x/y here flips it to Position::Absolute in + // jian-core and detaches it from flex flow. A free drag of + // such a node is a no-op (it cannot move independently of the + // layout engine). Checked before the cascade dedup. + if walkers::is_flow_child_of_flex(children, target) { + continue; + } if !walkers::is_ancestor_in_set(children, target, &editable) { if let Some(node) = find_node_mut(children, target) { walkers::translate_subtree(node, dx, dy); diff --git a/crates/op-editor-core/src/pen_node_ext.rs b/crates/op-editor-core/src/pen_node_ext.rs index 68643b17..6a81650d 100644 --- a/crates/op-editor-core/src/pen_node_ext.rs +++ b/crates/op-editor-core/src/pen_node_ext.rs @@ -47,6 +47,12 @@ pub trait PenNodeExt { /// True when this node is a `Group` — the ungroup target. fn is_group(&self) -> bool; + /// True when this node is an auto-layout (flex) container — + /// `layout: Vertical | Horizontal`. Its children are flow- + /// positioned by the layout engine, NOT by stored `x` / `y`, so a + /// move of the container must leave the children un-positioned. + fn is_auto_layout_container(&self) -> bool; + /// Explicit pixel width, when the variant carries one and it is /// a literal number (not `fit_content` / a `$variable`). fn width_px(&self) -> Option; @@ -145,6 +151,17 @@ impl PenNodeExt for PenNode { matches!(self, PenNode::Group(_)) } + fn is_auto_layout_container(&self) -> bool { + use jian_ops_schema::node::container::LayoutMode; + let layout = match self { + PenNode::Frame(n) => n.container.layout.as_ref(), + PenNode::Group(n) => n.container.layout.as_ref(), + PenNode::Rectangle(n) => n.container.layout.as_ref(), + _ => None, + }; + matches!(layout, Some(LayoutMode::Vertical | LayoutMode::Horizontal)) + } + fn width_px(&self) -> Option { match self { PenNode::Frame(n) => sizing_px(&n.container.width), diff --git a/crates/op-editor-core/src/property_edit_mutators.rs b/crates/op-editor-core/src/property_edit_mutators.rs index 98da66f8..f5536fbd 100644 --- a/crates/op-editor-core/src/property_edit_mutators.rs +++ b/crates/op-editor-core/src/property_edit_mutators.rs @@ -17,6 +17,7 @@ impl EditorState { } match focus { PropertyFocus::PositionR => return self.cmd_set_node_corner_radius(&sel, value), + PropertyFocus::StrokeWidth => return self.cmd_set_node_stroke_width(&sel, value), PropertyFocus::PolygonSides => { if !value.is_finite() { return false; @@ -105,14 +106,39 @@ impl EditorState { .selected_node() .map(container_padding_values) .unwrap_or([0.0; 4]); - let idx = match focus { - PropertyFocus::PaddingTop => 0, - PropertyFocus::PaddingRight => 1, - PropertyFocus::PaddingBottom => 2, - PropertyFocus::PaddingLeft => 3, - _ => unreachable!(), - }; - values[idx] = value.max(0.0) as f64; + let v = value.max(0.0) as f64; + // Fan the edit out to the sides the active mode binds + // together (TS: Single writes all four; Axis-V = + // top+bottom, Axis-H = right+left). + let mode = self.editor_ui.padding_edit_mode.unwrap_or_else(|| { + crate::PaddingEditMode::from_values( + values[0] as f32, + values[1] as f32, + values[2] as f32, + values[3] as f32, + ) + }); + match (mode, focus) { + (crate::PaddingEditMode::Single, _) => values = [v; 4], + (crate::PaddingEditMode::Axis, PropertyFocus::PaddingTop) => { + values[0] = v; + values[2] = v; + } + (crate::PaddingEditMode::Axis, PropertyFocus::PaddingRight) => { + values[1] = v; + values[3] = v; + } + _ => { + let idx = match focus { + PropertyFocus::PaddingTop => 0, + PropertyFocus::PaddingRight => 1, + PropertyFocus::PaddingBottom => 2, + PropertyFocus::PaddingLeft => 3, + _ => unreachable!(), + }; + values[idx] = v; + } + } return self.cmd_set_node_layout_prop( &sel, "padding", @@ -131,7 +157,9 @@ impl EditorState { PropertyFocus::SizeW => node.set_width_px(v.max(0.0)), PropertyFocus::SizeH => node.set_height_px(v.max(0.0)), PropertyFocus::Rotation => node.base_mut().rotation = Some(v), - PropertyFocus::PositionR => unreachable!("corner radius handled before node borrow"), + PropertyFocus::PositionR | PropertyFocus::StrokeWidth => { + unreachable!("corner radius / stroke width handled before node borrow") + } PropertyFocus::PolygonSides | PropertyFocus::EllipseStart | PropertyFocus::EllipseSweep @@ -147,10 +175,10 @@ impl EditorState { | PropertyFocus::PaddingLeft => { unreachable!("shape-specific properties handled before node borrow") } - PropertyFocus::StrokeWidth - | PropertyFocus::Opacity - | PropertyFocus::FillHex - | PropertyFocus::StrokeHex => {} + // StrokeWidth handled in the first match (before the node + // borrow). Opacity / FillHex / StrokeHex are applied by the + // host commit path, not this numeric commit. + PropertyFocus::Opacity | PropertyFocus::FillHex | PropertyFocus::StrokeHex => {} PropertyFocus::FillOpacity => { let _ = self.set_selected_fill_opacity((value / 100.0).clamp(0.0, 1.0)); } @@ -209,6 +237,31 @@ impl EditorState { crate::fills::set_primary_fill_type(node, fill_type) } + /// Normalise the selected node's padding to the canonical shape for + /// `mode` (TS `handleModeChange`): Single collapses to the top + /// value on all sides; Axis mirrors top→bottom and right→left; + /// Individual leaves the four values as-is. + pub fn set_selected_padding_mode_shape(&mut self, mode: crate::PaddingEditMode) -> bool { + let sel = self.selection.anchor.clone(); + if !sel.is_real() || !self.is_editable(&sel) { + return false; + } + let v = self + .selected_node() + .map(container_padding_values) + .unwrap_or([0.0; 4]); + let reshaped = match mode { + crate::PaddingEditMode::Single => [v[0]; 4], + crate::PaddingEditMode::Axis => [v[0], v[1], v[0], v[1]], + crate::PaddingEditMode::Individual => v, + }; + self.cmd_set_node_layout_prop( + &sel, + "padding", + &crate::LayoutPropValue::NumberArray(reshaped.to_vec()), + ) + } + pub fn clear_selected_fills(&mut self) -> bool { let sel = self.selection.anchor.clone(); if !sel.is_real() || !self.is_editable(&sel) { diff --git a/crates/op-editor-core/src/property_panel_state.rs b/crates/op-editor-core/src/property_panel_state.rs index 92a35a5a..3f1c1584 100644 --- a/crates/op-editor-core/src/property_panel_state.rs +++ b/crates/op-editor-core/src/property_panel_state.rs @@ -122,6 +122,42 @@ pub enum FlexLayout { Horizontal, } +/// How the padding section shows its inputs — TS `PaddingMode`. +/// `Single` = one value for all four sides; `Axis` = vertical + +/// horizontal; `Individual` = top / right / bottom / left. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaddingEditMode { + Single, + Axis, + Individual, +} + +impl PaddingEditMode { + pub const ALL: [Self; 3] = [Self::Single, Self::Axis, Self::Individual]; + + /// i18n key for the gear-popover row label. + pub fn label_key(self) -> &'static str { + match self { + Self::Single => "padding.oneValue", + Self::Axis => "padding.horizontalVertical", + Self::Individual => "padding.topRightBottomLeft", + } + } + + /// Derive the mode from the four effective padding values — mirrors + /// the TS `parsePaddingValues` (uniform first, then axis, else + /// individual). + pub fn from_values(t: f32, r: f32, b: f32, l: f32) -> Self { + if t == r && r == b && b == l { + Self::Single + } else if t == b && r == l { + Self::Axis + } else { + Self::Individual + } + } +} + /// Path boolean ops — TS parity with Paper.js (Ctrl+Alt+U/S/I/X). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BooleanOp { diff --git a/crates/op-editor-core/src/test_support.rs b/crates/op-editor-core/src/test_support.rs index 1158b188..b9a24db3 100644 --- a/crates/op-editor-core/src/test_support.rs +++ b/crates/op-editor-core/src/test_support.rs @@ -40,6 +40,34 @@ pub fn rect(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode { }) } +/// A rectangle leaf with NO authored position — a flow child of an +/// auto-layout (flex) frame. The layout engine places it; the editor +/// must never materialize an explicit `x` / `y` onto it. +pub fn flow_rect(id: &str, name: &str, w: f64, h: f64) -> PenNode { + PenNode::Rectangle(RectangleNode { + base: PenNodeBase { + id: id.to_string(), + name: Some(name.to_string()), + x: None, + y: None, + ..Default::default() + }, + container: ContainerProps { + width: Some(SizingBehavior::Number(w)), + height: Some(SizingBehavior::Number(h)), + ..Default::default() + }, + children: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }) +} + /// A text leaf at `(x, y)` sized `w × h` with `content`. pub fn text(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64, content: &str) -> PenNode { PenNode::Text(TextNode { @@ -113,6 +141,46 @@ pub fn frame( }) } +/// An auto-layout (flex) frame at `(x, y)` sized `w × h` with +/// `layout: Vertical`. Pass flow children (see [`flow_rect`]) that +/// carry NO authored x / y — the layout engine positions them. +pub fn flex_frame( + id: &str, + name: &str, + x: f64, + y: f64, + w: f64, + h: f64, + children: Vec, +) -> PenNode { + use jian_ops_schema::node::container::LayoutMode; + PenNode::Frame(FrameNode { + base: PenNodeBase { + id: id.to_string(), + name: Some(name.to_string()), + x: Some(x), + y: Some(y), + ..Default::default() + }, + container: ContainerProps { + width: Some(SizingBehavior::Number(w)), + height: Some(SizingBehavior::Number(h)), + layout: Some(LayoutMode::Vertical), + ..Default::default() + }, + children: Some(children), + reusable: None, + slot: None, + state: None, + bindings: None, + events: None, + lifecycle: None, + semantics: None, + gestures: None, + route: None, + }) +} + /// An ellipse leaf at `(x, y)` sized `w × h` with no arc geometry. pub fn ellipse(id: &str, name: &str, x: f64, y: f64, w: f64, h: f64) -> PenNode { use jian_ops_schema::node::EllipseNode; diff --git a/crates/op-editor-core/src/tests_geometry.rs b/crates/op-editor-core/src/tests_geometry.rs index c85a217c..a0a0a4f3 100644 --- a/crates/op-editor-core/src/tests_geometry.rs +++ b/crates/op-editor-core/src/tests_geometry.rs @@ -47,6 +47,78 @@ fn translate_selected_cascades_into_children() { assert_eq!(node_x(&s, "n2"), 25.0); } +#[test] +fn translate_selected_keeps_flex_children_flow_positioned() { + // A flex (auto-layout) frame positions its children via the + // layout engine — they carry NO authored x / y. Moving the frame + // must shift ONLY the frame's own origin; materializing an + // explicit x / y onto a flow child turns it into an absolutely + // positioned node (jian-core `explicit_position`), pulling it out + // of flex flow so the next re-layout stacks every child at the + // frame's top-left. Regression for "first frame select crowds the + // contents". + use crate::test_support::{flex_frame, flow_rect}; + let mut s = state_with(vec![flex_frame( + "n1", + "Flex", + 100.0, + 100.0, + 200.0, + 300.0, + vec![ + flow_rect("n2", "A", 80.0, 24.0), + flow_rect("n3", "B", 80.0, 24.0), + ], + )]); + s.set_single_selection(NodeId::new("n1")); + s.translate_selected(5.0, 7.0); + + // The frame's own origin moved. + let f = find_node(s.active_children(), &NodeId::new("n1")).unwrap(); + assert_eq!(f.base().x, Some(105.0)); + assert_eq!(f.base().y, Some(107.0)); + + // The flow children stay un-positioned — the core invariant. + for id in ["n2", "n3"] { + let c = find_node(s.active_children(), &NodeId::new(id)).unwrap(); + assert_eq!(c.base().x, None, "flow child {id} must keep x == None"); + assert_eq!(c.base().y, None, "flow child {id} must keep y == None"); + } +} + +#[test] +fn translate_selected_skips_a_directly_selected_flex_child() { + // The actual first-click bug path: the click selects the DEEPEST + // node, which inside an auto-layout form is a flow CHILD — not the + // frame. Dragging that child must NOT move it (it is engine- + // positioned); materializing x/y would detach it from flex flow and + // collapse the siblings. A free-layout child of a free frame still + // drags (see `translate_selected_cascades_into_children`). + use crate::test_support::{flex_frame, flow_rect}; + let mut s = state_with(vec![flex_frame( + "n1", + "Flex", + 0.0, + 0.0, + 200.0, + 300.0, + vec![flow_rect("n2", "A", 80.0, 24.0)], + )]); + s.set_single_selection(NodeId::new("n2")); + s.translate_selected(9.0, 11.0); + let c = find_node(s.active_children(), &NodeId::new("n2")).unwrap(); + assert_eq!( + c.base().x, + None, + "a dragged flex child must stay unpositioned" + ); + assert_eq!( + c.base().y, + None, + "a dragged flex child must stay unpositioned" + ); +} + #[test] fn translate_selected_dedups_ancestor_and_descendant() { let mut s = state_with(vec![frame( diff --git a/crates/op-editor-core/src/walkers.rs b/crates/op-editor-core/src/walkers.rs index 1e90a339..241c34e6 100644 --- a/crates/op-editor-core/src/walkers.rs +++ b/crates/op-editor-core/src/walkers.rs @@ -294,14 +294,25 @@ pub fn deep_clone_with_new_ids( } /// Translate `node` and its whole subtree by `(dx, dy)` document px. -/// Children carry document-absolute coords, so moving a container -/// must shift the descendants too or they detach. +/// Free-layout children carry document-absolute coords, so moving a +/// container must shift the descendants too or they detach. +/// +/// Auto-layout (flex) containers are the exception: their children are +/// flow-positioned by the layout engine and re-flow under the moved +/// origin on their own. Cascading into them would materialize an +/// explicit `x` / `y` onto each flow child, which jian-core reads as +/// an absolutely positioned node — collapsing every child to the +/// frame's top-left on the next re-layout. So move only the flex +/// container's own origin and stop. pub fn translate_subtree(node: &mut PenNode, dx: f64, dy: f64) { { let base = node.base_mut(); base.x = Some(base.x.unwrap_or(0.0) + dx); base.y = Some(base.y.unwrap_or(0.0) + dy); } + if node.is_auto_layout_container() { + return; + } if let Some(children) = node.children_mut() { for child in children { translate_subtree(child, dx, dy); @@ -309,6 +320,30 @@ pub fn translate_subtree(node: &mut PenNode, dx: f64, dy: f64) { } } +/// True when `target`'s immediate parent (anywhere in the forest) is an +/// auto-layout (flex) container — i.e. `target` is positioned by the +/// layout engine, not by stored `x` / `y`. +/// +/// Such a node must not be free-dragged: writing `x` / `y` onto it (even +/// the tiny delta of a jittered click) flips it to `Position::Absolute` +/// in jian-core and detaches it from its parent's flex flow, collapsing +/// the siblings. A drag of an auto-layout child is therefore a no-op +/// (reorder-on-drag is a separate, future affordance). Top-level nodes +/// and children of free-layout parents are NOT affected. +pub fn is_flow_child_of_flex(children: &[PenNode], target: &NodeId) -> bool { + for child in children { + if let Some(grand) = child.children() { + if grand.iter().any(|c| c.id_str() == target.as_str()) { + return child.is_auto_layout_container(); + } + if is_flow_child_of_flex(grand, target) { + return true; + } + } + } + false +} + /// True when any ancestor of `target` within the forest is also in /// `set` — used by `translate_selected` to dedupe a double-shift. pub fn is_ancestor_in_set(children: &[PenNode], target: &NodeId, set: &[NodeId]) -> bool { diff --git a/crates/op-editor-ui/src/widgets/file_drop_overlay.rs b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs new file mode 100644 index 00000000..8610d590 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/file_drop_overlay.rs @@ -0,0 +1,81 @@ +//! Full-canvas overlay shown while a file is dragged over the window. +//! +//! Painted as the top-most layer (above every panel / modal) so the +//! user sees an unambiguous drop target. Driven by +//! `EditorState.editor_ui.file_drop_active`, which the desktop runner +//! toggles on the platform's `HoveredFile` / `HoveredFileCancelled` +//! drag events. + +use crate::theme::Theme; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::property_panel_inputs::to_jian_color; +use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; + +/// Paint the drop overlay across `canvas_rect` (the editor's canvas +/// region — i.e. excluding the rails so the highlight reads as "drop +/// onto the canvas"). +pub fn paint_file_drop_overlay( + backend: &mut dyn RenderBackend, + theme: &Theme, + locale: op_editor_core::Locale, + canvas_rect: Rect, +) { + let p = theme.primary; + // 1. A subtle primary-tinted scrim over the whole canvas. + backend.fill_rect( + canvas_rect, + Color { + r: p.r, + g: p.g, + b: p.b, + a: 0.10, + }, + ); + // 2. An inset rounded "drop zone" border in the accent colour. + let inset = 14.0; + let border = Rect { + origin: Point2D::new(canvas_rect.origin.x + inset, canvas_rect.origin.y + inset), + size: Point2D::new( + (canvas_rect.size.x - inset * 2.0).max(0.0), + (canvas_rect.size.y - inset * 2.0).max(0.0), + ), + }; + backend.stroke_round_rect(border, 16.0, theme.primary, 2.5); + + // 3. A centred card: download icon + label. + let label_text = op_i18n::translate(locale, "dialog.dropToOpen"); + let label_w = backend.measure_text(label_text, 14.0); + let card_w = (label_w + 56.0).max(220.0); + let card_h = 104.0; + let cx = canvas_rect.origin.x + canvas_rect.size.x / 2.0; + let cy = canvas_rect.origin.y + canvas_rect.size.y / 2.0; + let card = Rect { + origin: Point2D::new(cx - card_w / 2.0, cy - card_h / 2.0), + size: Point2D::new(card_w, card_h), + }; + backend.fill_round_rect(card, 14.0, theme.popover); + backend.stroke_round_rect(card, 14.0, theme.border, 1.0); + + // Download glyph (arrow into a tray) reads as "drop here". + let icon = 30.0; + draw_icon( + backend, + Icon::Download, + Point2D::new(cx - icon / 2.0, card.origin.y + 20.0), + icon, + theme.primary, + 2.0, + ); + // Label below the icon, horizontally centred. + let label = TextLayout::single_run( + label_text, + "system-ui", + 14.0, + to_jian_color(theme.foreground), + Point2D::new(0.0, 0.0), + ); + backend.draw_text( + &label, + Point2D::new(cx - label_w / 2.0, card.origin.y + 80.0), + ); +} diff --git a/crates/op-editor-ui/src/widgets/git_panel.rs b/crates/op-editor-ui/src/widgets/git_panel.rs index 20a50b9f..d2f87ddd 100644 --- a/crates/op-editor-ui/src/widgets/git_panel.rs +++ b/crates/op-editor-ui/src/widgets/git_panel.rs @@ -14,7 +14,7 @@ use crate::theme::Theme; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::PaintCx; -use crate::{Color, Point2D, Rect, TextLayout}; +use crate::{Color, Point2D, Rect}; use op_editor_core::{EditorState, GitPanelState}; /// Panel width in logical px. @@ -76,12 +76,29 @@ pub enum GitPanelHit { CommitInput, /// The Commit button. Commit, + /// The ready-view "Save milestone" button — save the live design to + /// the tracked `.op` + stage + commit (TS `commitMilestone`). + CommitMilestone, /// The Refresh button. Refresh, /// The Pull button. Pull, /// The Push button. Push, + /// The ready-state header's `⎇ ▾` button — toggle the + /// branch-picker dropdown. + BranchPicker, + /// The ready-state header's `…` button — toggle the overflow menu. + Overflow, + /// The overflow menu's "Remote settings" entry — open that subview. + OverflowRemoteSettings, + /// The overflow menu's "SSH keys" entry — set up SSH auth. + OverflowSshKeys, + /// A subview's `‹ Back` row — return to the overflow menu. + OverflowBack, + /// A click outside an open header popover (but inside the panel) — + /// the host closes the popover and swallows the click. + DismissPopover, /// The "Abort Merge" button (shown while a merge is in progress). AbortMerge, /// The "Complete Merge" button (shown while a merge is in @@ -171,11 +188,22 @@ pub struct GitPanel<'a> { pub(super) theme: Theme, /// UI locale — every painted string goes through [`GitPanel::t`]. pub(super) locale: op_editor_core::Locale, + /// Wall-clock ms, for caret-blink animation. `0` (hit-test / tests) + /// just yields a steady un-blinked caret. + pub(super) now_ms: u64, } impl<'a> GitPanel<'a> { /// Build the panel for the editor, or `None` when it is closed. + /// Hit-test / tests use this (no blink); paint uses + /// [`GitPanel::for_editor_at`] to drive the caret blink. pub fn for_editor(state: &'a EditorState) -> Option> { + Self::for_editor_at(state, 0) + } + + /// Like [`GitPanel::for_editor`] but threads the wall-clock ms so + /// the commit-input caret can blink. + pub fn for_editor_at(state: &'a EditorState, now_ms: u64) -> Option> { let panel = &state.editor_ui.git_panel; if !panel.open { return None; @@ -184,6 +212,7 @@ impl<'a> GitPanel<'a> { state: panel, theme: theme_for(&state.editor_ui), locale: state.editor_ui.locale, + now_ms, }) } @@ -249,6 +278,10 @@ impl<'a> GitPanel<'a> { // + footer — no branch / action area / commit list. return HEADER_BASELINE + 24.0 + FOOTER_H + PAD; } + // Clean bound-repo → the TS ready layout sizes to its history. + if self.is_ready_state() { + return self.ready_height(); + } // List + Branches + Remotes sections, then the footer. self.remotes_section_top() + self.remotes_block_height() + FOOTER_H + PAD } @@ -383,6 +416,22 @@ impl<'a> GitPanel<'a> { let left = rect.origin.x + PAD; let top = rect.origin.y; + // Clean bound-repo → the TS `GitPanelReady` layout: a compact + // header (branch button + pull/push + overflow), a commit + // textarea, and the recent-commit history. Painted BEFORE the + // classic "Git" title so that title never bleeds through the + // ready header. Dirty trees + merges keep the classic body. + if self.is_ready_state() { + self.paint_ready(cx, rect); + // Header popovers paint on top of the ready body. + if self.state.branch_picker_open { + self.paint_branch_picker(cx, rect); + } else if self.state.overflow_open { + self.paint_overflow(cx, rect); + } + return; + } + self.text( cx, self.t("git.panel.title"), @@ -695,11 +744,12 @@ impl<'a> GitPanel<'a> { cx.backend .stroke_round_rect(rect, 6.0, self.theme.border, 1.0); } - // Roughly centre the label (no per-glyph measurement here). - // A long localized label (e.g. German "Birleştirmeyi iptal - // et") can be wider than the fixed button — clip the draw to - // the button rect so it never bleeds into a neighbour. - let label_w = label.chars().count() as f32 * 6.5; + // Centre the label using the real measured width so CJK labels + // (e.g. "保存为里程碑", ~2× a Latin glyph) centre correctly + // instead of overflowing the right edge. A long localized label + // can still exceed the fixed button — clip the draw to the rect + // so it never bleeds into a neighbour. + let label_w = cx.backend.measure_text(label, 12.0); let text_x = rect.origin.x + (rect.size.x - label_w).max(6.0) / 2.0; let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0; cx.backend.save(); @@ -718,33 +768,6 @@ impl<'a> GitPanel<'a> { self.theme.border, ); } - - /// Paint the menu hint at the panel foot. - fn footer(&self, cx: &mut PaintCx<'_>, left: f32, y: f32) { - self.text( - cx, - self.t("git.panel.footer"), - left, - y, - 10.0, - self.theme.muted_foreground, - ); - } - - /// Draw one line of text. - pub(super) fn text( - &self, - cx: &mut PaintCx<'_>, - s: &str, - x: f32, - baseline_y: f32, - size: f32, - color: Color, - ) { - let layout = - TextLayout::single_run(s, "system-ui", size, to_jian(color), Point2D::new(0.0, 0.0)); - cx.backend.draw_text(&layout, Point2D::new(x, baseline_y)); - } } /// Whether `point` is inside `rect`. @@ -763,10 +786,3 @@ pub(super) fn truncate(s: &str, max: usize) -> String { let kept: String = s.chars().take(max.saturating_sub(1)).collect(); format!("{kept}…") } - -fn to_jian(c: Color) -> jian_core::scene::Color { - fn ch(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 - } - jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) -} diff --git a/crates/op-editor-ui/src/widgets/git_panel_hit.rs b/crates/op-editor-ui/src/widgets/git_panel_hit.rs index ab728a58..942813ac 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_hit.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_hit.rs @@ -12,6 +12,26 @@ impl GitPanel<'_> { /// Map a click at `point` onto a [`GitPanelHit`]. `None` when the /// click is outside `panel_rect` entirely. pub fn hit_test(&self, panel_rect: Rect, point: Point2D) -> Option { + // While a ready-state header popover is open it is modal: a + // click inside it maps to its action; ANY click outside it + // (below the panel body, or out on the canvas) dismisses it and + // is swallowed — so the popover can never get stuck open. This + // runs BEFORE the panel-bounds gate precisely so an outside + // click still reaches `DismissPopover`. + if self.is_ready_state() { + if self.state.branch_picker_open { + return Some( + self.branch_picker_hit(panel_rect, point) + .unwrap_or(GitPanelHit::DismissPopover), + ); + } + if self.state.overflow_open { + return Some( + self.overflow_hit_dispatch(panel_rect, point) + .unwrap_or(GitPanelHit::DismissPopover), + ); + } + } if !contains(panel_rect, point) { return None; } @@ -81,6 +101,14 @@ impl GitPanel<'_> { if self.state.loading || !self.state.in_repo { return Some(GitPanelHit::Inside); } + // Clean bound-repo → the TS ready layout's own hit-map (branch + // button / pull / push / overflow / commit box + button / + // history rows). Any open header popover was already fully + // handled at the top of this fn, so reaching here means none is + // open. + if self.is_ready_state() { + return self.ready_hit(panel_rect, point); + } // The button row. Normal: Commit / Refresh / Pull / Push. // Merge mode: Abort / Refresh / Complete; the commit input // is inert. diff --git a/crates/op-editor-ui/src/widgets/git_panel_menus.rs b/crates/op-editor-ui/src/widgets/git_panel_menus.rs new file mode 100644 index 00000000..e9fad970 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_menus.rs @@ -0,0 +1,478 @@ +//! Ready-state header popovers for [`GitPanel`] — the branch-picker +//! dropdown (`⎇ ▾`) and the overflow `…` menu. +//! +//! A port of the TS `GitPanelBranchPicker` (list mode) + the +//! `GitPanelHeader` overflow popover. Split out of `git_panel_ready.rs` +//! to keep both files under the repo's 800-line cap. +//! +//! These paint as overlays ON TOP of the ready view and are hit-tested +//! BEFORE it (so an open popover captures the click). Geometry is +//! shared between paint + hit-test through the `*_rects` helpers so the +//! pure-geometry hit-test agrees with paint. + +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 op_editor_core::GitOverflowView; + +/// Dropdown row height (TS menu item ≈ 28 px). +const MENU_ROW_H: f32 = 28.0; +/// Inner padding around a popover's row stack. +const MENU_PAD: f32 = 4.0; +/// Most branches the picker lists (matches the classic section cap). +const MENU_MAX_BRANCHES: usize = 8; +/// Branch-name truncation inside the dropdown. +const BRANCH_NAME_MAX: usize = 26; +/// Overflow-menu width (TS `w-56` ≈ 224 px, clamped to the panel). +const OVERFLOW_W: f32 = 208.0; +/// Remote-settings subview width (TS `w-[300px]`, clamped). +const RS_W: f32 = 280.0; +/// Inner padding inside the remote-settings subview. +const RS_PAD: f32 = 10.0; +/// Input / button row height inside the subview. +const RS_ROW_H: f32 = 28.0; +/// The `‹ Back` header-row height. +const RS_BACK_H: f32 = 24.0; +/// Width of the "Set" / "Login" buttons at an input row's right edge. +const RS_BTN_W: f32 = 52.0; +/// Gap between an input and its trailing button. +const RS_GAP: f32 = 8.0; + +/// One overflow-menu entry — an icon, a label key, and the +/// [`GitPanelHit`] it dispatches. +struct OverflowItem { + icon: Icon, + label_key: &'static str, + hit: GitPanelHit, + /// A `›` submenu affordance (the entry opens a subview). + submenu: bool, +} + +impl GitPanel<'_> { + /// The overflow menu's entries, top to bottom — a port of the TS + /// header popover. Only the remote-settings / SSH-keys subviews are + /// wired today; the entries map to existing git actions. + fn overflow_items(&self) -> [OverflowItem; 2] { + [ + OverflowItem { + icon: Icon::Settings2, + label_key: "git.header.overflowRemoteSettings", + hit: GitPanelHit::OverflowRemoteSettings, + submenu: true, + }, + OverflowItem { + icon: Icon::Lock, + label_key: "git.header.overflowSshKeys", + hit: GitPanelHit::OverflowSshKeys, + submenu: false, + }, + ] + } + + // ── Branch picker ──────────────────────────────────────────────── + + /// The branch-picker dropdown rect, anchored below the branch + /// button. At least one row tall (a placeholder when no branches). + pub(super) fn branch_picker_panel(&self, panel_rect: Rect) -> Rect { + let btn = self.ready_branch_rect(panel_rect); + let rows = self.state.branches.len().clamp(1, MENU_MAX_BRANCHES); + let w = (panel_rect.size.x - PAD * 2.0).clamp(180.0, 260.0); + let h = MENU_PAD * 2.0 + rows as f32 * MENU_ROW_H; + Rect { + origin: Point2D::new(btn.origin.x, btn.origin.y + btn.size.y + 4.0), + size: Point2D::new(w, h), + } + } + + /// One clickable rect per listed branch. + pub(super) fn branch_picker_row_rects(&self, panel_rect: Rect) -> Vec { + let panel = self.branch_picker_panel(panel_rect); + (0..self.state.branches.len().min(MENU_MAX_BRANCHES)) + .map(|i| Rect { + origin: Point2D::new( + panel.origin.x + MENU_PAD, + panel.origin.y + MENU_PAD + i as f32 * MENU_ROW_H, + ), + size: Point2D::new(panel.size.x - MENU_PAD * 2.0, MENU_ROW_H), + }) + .collect() + } + + /// Paint the branch-picker dropdown. + pub(super) fn paint_branch_picker(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let panel = self.branch_picker_panel(panel_rect); + cx.backend.fill_round_rect(panel, 8.0, t.popover); + cx.backend.stroke_round_rect(panel, 8.0, t.border, 1.0); + if self.state.branches.is_empty() { + self.text( + cx, + self.t("git.branch.listHeading"), + panel.origin.x + 12.0, + panel.origin.y + MENU_PAD + 18.0, + 12.0, + t.muted_foreground, + ); + return; + } + let rows = self.branch_picker_row_rects(panel_rect); + for (i, row) in rows.iter().enumerate() { + let is_current = self.state.branches.get(i) == self.state.branch.as_ref(); + let name = truncate( + self.state.branches.get(i).map(String::as_str).unwrap_or(""), + BRANCH_NAME_MAX, + ); + self.text( + cx, + &name, + row.origin.x + 10.0, + row.origin.y + row.size.y / 2.0 + 4.0, + 12.0, + if is_current { t.primary } else { t.foreground }, + ); + if is_current { + draw_icon( + cx.backend, + Icon::Check, + Point2D::new( + row.origin.x + row.size.x - 22.0, + row.origin.y + (row.size.y - 14.0) / 2.0, + ), + 14.0, + t.primary, + 1.6, + ); + } else { + // A non-current branch shows a merge affordance on the + // right edge (TS branch-row merge button). + let merge = GitPanel::branch_merge_button(*row); + draw_icon( + cx.backend, + Icon::GitBranch, + Point2D::new(merge.origin.x, merge.origin.y), + 14.0, + t.muted_foreground, + 1.5, + ); + } + } + } + + /// Hit-test the branch-picker dropdown. `None` when the point is + /// outside the popover (the caller then closes it + falls through). + pub(super) fn branch_picker_hit( + &self, + panel_rect: Rect, + point: Point2D, + ) -> Option { + let panel = self.branch_picker_panel(panel_rect); + if !contains(panel, point) { + return None; + } + for (i, row) in self.branch_picker_row_rects(panel_rect).iter().enumerate() { + if !contains(*row, point) { + continue; + } + let is_current = self.state.branches.get(i) == self.state.branch.as_ref(); + if is_current { + return Some(GitPanelHit::Inside); + } + if contains(GitPanel::branch_merge_button(*row), point) { + return Some(GitPanelHit::MergeBranch(i)); + } + return Some(GitPanelHit::SwitchBranch(i)); + } + Some(GitPanelHit::Inside) + } + + // ── Overflow menu ──────────────────────────────────────────────── + + /// The overflow `…` menu rect, anchored below the overflow button + /// and right-aligned to the panel edge. + pub(super) fn overflow_panel(&self, panel_rect: Rect) -> Rect { + let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); + let items = self.overflow_items().len(); + let w = OVERFLOW_W.min(panel_rect.size.x - PAD * 2.0); + let h = MENU_PAD * 2.0 + items as f32 * MENU_ROW_H; + let right = overflow_btn.origin.x + overflow_btn.size.x; + Rect { + origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), + size: Point2D::new(w, h), + } + } + + /// One clickable rect per overflow-menu entry. + pub(super) fn overflow_row_rects(&self, panel_rect: Rect) -> Vec { + let panel = self.overflow_panel(panel_rect); + (0..self.overflow_items().len()) + .map(|i| Rect { + origin: Point2D::new( + panel.origin.x + MENU_PAD, + panel.origin.y + MENU_PAD + i as f32 * MENU_ROW_H, + ), + size: Point2D::new(panel.size.x - MENU_PAD * 2.0, MENU_ROW_H), + }) + .collect() + } + + /// Paint the overflow `…` menu. + pub(super) fn paint_overflow_menu(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let panel = self.overflow_panel(panel_rect); + 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()) { + draw_icon( + cx.backend, + item.icon, + Point2D::new(row.origin.x + 8.0, row.origin.y + (row.size.y - 14.0) / 2.0), + 14.0, + t.muted_foreground, + 1.5, + ); + self.text( + cx, + self.t(item.label_key), + row.origin.x + 30.0, + row.origin.y + row.size.y / 2.0 + 4.0, + 12.0, + t.foreground, + ); + if item.submenu { + draw_icon( + cx.backend, + Icon::ChevronRight, + Point2D::new( + row.origin.x + row.size.x - 18.0, + row.origin.y + (row.size.y - 12.0) / 2.0, + ), + 12.0, + alpha(t.muted_foreground, 0.70), + 1.5, + ); + } + } + } + + /// 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 { + let panel = self.overflow_panel(panel_rect); + if !contains(panel, point) { + return None; + } + for (item, row) in self + .overflow_items() + .iter() + .zip(self.overflow_row_rects(panel_rect).iter()) + { + if contains(*row, point) { + return Some(item.hit); + } + } + Some(GitPanelHit::Inside) + } + + /// Paint whichever overflow view is active — the menu or a subview. + pub(super) fn paint_overflow(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + match self.state.overflow_view { + GitOverflowView::Menu => self.paint_overflow_menu(cx, panel_rect), + GitOverflowView::RemoteSettings => self.paint_remote_settings(cx, panel_rect), + } + } + + /// Hit-test whichever overflow view is active. + pub(super) fn overflow_hit_dispatch( + &self, + panel_rect: Rect, + point: Point2D, + ) -> Option { + match self.state.overflow_view { + GitOverflowView::Menu => self.overflow_hit(panel_rect, point), + GitOverflowView::RemoteSettings => self.remote_settings_hit(panel_rect, point), + } + } + + // ── Remote-settings subview ────────────────────────────────────── + + /// The remote-settings subview popover rect. + pub(super) fn remote_settings_panel(&self, panel_rect: Rect) -> Rect { + let (_, _, overflow_btn) = self.ready_header_buttons(panel_rect); + let w = RS_W.min(panel_rect.size.x - PAD * 2.0); + let h = RS_PAD * 2.0 + RS_BACK_H + 8.0 + RS_ROW_H + 8.0 + RS_ROW_H; + let right = overflow_btn.origin.x + overflow_btn.size.x; + Rect { + origin: Point2D::new(right - w, overflow_btn.origin.y + overflow_btn.size.y + 4.0), + size: Point2D::new(w, h), + } + } + + /// The subview's interactive sub-rects: `(back, url_input, set, + /// https_input, login)`. Shared by paint + hit-test (+ tests). + pub(super) fn remote_settings_rects(&self, panel_rect: Rect) -> (Rect, Rect, Rect, Rect, Rect) { + let p = self.remote_settings_panel(panel_rect); + let left = p.origin.x + RS_PAD; + let inner_w = p.size.x - RS_PAD * 2.0; + let back = Rect { + origin: Point2D::new(left, p.origin.y + RS_PAD), + size: Point2D::new(inner_w, RS_BACK_H), + }; + let url_top = back.origin.y + RS_BACK_H + 8.0; + let field_w = inner_w - RS_BTN_W - RS_GAP; + let url_input = Rect { + origin: Point2D::new(left, url_top), + size: Point2D::new(field_w, RS_ROW_H), + }; + let set = Rect { + origin: Point2D::new(left + field_w + RS_GAP, url_top), + size: Point2D::new(RS_BTN_W, RS_ROW_H), + }; + let cred_top = url_top + RS_ROW_H + 8.0; + let https_input = Rect { + origin: Point2D::new(left, cred_top), + size: Point2D::new(field_w, RS_ROW_H), + }; + let login = Rect { + origin: Point2D::new(left + field_w + RS_GAP, cred_top), + size: Point2D::new(RS_BTN_W, RS_ROW_H), + }; + (back, url_input, set, https_input, login) + } + + /// Paint the remote-settings subview. + pub(super) fn paint_remote_settings(&self, cx: &mut PaintCx<'_>, panel_rect: Rect) { + let t = self.theme; + let p = self.remote_settings_panel(panel_rect); + cx.backend.fill_round_rect(p, 8.0, t.popover); + cx.backend.stroke_round_rect(p, 8.0, t.border, 1.0); + let (back, url_input, set, https_input, login) = self.remote_settings_rects(panel_rect); + // ‹ Back header row. + draw_icon( + cx.backend, + Icon::ChevronLeft, + Point2D::new(back.origin.x, back.origin.y + (back.size.y - 14.0) / 2.0), + 14.0, + t.muted_foreground, + 1.5, + ); + self.text( + cx, + self.t("git.remote.settingsHeading"), + back.origin.x + 20.0, + back.origin.y + back.size.y / 2.0 + 4.0, + 12.0, + t.foreground, + ); + // Remote-URL field + "Save". + self.paint_menu_input( + cx, + url_input, + &self.state.remote_draft, + self.t("git.remote.urlPlaceholder"), + self.state.remote_focused, + ); + self.paint_button( + cx, + set, + self.t("git.remote.saveButton"), + !self.state.remote_draft.trim().is_empty(), + true, + ); + // HTTPS-credential field + "Login". + self.paint_menu_input( + cx, + https_input, + &self.state.https_draft, + self.t("git.panel.httpsPlaceholder"), + self.state.https_focused, + ); + self.paint_button( + cx, + login, + self.t("git.panel.login"), + !self.state.https_draft.trim().is_empty(), + false, + ); + } + + /// Hit-test the remote-settings subview. + pub(super) fn remote_settings_hit( + &self, + panel_rect: Rect, + point: Point2D, + ) -> Option { + let p = self.remote_settings_panel(panel_rect); + if !contains(p, point) { + return None; + } + let (back, url_input, set, https_input, login) = self.remote_settings_rects(panel_rect); + if contains(back, point) { + return Some(GitPanelHit::OverflowBack); + } + if contains(url_input, point) { + return Some(GitPanelHit::RemoteInput); + } + if contains(set, point) { + return Some(GitPanelHit::SetRemote); + } + if contains(https_input, point) { + return Some(GitPanelHit::HttpsInput); + } + if contains(login, point) { + return Some(GitPanelHit::SetHttpsAuth); + } + Some(GitPanelHit::Inside) + } + + /// A simple popover input box — rounded field + draft / placeholder + /// text + a blink-free caret bar when focused. + fn paint_menu_input( + &self, + cx: &mut PaintCx<'_>, + rect: Rect, + value: &str, + placeholder: &str, + focused: bool, + ) { + let t = self.theme; + cx.backend.fill_round_rect(rect, 6.0, t.muted); + let border = if focused { + alpha(t.primary, 0.50) + } else { + alpha(t.border, 0.70) + }; + cx.backend.stroke_round_rect(rect, 6.0, border, 1.0); + let baseline = rect.origin.y + rect.size.y / 2.0 + 4.0; + if value.is_empty() && !focused { + self.text( + cx, + placeholder, + rect.origin.x + 8.0, + baseline, + 11.0, + alpha(t.muted_foreground, 0.70), + ); + } else { + let shown = truncate(value, 30); + let shown = if focused { format!("{shown}|") } else { shown }; + self.text( + cx, + &shown, + rect.origin.x + 8.0, + baseline, + 11.0, + t.foreground, + ); + } + } +} + +/// A colour at `factor` of its current alpha (Tailwind `/NN`). +fn alpha(c: Color, factor: f32) -> Color { + Color { + a: c.a * factor, + ..c + } +} diff --git a/crates/op-editor-ui/src/widgets/git_panel_ready.rs b/crates/op-editor-ui/src/widgets/git_panel_ready.rs new file mode 100644 index 00000000..14811587 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_ready.rs @@ -0,0 +1,357 @@ +//! Ready-state (clean bound-repo) view for [`GitPanel`] — a port of +//! the TS `GitPanelReady`: a compact header (branch button + pull/push +//! + overflow `…`), a commit textarea, and the recent-commit history. +//! +//! Split out of `git_panel.rs` to keep that file under the repo's +//! 800-line cap. The merge / loading / not-repo states keep the classic +//! header + body in `git_panel.rs`; only the clean bound-repo view is +//! the TS popover layout here. +//! +//! Geometry is shared by paint + hit-test through the `ready_*` rect +//! helpers, and the branch button uses a char-width heuristic (not text +//! measurement) so the pure-geometry hit-test agrees with paint. + +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}; + +/// Header bar height (TS `px-2.5 py-1.5` around a 28 px `icon-sm` +/// button = 6 + 28 + 6 = 40 px row). +const HEADER_H: f32 = 40.0; +/// 28 px ghost icon button (TS `size="icon-sm"` = `h-7 w-7`). +const ICON_BTN: f32 = 28.0; +/// Horizontal inset for the ready view — tighter than the classic +/// body's `PAD` (16) to match the TS header `px-2.5` (10 px). Shared +/// by the header, commit box, and history so the column lines up. +const READY_PAD: f32 = 10.0; +/// Commit-box geometry — the TS `p-3` outer wrapper (12 px) around a +/// `rounded-lg` card (~74 px: 2-row textarea + `h-6` button row). +const COMMIT_TOP: f32 = HEADER_H + 12.0; +const COMMIT_H: f32 = 74.0; +/// History section. The commit box owns the only divider below it; the +/// label sits ~16 px under the box bottom and the first row ~20 px +/// under the label so the rhythm matches the TS list `py-1.5`. +const HISTORY_LABEL: f32 = COMMIT_TOP + COMMIT_H + 16.0; +const HISTORY_FIRST: f32 = HISTORY_LABEL + 20.0; +const ROW_H: f32 = 26.0; +const MAX_COMMITS: usize = 8; +const SUMMARY_MAX: usize = 34; +/// Per-char advance heuristic for the branch label (keeps paint + +/// hit-test aligned without measuring text). +const BRANCH_CHAR_W: f32 = 7.5; + +impl GitPanel<'_> { + /// `true` when the panel shows the TS ready layout — a clean, + /// bound repository with no merge in progress and no diff / + /// resolve takeover. Dirty working trees keep the classic status + /// + staging body so per-file staging stays reachable. + pub(super) fn is_ready_state(&self) -> bool { + self.state.in_repo + && !self.state.loading + && !self.state.merging + && self.state.diff.is_none() + && self.state.merge_resolve.is_none() + && self.state.changed_files.is_empty() + } + + /// The panel height for the ready view — header + commit box + the + /// recent-commit rows (at least one placeholder row). + pub(super) fn ready_height(&self) -> f32 { + let rows = self.state.recent_commits.len().clamp(1, MAX_COMMITS); + HISTORY_FIRST + rows as f32 * ROW_H + PAD + } + + /// Resolved branch label (`detached HEAD` fallback). + fn ready_branch_label(&self) -> String { + self.state + .branch + .clone() + .unwrap_or_else(|| self.t("git.panel.detachedHead").to_string()) + } + + /// Branch-button rect — `⎇ ▾`, left-aligned in the header. + /// Width is clamped so a long branch name can never push the + /// pull / push / overflow icon cluster past the right edge. + pub(super) fn ready_branch_rect(&self, rect: Rect) -> Rect { + let label = self.ready_branch_label(); + let raw_w = 18.0 + label.chars().count() as f32 * BRANCH_CHAR_W + 16.0; + // Reserve room for pull + push + overflow (3 ICON_BTN) + the + // inter-button gaps (2px ×3) + both READY_PAD insets. + let reserved = 3.0 * ICON_BTN + 6.0 + READY_PAD * 2.0; + let w = raw_w.min((rect.size.x - reserved).max(40.0)); + Rect { + origin: Point2D::new( + rect.origin.x + READY_PAD, + rect.origin.y + (HEADER_H - ICON_BTN) / 2.0, + ), + size: Point2D::new(w, ICON_BTN), + } + } + + /// The three header icon buttons: (pull `↓`, push `↑`, overflow `…`). + pub(super) fn ready_header_buttons(&self, rect: Rect) -> (Rect, Rect, Rect) { + let y = rect.origin.y + (HEADER_H - ICON_BTN) / 2.0; + let branch = self.ready_branch_rect(rect); + let pull_x = branch.origin.x + branch.size.x + 2.0; + let push_x = pull_x + ICON_BTN + 2.0; + let overflow_x = rect.origin.x + rect.size.x - READY_PAD - ICON_BTN; + let mk = |x: f32| Rect { + origin: Point2D::new(x, y), + size: Point2D::new(ICON_BTN, ICON_BTN), + }; + (mk(pull_x), mk(push_x), mk(overflow_x)) + } + + /// The bordered commit textarea box. + pub(super) fn ready_commit_box(&self, rect: Rect) -> Rect { + Rect { + origin: Point2D::new(rect.origin.x + READY_PAD, rect.origin.y + COMMIT_TOP), + size: Point2D::new(rect.size.x - READY_PAD * 2.0, COMMIT_H), + } + } + + /// The `保存为里程碑` button anchored bottom-right inside the box. + pub(super) fn ready_commit_btn(&self, rect: Rect) -> Rect { + let b = self.ready_commit_box(rect); + let w = 96.0; + let h = 24.0; + Rect { + origin: Point2D::new( + b.origin.x + b.size.x - w - 6.0, + b.origin.y + b.size.y - h - 6.0, + ), + size: Point2D::new(w, h), + } + } + + /// Whether the "Save milestone" button can fire — a non-empty + /// message (TS `canSubmit = commitMessage.trim().length > 0`). The + /// ready-view commit saves the live design to the tracked `.op` and + /// commits it in one step, so it does NOT require a pre-staged file + /// the way the classic staging body's Commit does. + fn ready_can_commit(&self) -> bool { + !self.state.commit_message.trim().is_empty() + } + + /// Paint the ready view. + pub(super) fn paint_ready(&self, cx: &mut PaintCx<'_>, rect: Rect) { + let t = self.theme; + let left = rect.origin.x + READY_PAD + 2.0; + let top = rect.origin.y; + let width = rect.size.x; + + // ── Header bar (TS `border-b border-border/60 bg-card/40`) ── + cx.backend.fill_rect( + Rect { + origin: rect.origin, + size: Point2D::new(width, HEADER_H), + }, + alpha(t.card, 0.40), + ); + cx.backend.fill_rect( + Rect { + origin: Point2D::new(rect.origin.x, top + HEADER_H), + size: Point2D::new(width, 1.0), + }, + alpha(t.border, 0.60), + ); + + // Branch button — `⎇ ▾`. + let branch_r = self.ready_branch_rect(rect); + let cy = top + HEADER_H / 2.0; + draw_icon( + cx.backend, + Icon::GitBranch, + Point2D::new(branch_r.origin.x, cy - 7.0), + 14.0, + t.foreground, + 1.5, + ); + // Truncate to the clamped button width (label area = button + // width minus the 18px icon gutter and the 12px chevron). + let max_chars = ((branch_r.size.x - 30.0) / BRANCH_CHAR_W).max(1.0) as usize; + let label = truncate(&self.ready_branch_label(), max_chars); + self.text( + cx, + &label, + branch_r.origin.x + 18.0, + cy + 4.0, + 13.0, + t.foreground, + ); + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new(branch_r.origin.x + branch_r.size.x - 12.0, cy - 5.0), + 10.0, + t.muted_foreground, + 1.5, + ); + + // Pull / Push / Overflow icon buttons. + let (pull_r, push_r, overflow_r) = self.ready_header_buttons(rect); + self.paint_ready_icon(cx, pull_r, Icon::ArrowDown, !self.state.pulling); + self.paint_ready_icon(cx, push_r, Icon::ArrowUp, !self.state.pushing); + self.paint_ready_icon(cx, overflow_r, Icon::MoreVertical, true); + + // ── Commit box (TS textarea + milestone button) ── + let box_r = self.ready_commit_box(rect); + cx.backend.fill_round_rect(box_r, 8.0, t.card); + let border = if self.state.commit_focused { + alpha(t.primary, 0.50) + } else { + alpha(t.border, 0.70) + }; + cx.backend.stroke_round_rect(box_r, 8.0, border, 1.0); + let msg = &self.state.commit_message; + if msg.is_empty() && !self.state.commit_focused { + self.text( + cx, + self.t("git.commit.placeholder"), + box_r.origin.x + 12.0, + box_r.origin.y + 22.0, + 12.0, + alpha(t.muted_foreground, 0.70), + ); + } else { + // Draw the message, then a separate blinking caret bar + // (not an inline `|`) so it animates on the same cadence as + // the chat / property inputs instead of sitting static. + let text_x = box_r.origin.x + 12.0; + let baseline = box_r.origin.y + 22.0; + self.text(cx, msg, text_x, baseline, 12.0, t.foreground); + let blink = + jian_core::anim::blink_visible(self.now_ms, self.state.commit_caret_anchor_ms, 530); + if self.state.commit_focused && blink { + let caret_x = text_x + cx.backend.measure_text(msg, 12.0) + 1.0; + cx.backend.fill_rect( + Rect { + origin: Point2D::new(caret_x, box_r.origin.y + 10.0), + size: Point2D::new(1.5, 15.0), + }, + t.foreground, + ); + } + } + self.paint_button( + cx, + self.ready_commit_btn(rect), + self.t("git.commit.submitButton"), + self.ready_can_commit(), + true, + ); + cx.backend.fill_rect( + Rect { + origin: Point2D::new(rect.origin.x, box_r.origin.y + box_r.size.y + 6.0), + size: Point2D::new(width, 1.0), + }, + alpha(t.border, 0.60), + ); + + // ── Recent-commit history ── + self.text( + cx, + self.t("git.panel.recentCommits"), + left, + top + HISTORY_LABEL, + 12.0, + t.muted_foreground, + ); + let mut y = top + HISTORY_FIRST; + if self.state.recent_commits.is_empty() { + self.text( + cx, + self.t("git.panel.noCommits"), + left, + y, + 12.0, + t.muted_foreground, + ); + } + for commit in self.state.recent_commits.iter().take(MAX_COMMITS) { + let summary = truncate(&commit.summary, SUMMARY_MAX); + self.text( + cx, + &format!("{} {}", commit.short_hash, summary), + left, + y, + 12.0, + t.foreground, + ); + y += ROW_H; + } + } + + /// One ghost icon button — a faint rounded slot + a centred glyph, + /// dimmed when disabled. + fn paint_ready_icon(&self, cx: &mut PaintCx<'_>, rect: Rect, icon: Icon, enabled: bool) { + let color = if enabled { + alpha(self.theme.foreground, 0.80) + } else { + alpha(self.theme.muted_foreground, 0.50) + }; + let s = 14.0; + let c = Point2D::new( + rect.origin.x + (rect.size.x - s) / 2.0, + rect.origin.y + (rect.size.y - s) / 2.0, + ); + draw_icon(cx.backend, icon, c, s, color, 1.5); + } + + /// One clickable rect per displayed recent-commit row. Shared by + /// [`GitPanel::paint_ready`] + [`GitPanel::ready_hit`] so paint + + /// hit-test stay aligned. + pub(super) fn ready_commit_row_rects(&self, rect: Rect) -> Vec { + // +9 centres the 26px click target on the 12px row text whose + // baseline is `HISTORY_FIRST + i*ROW_H` (was +4, bottom-biased). + let first = rect.origin.y + HISTORY_FIRST - ROW_H + 9.0; + (0..self.state.recent_commits.len().min(MAX_COMMITS)) + .map(|i| Rect { + origin: Point2D::new(rect.origin.x, first + i as f32 * ROW_H), + size: Point2D::new(rect.size.x, ROW_H), + }) + .collect() + } + + /// Map a press inside the ready view onto a [`GitPanelHit`]. + pub(super) fn ready_hit(&self, rect: Rect, point: Point2D) -> Option { + let (pull, push, overflow) = self.ready_header_buttons(rect); + // Save-milestone button first (it sits inside the commit box). + if self.ready_can_commit() && contains(self.ready_commit_btn(rect), point) { + return Some(GitPanelHit::CommitMilestone); + } + // Overflow is the right-anchored fixed element — test it before + // pull/push so the always-present `…` menu wins any residual + // pixel overlap (defense-in-depth on top of the branch clamp). + if contains(overflow, point) { + return Some(GitPanelHit::Overflow); + } + if contains(self.ready_branch_rect(rect), point) { + return Some(GitPanelHit::BranchPicker); + } + if contains(pull, point) { + return Some(GitPanelHit::Pull); + } + if contains(push, point) { + return Some(GitPanelHit::Push); + } + if contains(self.ready_commit_box(rect), point) { + return Some(GitPanelHit::CommitInput); + } + for (i, row) in self.ready_commit_row_rects(rect).iter().enumerate() { + if contains(*row, point) { + return Some(GitPanelHit::ShowCommitDiff(i)); + } + } + contains(rect, point).then_some(GitPanelHit::Inside) + } +} + +/// A colour at `factor` of its current alpha (Tailwind `/NN`). +fn alpha(c: Color, factor: f32) -> Color { + Color { + a: c.a * factor, + ..c + } +} 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 66510acc..afd7a579 100644 --- a/crates/op-editor-ui/src/widgets/git_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/git_panel_tests.rs @@ -4,8 +4,8 @@ use crate::widgets::git_panel::*; use crate::{Point2D, Rect}; use op_editor_core::{ - EditorState, GitCommitSummary, GitDiffView, GitFileEntry, GitPanelState, MergeConflictRow, - MergeResolveFile, MergeResolveState, + EditorState, GitCommitSummary, GitDiffView, GitFileEntry, GitOverflowView, GitPanelState, + MergeConflictRow, MergeResolveFile, MergeResolveState, }; fn state_with(panel: GitPanelState) -> EditorState { @@ -22,6 +22,22 @@ fn open_repo() -> GitPanelState { } } +/// A bound repo with a dirty working tree — leaves the TS ready view +/// (which only shows for a clean tree) and exercises the classic +/// status body: branch rows, the Commit/Refresh/Pull/Push row, the +/// working-tree status line, and the Remotes section. +fn dirty_repo() -> GitPanelState { + GitPanelState { + changed_files: vec![GitFileEntry { + path: "dirty.op".into(), + staged: false, + status: 'M', + }], + dirty_count: 1, + ..open_repo() + } +} + fn centre(r: Rect) -> Point2D { Point2D::new(r.origin.x + r.size.x / 2.0, r.origin.y + r.size.y / 2.0) } @@ -78,7 +94,10 @@ fn empty_history_reserves_a_placeholder_row() { #[test] fn hit_test_maps_each_action_region() { - let s = state_with(open_repo()); + // A dirty tree keeps the classic status body (the TS ready view + // only paints for a clean tree); that is where the 4-button row + // + commit input live. + let s = state_with(dirty_repo()); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); let rects = GitPanel::action_rects(rect, false); @@ -121,7 +140,7 @@ fn branch_rows_switch_to_non_current_branch() { let s = state_with(GitPanelState { branch: Some("main".to_string()), branches: vec!["feature".to_string(), "main".to_string()], - ..open_repo() + ..dirty_repo() }); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); @@ -144,7 +163,7 @@ fn branch_row_merge_button_dispatches_a_merge() { let s = state_with(GitPanelState { branch: Some("main".to_string()), branches: vec!["feature".to_string(), "main".to_string()], - ..open_repo() + ..dirty_repo() }); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); @@ -210,7 +229,7 @@ fn changed_file_rows_toggle_staging() { fn remotes_section_maps_the_input_and_set_button() { let s = state_with(GitPanelState { remotes: vec!["origin → git@host:org/repo.git".into()], - ..open_repo() + ..dirty_repo() }); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); @@ -301,17 +320,18 @@ fn truncate_caps_long_summaries() { #[test] fn dirty_status_line_opens_the_working_diff() { - // A clean tree → the status line is inert. + // A clean tree → the TS ready view, which has no working-tree + // status line at all, so no click anywhere yields a working diff. let clean = state_with(open_repo()); let panel = GitPanel::for_editor(&clean).unwrap(); let rect = panel_rect(&panel); let clean_hit = panel.hit_test(rect, centre(panel.status_rect(rect))); - assert_eq!(clean_hit, Some(GitPanelHit::Inside)); + assert_ne!(clean_hit, Some(GitPanelHit::ShowWorkingDiff)); - // A dirty tree → clicking the status line opens the diff. + // A dirty tree → the classic body's status line opens the diff. let dirty = state_with(GitPanelState { dirty_count: 3, - ..open_repo() + ..dirty_repo() }); let panel = GitPanel::for_editor(&dirty).unwrap(); let rect = panel_rect(&panel); @@ -340,7 +360,9 @@ fn commit_rows_open_a_commit_diff() { }); let panel = GitPanel::for_editor(&s).unwrap(); let rect = panel_rect(&panel); - let rows = panel.list_row_rects(rect); + // A clean tree shows the TS ready view; its history rows map to + // a commit's diff. + let rows = panel.ready_commit_row_rects(rect); assert_eq!(rows.len(), 2); assert_eq!( panel.hit_test(rect, centre(rows[0])), @@ -352,6 +374,172 @@ fn commit_rows_open_a_commit_diff() { ); } +#[test] +fn ready_view_maps_each_header_and_commit_region() { + // A clean bound repo → the TS ready layout. Its header exposes + // the branch picker + pull/push + overflow; the commit box is a + // focus target and its button commits a non-empty message. + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + commit_message: "ship it".to_string(), + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let (pull, push, overflow) = panel.ready_header_buttons(rect); + assert_eq!( + panel.hit_test(rect, centre(panel.ready_branch_rect(rect))), + Some(GitPanelHit::BranchPicker) + ); + assert_eq!(panel.hit_test(rect, centre(pull)), Some(GitPanelHit::Pull)); + assert_eq!(panel.hit_test(rect, centre(push)), Some(GitPanelHit::Push)); + assert_eq!( + panel.hit_test(rect, centre(overflow)), + Some(GitPanelHit::Overflow) + ); + // With a non-empty message the Save-milestone button fires (it + // saves the live design + commits, so no pre-staged file needed). + assert_eq!( + panel.hit_test(rect, centre(panel.ready_commit_btn(rect))), + Some(GitPanelHit::CommitMilestone) + ); + // The box body away from the button focuses the input. + let box_r = panel.ready_commit_box(rect); + let top_left = Point2D::new(box_r.origin.x + 6.0, box_r.origin.y + 6.0); + assert_eq!( + panel.hit_test(rect, top_left), + Some(GitPanelHit::CommitInput) + ); +} + +#[test] +fn ready_commit_button_is_inert_without_a_message() { + // An empty commit message → the button is not a commit target; + // the click falls through to the box's focus instead. + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + assert_eq!( + panel.hit_test(rect, centre(panel.ready_commit_btn(rect))), + Some(GitPanelHit::CommitInput) + ); +} + +#[test] +fn branch_picker_dropdown_switches_and_dismisses() { + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + branches: vec!["feature".to_string(), "main".to_string()], + branch_picker_open: true, + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let rows = panel.branch_picker_row_rects(rect); + assert_eq!(rows.len(), 2); + // Row 0 = feature (not current) → switch; row 1 = main (current) → no-op. + assert_eq!( + panel.hit_test(rect, centre(rows[0])), + Some(GitPanelHit::SwitchBranch(0)) + ); + assert_eq!( + panel.hit_test(rect, centre(rows[1])), + Some(GitPanelHit::Inside) + ); + // A click outside the dropdown (but inside the panel) dismisses it. + let outside = Point2D::new(rect.origin.x + rect.size.x / 2.0, rect.origin.y + 8.0); + assert_eq!( + panel.hit_test(rect, outside), + Some(GitPanelHit::DismissPopover) + ); + // An open popover is modal: a click FAR OUTSIDE the panel (e.g. on + // the canvas) also dismisses it rather than returning None (which + // would leave the popover stuck open). + let far = Point2D::new(rect.origin.x - 200.0, rect.origin.y + 400.0); + assert_eq!(panel.hit_test(rect, far), Some(GitPanelHit::DismissPopover)); +} + +#[test] +fn ready_long_branch_never_eats_the_overflow_button() { + // A long branch name must not push the pull/push cluster over the + // right-anchored `…` overflow button (the branch rect is clamped). + let s = state_with(GitPanelState { + branch: Some("feature/a-very-long-branch-name-indeed".to_string()), + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let (_, _, overflow) = panel.ready_header_buttons(rect); + assert_eq!( + panel.hit_test(rect, centre(overflow)), + Some(GitPanelHit::Overflow) + ); + // The branch button must not overlap the pull button either. + let branch = panel.ready_branch_rect(rect); + let (pull, _, _) = panel.ready_header_buttons(rect); + assert!( + branch.origin.x + branch.size.x <= pull.origin.x, + "branch button overruns the pull icon" + ); +} + +#[test] +fn overflow_menu_maps_its_entries() { + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + overflow_open: true, + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let rows = panel.overflow_row_rects(rect); + assert_eq!(rows.len(), 2); + assert_eq!( + panel.hit_test(rect, centre(rows[0])), + Some(GitPanelHit::OverflowRemoteSettings) + ); + assert_eq!( + panel.hit_test(rect, centre(rows[1])), + Some(GitPanelHit::OverflowSshKeys) + ); +} + +#[test] +fn overflow_remote_settings_subview_maps_inputs_and_back() { + let s = state_with(GitPanelState { + branch: Some("main".to_string()), + overflow_open: true, + overflow_view: GitOverflowView::RemoteSettings, + ..open_repo() + }); + let panel = GitPanel::for_editor(&s).unwrap(); + let rect = panel_rect(&panel); + let (back, url, set, https, login) = panel.remote_settings_rects(rect); + assert_eq!( + panel.hit_test(rect, centre(back)), + Some(GitPanelHit::OverflowBack) + ); + assert_eq!( + panel.hit_test(rect, centre(url)), + Some(GitPanelHit::RemoteInput) + ); + assert_eq!( + panel.hit_test(rect, centre(set)), + Some(GitPanelHit::SetRemote) + ); + assert_eq!( + panel.hit_test(rect, centre(https)), + Some(GitPanelHit::HttpsInput) + ); + assert_eq!( + panel.hit_test(rect, centre(login)), + Some(GitPanelHit::SetHttpsAuth) + ); +} + #[test] fn conflict_rows_open_a_file_diff_in_merge_mode() { let s = state_with(GitPanelState { diff --git a/crates/op-editor-ui/src/widgets/git_panel_text.rs b/crates/op-editor-ui/src/widgets/git_panel_text.rs new file mode 100644 index 00000000..e928b236 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/git_panel_text.rs @@ -0,0 +1,43 @@ +//! GitPanel text-drawing helpers (footer + one-line text + the +//! `Color` -> jian conversion), split out of `git_panel.rs` to keep +//! it under the 800-line cap. + +use super::git_panel::GitPanel; +use crate::widgets::PaintCx; +use crate::{Color, Point2D, TextLayout}; + +impl GitPanel<'_> { + /// Paint the menu hint at the panel foot. + pub(super) fn footer(&self, cx: &mut PaintCx<'_>, left: f32, y: f32) { + self.text( + cx, + self.t("git.panel.footer"), + left, + y, + 10.0, + self.theme.muted_foreground, + ); + } + + /// Draw one line of text. + pub(super) fn text( + &self, + cx: &mut PaintCx<'_>, + s: &str, + x: f32, + baseline_y: f32, + size: f32, + color: Color, + ) { + let layout = + TextLayout::single_run(s, "system-ui", size, to_jian(color), Point2D::new(0.0, 0.0)); + cx.backend.draw_text(&layout, Point2D::new(x, baseline_y)); + } +} + +fn to_jian(c: Color) -> jian_core::scene::Color { + fn ch(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) +} diff --git a/crates/op-editor-ui/src/widgets/icons.rs b/crates/op-editor-ui/src/widgets/icons.rs index 8b278a51..edd36f28 100644 --- a/crates/op-editor-ui/src/widgets/icons.rs +++ b/crates/op-editor-ui/src/widgets/icons.rs @@ -238,6 +238,8 @@ pub enum Icon { DistributeH, /// Lucide `align-vertical-distribute-center` — equal center spacing on Y. DistributeV, + /// Custom line-height glyph — typography section's 行高 input prefix. + LineHeight, } impl Icon { @@ -373,6 +375,7 @@ impl Icon { Icon::AlignBottom => ALIGN_BOTTOM, Icon::DistributeH => DISTRIBUTE_H, Icon::DistributeV => DISTRIBUTE_V, + Icon::LineHeight => LINE_HEIGHT, } } diff --git a/crates/op-editor-ui/src/widgets/icons_data.rs b/crates/op-editor-ui/src/widgets/icons_data.rs index 73df654b..b834fdf4 100644 --- a/crates/op-editor-ui/src/widgets/icons_data.rs +++ b/crates/op-editor-ui/src/widgets/icons_data.rs @@ -688,3 +688,15 @@ pub(super) const DISTRIBUTE_V: &[&str] = &[ "M7 14 H17 A2 2 0 0 1 19 16 V18 A2 2 0 0 1 17 20 H7 A2 2 0 0 1 5 18 V16 A2 2 0 0 1 7 14 Z", "M9 4 H15 A2 2 0 0 1 17 6 V8 A2 2 0 0 1 15 10 H9 A2 2 0 0 1 7 8 V6 A2 2 0 0 1 9 4 Z", ]; + +/// Line-height glyph for the typography section — a vertical +/// double-arrow beside three text rules. Ported from the TS +/// `LineHeightIcon` inline SVG (12×12), scaled to the 24×24 viewBox. +pub(super) const LINE_HEIGHT: &[&str] = &[ + "M4 4 L4 20", + "M8 8 L4 4 L0 8", + "M8 16 L4 20 L0 16", + "M11 6 L18 6", + "M11 12 L22 12", + "M11 18 L18 18", +]; diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 386dd7ee..fa8958ed 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -51,15 +51,21 @@ pub mod property_panel_icon; #[cfg(test)] mod property_panel_icon_tests; pub mod property_panel_image_fill; +#[cfg(test)] +mod property_panel_image_fill_tests; pub mod property_panel_image_node; mod property_panel_image_preview; pub mod property_panel_input_layout; pub mod property_panel_inputs; pub mod property_panel_layer; pub mod property_panel_layout; +#[cfg(test)] +mod property_panel_multi_select_tests; pub mod property_panel_sections; pub mod property_panel_snapshot; #[cfg(test)] +mod property_panel_test_support; +#[cfg(test)] mod property_panel_tests; pub mod property_panel_text; pub mod property_panel_visibility; @@ -116,16 +122,20 @@ pub mod design_md_panel; pub mod export_dialog; pub mod figma_import; pub mod figma_import_progress; +pub mod file_drop_overlay; pub mod file_menu; pub mod git_panel; mod git_panel_diff; mod git_panel_empty; mod git_panel_hit; +mod git_panel_menus; +mod git_panel_ready; mod git_panel_remotes; mod git_panel_resolve; mod git_panel_status; #[cfg(test)] mod git_panel_tests; +mod git_panel_text; pub mod icon_picker_panel; pub mod locale_picker; pub mod shape_picker; @@ -140,7 +150,7 @@ pub use text_input::{TextInput, TextInputState}; pub use tree::{TreeItem, TreeWidget}; pub use layer_panel::{LayerItem, LayerPanel}; -pub use property_panel::{PropertyPanel, PropertyPanelAction}; +pub use property_panel::{FontWeightChoice, PropertyPanel, PropertyPanelAction}; pub use toolbar::Toolbar; pub use canvas_viewport::{ diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index cbe15bb1..63d7b9a9 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -39,8 +39,8 @@ pub const PROPERTY_PANEL_WIDTH: f32 = 280.0; // `widgets::PropertyPanelAction` / `property_panel::PropertyPanelAction` // path is unchanged. pub use crate::widgets::property_panel_action::{ - FontFamilyChoice, LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, TextAlignValue, - TextGrowthValue, TextVerticalAlignValue, + FontFamilyChoice, FontWeightChoice, LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, + TextAlignValue, TextGrowthValue, TextVerticalAlignValue, }; // `SectionCapabilities` lives in `property_panel_layout.rs` @@ -84,6 +84,15 @@ pub struct PropertyPanel { pub fill_type_picker_open: bool, pub image_fill_popover_open: bool, pub font_family_picker_open: bool, + pub font_weight_picker_open: bool, + /// Hovered weight-dropdown row index (when the dropdown is open). + pub font_weight_picker_hover: Option, + /// Resolved padding edit mode (UI pin or derived from the node's + /// values) + whether the gear popover is open. + pub padding_edit_mode: op_editor_core::PaddingEditMode, + pub padding_mode_popover_open: bool, + /// Hovered padding-mode popover row index (gear popover open). + pub padding_mode_popover_hover: Option, /// True for multi-select aggregate (inputs inert, "N items"). pub is_multi: bool, /// Active header tab — toggled by Cmd+Shift+C. @@ -180,6 +189,19 @@ impl PropertyPanel { hug_height: snapshot.size_hug_height, clip_content: snapshot.size_clip_content, }; + // Padding edit mode: the user's gear pin (only while it still + // applies to the selected node — see `padding_edit_mode_anchor`), + // else derived from the node's four effective values (TS + // default-derives each frame). Anchor-scoping stops one node's + // pinned mode leaking into the next selection. + let pin_applies = ui.padding_edit_mode_anchor == state.selection.anchor.as_str(); + let padding_edit_mode = ui + .padding_edit_mode + .filter(|_| pin_applies) + .unwrap_or_else(|| { + let p = snapshot.layout_padding; + op_editor_core::PaddingEditMode::from_values(p.top, p.right, p.bottom, p.left) + }); Self { id: WidgetId::new(2000), snapshot, @@ -212,6 +234,11 @@ impl PropertyPanel { fill_type_picker_open: ui.fill_type_picker_open, image_fill_popover_open: ui.image_fill_popover_open, font_family_picker_open: ui.font_family_picker_open, + font_weight_picker_open: ui.font_weight_picker_open, + font_weight_picker_hover: ui.font_weight_picker_hover, + padding_edit_mode, + padding_mode_popover_open: ui.padding_mode_popover_open, + padding_mode_popover_hover: ui.padding_mode_popover_hover, is_multi, tab: ui.property_tab, export_format: ui.export_format, @@ -281,9 +308,14 @@ impl PropertyPanel { create_component: caps.create_component && self.snapshot.can_create_component, flex_layout: caps.flex_layout, flex_layout_mode: self.snapshot.flex_layout, + padding_edit_mode: self.padding_edit_mode, layout_justify: self.snapshot.layout_justify, layout_align: self.snapshot.layout_align, size_options: caps.size_options, + size_fill_width: self.snapshot.size_fill_width, + size_fill_height: self.snapshot.size_fill_height, + size_hug_width: self.snapshot.size_hug_width, + size_hug_height: self.snapshot.size_hug_height, clip_content: self.snapshot.can_clip_content, text: caps.text && self.snapshot.text.is_some(), icon: self.snapshot.icon.is_some(), @@ -329,8 +361,10 @@ impl PropertyPanel { &self.snapshot.effects, self.fill_type_picker_open, self.font_family_picker_open, + self.font_weight_picker_open, self.export_scale_picker_open, self.export_format_picker_open, + self.padding_mode_popover_open, ); // Picker rows live in `rects` AFTER the dropdown rect, so // a row hit takes priority — `rev()` makes the picker rows @@ -362,8 +396,10 @@ impl PropertyPanel { &self.snapshot.effects, self.fill_type_picker_open, self.font_family_picker_open, + self.font_weight_picker_open, self.export_scale_picker_open, self.export_format_picker_open, + self.padding_mode_popover_open, ) .into_iter() .filter(|(a, _)| { @@ -515,6 +551,7 @@ impl Widget for PropertyPanel { y, w, ); + let flex_section_y = y; if caps.flex_layout { y = crate::widgets::property_panel_flex::paint_flex_section( cx, @@ -523,6 +560,7 @@ impl Widget for PropertyPanel { &edit_ctx, &self.labels, self.locale, + self.padding_edit_mode, x, y, w, @@ -663,6 +701,35 @@ impl Widget for PropertyPanel { ); } } + if caps.text && self.font_weight_picker_open { + if let Some(text) = self.snapshot.text.as_ref() { + crate::widgets::property_panel_text::paint_font_weight_picker( + cx, + &self.theme, + scrolled, + self.visible_sections(), + self.locale, + text.font_weight, + self.font_weight_picker_hover, + ); + } + } + // Padding mode-selector popover — overlays the sections below + // the gear. Anchored off the flex section's body top (after its + // header), matching the y the action-rect walker passes to + // `push_flex_action_rects`. + if caps.flex_layout && self.padding_mode_popover_open { + crate::widgets::property_panel_flex::paint_padding_mode_popover( + cx, + &self.theme, + self.locale, + self.padding_edit_mode, + self.padding_mode_popover_hover, + x, + flex_section_y + crate::widgets::property_panel_inputs::SECTION_HEADER_HEIGHT, + w, + ); + } // Export-section inline select popups — painted last so the // scale / format dropdown overlays sit above every section. if caps.export && (self.export_scale_picker_open || self.export_format_picker_open) { diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index f4339c64..1b4c347e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -84,6 +84,90 @@ impl FontFamilyChoice { } } +/// Named font-weight options for the typography weight dropdown — a +/// port of the TS `WEIGHT_OPTIONS` (thin … black). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FontWeightChoice { + Thin, + ExtraLight, + Light, + Regular, + Medium, + Semibold, + Bold, + ExtraBold, + Black, +} + +impl FontWeightChoice { + pub const ALL: [Self; 9] = [ + Self::Thin, + Self::ExtraLight, + Self::Light, + Self::Regular, + Self::Medium, + Self::Semibold, + Self::Bold, + Self::ExtraBold, + Self::Black, + ]; + + /// Numeric weight written to the node. + pub fn value(self) -> u16 { + match self { + Self::Thin => 100, + Self::ExtraLight => 200, + Self::Light => 300, + Self::Regular => 400, + Self::Medium => 500, + Self::Semibold => 600, + Self::Bold => 700, + Self::ExtraBold => 800, + Self::Black => 900, + } + } + + /// The bare numeric weight string (`"100"` … `"900"`) for the + /// dropdown's "number + name" rows. + pub fn numeric_label(self) -> &'static str { + match self { + Self::Thin => "100", + Self::ExtraLight => "200", + Self::Light => "300", + Self::Regular => "400", + Self::Medium => "500", + Self::Semibold => "600", + Self::Bold => "700", + Self::ExtraBold => "800", + Self::Black => "900", + } + } + + /// i18n key for the named label (`text.weight.*`). + pub fn label_key(self) -> &'static str { + match self { + Self::Thin => "text.weight.thin", + Self::ExtraLight => "text.weight.extralight", + Self::Light => "text.weight.light", + Self::Regular => "text.weight.regular", + Self::Medium => "text.weight.medium", + Self::Semibold => "text.weight.semibold", + Self::Bold => "text.weight.bold", + Self::ExtraBold => "text.weight.extrabold", + Self::Black => "text.weight.black", + } + } + + /// The named choice nearest to a numeric weight — used to show the + /// node's current weight in the dropdown trigger. + pub fn nearest(weight: u16) -> Self { + Self::ALL + .into_iter() + .min_by_key(|c| (c.value() as i32 - weight as i32).abs()) + .unwrap_or(Self::Regular) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LayoutAlignValue { Start, @@ -208,4 +292,15 @@ pub enum PropertyPanelAction { SetTextGrowth(TextGrowthValue), ToggleFontFamilyPicker, SetFontFamily(FontFamilyChoice), + /// User clicked the typography weight dropdown — host toggles + /// `editor_ui.font_weight_picker_open`. + ToggleFontWeightPicker, + /// User picked a named weight from the dropdown. + SetFontWeight(FontWeightChoice), + /// User clicked the padding-section gear — host toggles + /// `editor_ui.padding_mode_popover_open`. + TogglePaddingModePopover, + /// User picked a padding edit mode in the gear popover — host pins + /// `editor_ui.padding_edit_mode` + reshapes the value. + SetPaddingMode(op_editor_core::PaddingEditMode), } diff --git a/crates/op-editor-ui/src/widgets/property_panel_export.rs b/crates/op-editor-ui/src/widgets/property_panel_export.rs index 4f782c02..79b93695 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_export.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_export.rs @@ -116,8 +116,10 @@ pub fn paint_export_picker( effects, false, false, + false, scale_open, format_open, + false, ); if scale_open { let rows: Vec<(&str, bool, Rect)> = rects diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_fill.rs index c4a4f278..2207eb03 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill.rs @@ -11,8 +11,8 @@ use crate::widgets::property_panel::NodeSnapshot; use crate::widgets::property_panel_image_preview::paint_image_preview; use crate::widgets::property_panel_inputs::{ format_color_hex, paint_section_divider, paint_section_label_with_add, to_jian_color, - HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, - TAB_HEIGHT, + CREATE_COMPONENT_BLOCK_H, HEADER_HEIGHT, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, + SECTION_HEADER_HEIGHT, TAB_HEIGHT, }; use crate::widgets::property_panel_layout::{fill_body_height_with_stops, VisibleSections}; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; @@ -58,7 +58,7 @@ pub fn paint_fill_type_picker( y += TAB_HEIGHT; y += HEADER_HEIGHT; if visible.create_component { - y += 8.0 + 36.0 + 12.0; + y += CREATE_COMPONENT_BLOCK_H; } // Position section. y += SECTION_HEADER_HEIGHT; @@ -66,7 +66,10 @@ pub fn paint_fill_type_picker( y += INPUT_HEIGHT + 12.0; y += SECTION_GAP; if visible.flex_layout { - y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode); + y += crate::widgets::property_panel_flex::flex_section_height( + visible.flex_layout_mode, + visible.padding_edit_mode, + ); } if visible.size_options { y += SECTION_HEADER_HEIGHT; diff --git a/crates/op-editor-ui/src/widgets/property_panel_flex.rs b/crates/op-editor-ui/src/widgets/property_panel_flex.rs index 5359c296..45268a20 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_flex.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_flex.rs @@ -6,13 +6,14 @@ use crate::widgets::property_panel::{ LayoutAlignValue, LayoutJustifyValue, NodeSnapshot, PropertyPanelAction, }; use crate::widgets::property_panel_inputs::{ - paint_input_with_prefix_focused, paint_section_divider, paint_section_label, to_jian_color, - INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, + paint_input_with_prefix_focused, paint_input_with_suffix_focused, paint_section_divider, + paint_section_label, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, + SECTION_HEADER_HEIGHT, }; use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; use crate::widgets::PaintCx; use crate::{Point2D, Rect, TextLayout}; -use op_editor_core::{FlexLayout, PropertyFocus}; +use op_editor_core::{FlexLayout, PaddingEditMode, PropertyFocus}; const DIR_BUTTON_W: f32 = 56.0; const DIR_BUTTON_H: f32 = 32.0; @@ -22,36 +23,96 @@ const SUB_LABEL_H: f32 = 18.0; const GRID_CELL_W: f32 = 34.0; const GRID_CELL_H: f32 = 22.0; const GRID_GAP: f32 = 3.0; -const GAP_BUTTON_H: f32 = 24.0; +/// Internal padding of the alignment grid box (TS `p-2` == 8px) — +/// insets the dot cells inside the `theme.muted` box so the box top +/// stays flush with the gap-input top in the right column. +const GRID_PAD: f32 = 8.0; +/// Horizontal offset from the grid's cell-area left edge to the gap +/// column. The grid box is `grid_w + 2*GRID_PAD` wide; this leaves an +/// 8px (TS `gap-2`) visual gap so the muted grid box and the muted gap +/// pill don't merge into one blob. +const GRID_COL_GAP: f32 = GRID_PAD * 2.0 + 8.0; +/// Gap radio-row height (the row-0 number input is `GAP_ROW_H` tall — +/// shorter than the standard 30px input for a tighter cluster) and the +/// gap between rows. Three rows = 20×3 + 2×2 = 64, which fits inside the +/// alignment grid's 88px block (so the block height — and every section +/// below — is unchanged), just more compact. +const GAP_ROW_H: f32 = 20.0; +const GAP_ROW_GAP: f32 = 2.0; +/// RadioCircle geometry (compact ring + dot). +const RADIO_SIZE: f32 = 13.0; +const RADIO_DOT: f32 = 7.0; +/// x-offset from a radio row's left to its content (TS `gap-1.5` = 6px +/// + the 14px circle). +const RADIO_GUTTER: f32 = 6.0 + RADIO_SIZE; const PADDING_ROW_GAP: f32 = 6.0; fn alignment_grid_h() -> f32 { - GRID_CELL_H * 3.0 + GRID_GAP * 2.0 + GRID_CELL_H * 3.0 + GRID_GAP * 2.0 + GRID_PAD * 2.0 } fn gap_column_h() -> f32 { - INPUT_HEIGHT + 4.0 + GAP_BUTTON_H * 3.0 + GAP_ROW_H * 3.0 + GAP_ROW_GAP * 2.0 +} + +/// Top-Y of gap radio row `i` (0 = numeric/input, 1 = space-between, +/// 2 = space-around). Single source of truth so paint + both hit-test +/// walkers can never drift. +fn gap_row_y(grid_y: f32, i: usize) -> f32 { + grid_y + i as f32 * (GAP_ROW_H + GAP_ROW_GAP) } fn alignment_block_body_h() -> f32 { alignment_grid_h().max(gap_column_h()) } -pub fn flex_section_height(active: FlexLayout) -> f32 { +/// Geometry of the padding-section gear + its mode popover, anchored off +/// the flex section's top `y` (same origin `push_flex_action_rects` +/// uses). Returns `(gear_rect, popover_box, [single, axis, individual] +/// row rects)` so paint + hit-test share one source of truth. +fn padding_mode_popover_layout(x0: f32, y: f32, width: f32) -> (Rect, Rect, [Rect; 3]) { + let grid_y = y + DIR_BUTTON_H + 12.0 + ADVANCED_TOP_GAP + SUB_LABEL_H; + let sublabel_y = grid_y + alignment_block_body_h() + 8.0; + let gear = Rect { + origin: Point2D::new(x0 + width - PAD_X - 18.0, sublabel_y - 2.0), + size: Point2D::new(18.0, 18.0), + }; + let pop_w = 190.0_f32.min(width - PAD_X * 2.0); + let pop_x = gear.origin.x + 18.0 - pop_w; // right-aligned under the gear + let pad = 6.0; + let title_h = 22.0; + let row_h = 26.0; + let pop_box = Rect { + origin: Point2D::new(pop_x, sublabel_y + 20.0), + size: Point2D::new(pop_w, pad * 2.0 + title_h + row_h * 3.0), + }; + let first_row = pop_box.origin.y + pad + title_h; + let row = |i: usize| Rect { + origin: Point2D::new(pop_x + pad, first_row + i as f32 * row_h), + size: Point2D::new(pop_w - pad * 2.0, row_h), + }; + (gear, pop_box, [row(0), row(1), row(2)]) +} + +pub fn flex_section_height(active: FlexLayout, padding_mode: PaddingEditMode) -> f32 { let base = SECTION_HEADER_HEIGHT + DIR_BUTTON_H + 12.0; if active == FlexLayout::Free { - base + SECTION_GAP - } else { - base + ADVANCED_TOP_GAP - + SUB_LABEL_H - + alignment_block_body_h() - + 8.0 - + SUB_LABEL_H - + INPUT_HEIGHT * 2.0 - + PADDING_ROW_GAP - + 12.0 - + SECTION_GAP + return base + SECTION_GAP; } + // Padding input ROWS: Individual is a 2×2 grid (2 rows); Single + // (1 input) and Axis (2 inputs side-by-side) are a single row. + let rows = if padding_mode == PaddingEditMode::Individual { + 2.0 + } else { + 1.0 + }; + let padding_block = SUB_LABEL_H + INPUT_HEIGHT * rows + PADDING_ROW_GAP * (rows - 1.0) + 12.0; + base + ADVANCED_TOP_GAP + + SUB_LABEL_H + + alignment_block_body_h() + + 8.0 + + padding_block + + SECTION_GAP } #[allow(clippy::too_many_arguments)] @@ -62,6 +123,7 @@ pub fn paint_flex_section( edit: &EditContext<'_>, labels: &PropertyLabels, locale: op_editor_core::Locale, + padding_mode: PaddingEditMode, x: f32, y: f32, width: f32, @@ -72,18 +134,21 @@ pub fn paint_flex_section( if snapshot.flex_layout != FlexLayout::Free { y += ADVANCED_TOP_GAP; y = paint_alignment_and_gap(cx, theme, snapshot, edit, locale, x, y, width); - y = paint_padding_inputs(cx, theme, snapshot, edit, locale, x, y, width); + y = paint_padding_inputs(cx, theme, snapshot, edit, locale, padding_mode, x, y, width); } paint_section_divider(cx, theme, x, y, width); y + SECTION_GAP } +#[allow(clippy::too_many_arguments)] pub fn push_flex_input_rects( rects: &mut Vec<(PropertyFocus, Rect)>, x0: f32, y: f32, width: f32, active: FlexLayout, + justify: LayoutJustifyValue, + padding_mode: PaddingEditMode, ) { if active == FlexLayout::Free { return; @@ -91,41 +156,63 @@ pub fn push_flex_input_rects( let usable_w = width - PAD_X * 2.0; let mut y = y + SECTION_HEADER_HEIGHT + DIR_BUTTON_H + 12.0 + ADVANCED_TOP_GAP; let grid_w = GRID_CELL_W * 3.0 + GRID_GAP * 2.0; - let gap_x = x0 + PAD_X + grid_w + 16.0; - let gap_w = usable_w - grid_w - 16.0; + let gap_x = x0 + PAD_X + grid_w + GRID_COL_GAP; + let gap_w = usable_w - grid_w - GRID_COL_GAP; y += SUB_LABEL_H; - rects.push(( - PropertyFocus::LayoutGap, - Rect { - origin: Point2D::new(gap_x, y), - size: Point2D::new(gap_w, INPUT_HEIGHT), - }, - )); - y += alignment_block_body_h() + 8.0; - y += SUB_LABEL_H; - let half_w = (usable_w - 8.0) / 2.0; - let focuses = [ - PropertyFocus::PaddingTop, - PropertyFocus::PaddingRight, - PropertyFocus::PaddingBottom, - PropertyFocus::PaddingLeft, - ]; - for (i, focus) in focuses.into_iter().enumerate() { - let row = i / 2; - let col = i % 2; + // Row 0 of the gap radio column: the number-input hit area starts + // PAST the radio circle so clicking the circle dispatches + // SetLayoutJustify(Start) (action rect) while the number area + // focuses the input. Space-between / space-around auto-distribute + // the gap, so the input is disabled — emit no focus rect for it. + let gap_disabled = matches!( + justify, + LayoutJustifyValue::SpaceBetween | LayoutJustifyValue::SpaceAround + ); + if !gap_disabled { rects.push(( - focus, + PropertyFocus::LayoutGap, Rect { - origin: Point2D::new( - x0 + PAD_X + col as f32 * (half_w + 8.0), - y + row as f32 * (INPUT_HEIGHT + PADDING_ROW_GAP), - ), - size: Point2D::new(half_w, INPUT_HEIGHT), + origin: Point2D::new(gap_x + RADIO_GUTTER, gap_row_y(y, 0)), + size: Point2D::new(gap_w - RADIO_GUTTER, GAP_ROW_H), }, )); } + y += alignment_block_body_h() + 8.0; + y += SUB_LABEL_H; + let half_w = (usable_w - 8.0) / 2.0; + let cell = |col: f32, row: f32| Rect { + origin: Point2D::new( + x0 + PAD_X + col * (half_w + 8.0), + y + row * (INPUT_HEIGHT + PADDING_ROW_GAP), + ), + size: Point2D::new(half_w, INPUT_HEIGHT), + }; + // Emit only the focus rects the active mode paints; the multi-side + // write happens at commit time (see commit_property_edit). + match padding_mode { + PaddingEditMode::Single => { + rects.push(( + PropertyFocus::PaddingTop, + Rect { + origin: Point2D::new(x0 + PAD_X, y), + size: Point2D::new(usable_w, INPUT_HEIGHT), + }, + )); + } + PaddingEditMode::Axis => { + rects.push((PropertyFocus::PaddingRight, cell(0.0, 0.0))); + rects.push((PropertyFocus::PaddingTop, cell(1.0, 0.0))); + } + PaddingEditMode::Individual => { + rects.push((PropertyFocus::PaddingTop, cell(0.0, 0.0))); + rects.push((PropertyFocus::PaddingRight, cell(1.0, 0.0))); + rects.push((PropertyFocus::PaddingBottom, cell(0.0, 1.0))); + rects.push((PropertyFocus::PaddingLeft, cell(1.0, 1.0))); + } + } } +#[allow(clippy::too_many_arguments)] pub fn push_flex_action_rects( out: &mut Vec<(PropertyPanelAction, Rect)>, x0: f32, @@ -133,6 +220,7 @@ pub fn push_flex_action_rects( width: f32, active: FlexLayout, justify: LayoutJustifyValue, + padding_mode_popover_open: bool, ) { let row_x = x0 + PAD_X; let modes = [ @@ -183,39 +271,47 @@ pub fn push_flex_action_rects( action, Rect { origin: Point2D::new( - x0 + PAD_X + col as f32 * (GRID_CELL_W + GRID_GAP), - grid_y + row as f32 * (GRID_CELL_H + GRID_GAP), + x0 + PAD_X + GRID_PAD + col as f32 * (GRID_CELL_W + GRID_GAP), + grid_y + GRID_PAD + row as f32 * (GRID_CELL_H + GRID_GAP), ), size: Point2D::new(GRID_CELL_W, GRID_CELL_H), }, )); } } - let gap_x = x0 + PAD_X + grid_w + 16.0; - let gap_w = usable_w - grid_w - 16.0; - let mut gap_y = grid_y + INPUT_HEIGHT + 4.0; - for (action, _) in [ - ( - PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::Start), - "numeric", - ), - ( - PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::SpaceBetween), - "between", - ), - ( - PropertyPanelAction::SetLayoutJustify(LayoutJustifyValue::SpaceAround), - "around", - ), - ] { + let gap_x = x0 + PAD_X + grid_w + GRID_COL_GAP; + let gap_w = usable_w - grid_w - GRID_COL_GAP; + // 3 gap radio rows. Row 0 (numeric) only takes the circle as its + // select target — the rest of that row is the number input. Rows + // 1/2 are whole-row select targets (TS cursor-pointer rows). + let rows = [ + (LayoutJustifyValue::Start, true), + (LayoutJustifyValue::SpaceBetween, false), + (LayoutJustifyValue::SpaceAround, false), + ]; + for (i, (justify_value, circle_only)) in rows.into_iter().enumerate() { + let row_w = if circle_only { RADIO_GUTTER } else { gap_w }; out.push(( - action, + PropertyPanelAction::SetLayoutJustify(justify_value), Rect { - origin: Point2D::new(gap_x, gap_y), - size: Point2D::new(gap_w, GAP_BUTTON_H), + origin: Point2D::new(gap_x, gap_row_y(grid_y, i)), + size: Point2D::new(row_w, GAP_ROW_H), }, )); - gap_y += GAP_BUTTON_H; + } + + // Padding gear + (when open) the 3 mode-radio rows. The radio rects + // are appended LAST so the open popover wins the hit-test over the + // sections it overlaps; the host gates them behind dismiss-on-press. + let (gear, _box, mode_rows) = padding_mode_popover_layout(x0, y, width); + out.push((PropertyPanelAction::TogglePaddingModePopover, gear)); + if padding_mode_popover_open { + for (i, rect) in mode_rows.into_iter().enumerate() { + out.push(( + PropertyPanelAction::SetPaddingMode(PaddingEditMode::ALL[i]), + rect, + )); + } } } @@ -282,61 +378,83 @@ fn paint_alignment_and_gap( x + PAD_X, y, ); - paint_sub_label( - cx, - theme, - op_i18n::translate(locale, "layout.gap"), - x + PAD_X + grid_w + 16.0, - y, - ); + // No "间距" sub-label over the gap column — TS GapSection has no + // header; the three radio rows are the whole control. let grid_y = y + SUB_LABEL_H; paint_alignment_grid(cx, theme, snapshot, x + PAD_X, grid_y); - let gap_x = x + PAD_X + grid_w + 16.0; - let gap_w = usable_w - grid_w - 16.0; + let gap_x = x + PAD_X + grid_w + GRID_COL_GAP; + let gap_w = usable_w - grid_w - GRID_COL_GAP; + let content_x = gap_x + RADIO_GUTTER; + let content_w = gap_w - RADIO_GUTTER; + let circle_y = |row_y: f32| row_y + (GAP_ROW_H - RADIO_SIZE) / 2.0; + let justify = snapshot.layout_justify; + + // Row 0 — numeric: radio + bare number input (no "G" prefix). + let r0_y = gap_row_y(grid_y, 0); + paint_radio_circle( + cx, + theme, + gap_x, + circle_y(r0_y), + justify == LayoutJustifyValue::Start, + ); let gap_text = format_panel_number(snapshot.layout_gap); - paint_input_with_prefix_focused( - cx, - theme, - Rect { - origin: Point2D::new(gap_x, grid_y), - size: Point2D::new(gap_w, INPUT_HEIGHT), - }, - "G", - edit.value_for(PropertyFocus::LayoutGap, &gap_text), - edit.focus == Some(PropertyFocus::LayoutGap), - edit.caret_at(PropertyFocus::LayoutGap), + let gap_rect = Rect { + origin: Point2D::new(content_x, r0_y), + size: Point2D::new(content_w, GAP_ROW_H), + }; + // Space-between / space-around distribute the gap automatically, so + // the explicit gap value is ignored. Paint the input disabled + // (muted, no caret) — it also emits no focus rect, so edits are + // rejected (see `push_flex_input_rects`). + let is_space = matches!( + justify, + LayoutJustifyValue::SpaceBetween | LayoutJustifyValue::SpaceAround ); - let mut yy = grid_y + INPUT_HEIGHT + 4.0; - yy = paint_gap_mode_button( + if is_space { + cx.backend + .fill_round_rect(gap_rect, INPUT_RADIUS, theme.muted); + let muted = TextLayout::single_run( + &gap_text, + "system-ui", + 12.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend + .draw_text(&muted, Point2D::new(content_x + 10.0, r0_y + 14.0)); + } else { + paint_input_with_suffix_focused( + cx, + theme, + gap_rect, + edit.value_for(PropertyFocus::LayoutGap, &gap_text), + "", + edit.focus == Some(PropertyFocus::LayoutGap), + edit.caret_at(PropertyFocus::LayoutGap), + ); + } + + // Row 1 — space-between, Row 2 — space-around: radio + label. + let r1_y = gap_row_y(grid_y, 1); + paint_radio_circle( cx, theme, - locale, gap_x, - yy, - gap_w, - snapshot.layout_justify == LayoutJustifyValue::Start, - "layout.gap", + circle_y(r1_y), + justify == LayoutJustifyValue::SpaceBetween, ); - yy = paint_gap_mode_button( + paint_gap_row_label(cx, theme, locale, content_x, r1_y, "layout.spaceBetween"); + let r2_y = gap_row_y(grid_y, 2); + paint_radio_circle( cx, theme, - locale, gap_x, - yy, - gap_w, - snapshot.layout_justify == LayoutJustifyValue::SpaceBetween, - "layout.spaceBetween", - ); - let _ = paint_gap_mode_button( - cx, - theme, - locale, - gap_x, - yy, - gap_w, - snapshot.layout_justify == LayoutJustifyValue::SpaceAround, - "layout.spaceAround", + circle_y(r2_y), + justify == LayoutJustifyValue::SpaceAround, ); + paint_gap_row_label(cx, theme, locale, content_x, r2_y, "layout.spaceAround"); + grid_y + alignment_block_body_h() + 8.0 } @@ -347,6 +465,7 @@ fn paint_padding_inputs( snapshot: &NodeSnapshot, edit: &EditContext<'_>, locale: op_editor_core::Locale, + padding_mode: PaddingEditMode, x: f32, y: f32, width: f32, @@ -358,46 +477,124 @@ fn paint_padding_inputs( x + PAD_X, y, ); + // Gear at the right of the label row — opens the mode popover. + draw_icon( + cx.backend, + Icon::Settings, + Point2D::new(x + width - PAD_X - 14.0, y + 1.0), + 14.0, + theme.muted_foreground, + 1.5, + ); let usable_w = width - PAD_X * 2.0; let half_w = (usable_w - 8.0) / 2.0; - let mut yy = y + SUB_LABEL_H; - let rows = [ - (PropertyFocus::PaddingTop, "T", snapshot.layout_padding.top), - ( - PropertyFocus::PaddingRight, - "R", - snapshot.layout_padding.right, + let yy = y + SUB_LABEL_H; + let p = &snapshot.layout_padding; + let mut input = |focus: PropertyFocus, prefix: &str, value: f32, rect: Rect| { + let value = format_panel_number(value); + paint_input_with_prefix_focused( + cx, + theme, + rect, + prefix, + edit.value_for(focus, &value), + edit.focus == Some(focus), + edit.caret_at(focus), + ); + }; + let cell = |col: f32, row: f32| Rect { + origin: Point2D::new( + x + PAD_X + col * (half_w + 8.0), + yy + row * (INPUT_HEIGHT + PADDING_ROW_GAP), ), - ( - PropertyFocus::PaddingBottom, - "B", - snapshot.layout_padding.bottom, - ), - ( - PropertyFocus::PaddingLeft, - "L", - snapshot.layout_padding.left, - ), - ]; - for pair in rows.chunks(2) { - for (col, (focus, label, value)) in pair.iter().enumerate() { - let value = format_panel_number(*value); - paint_input_with_prefix_focused( - cx, - theme, - Rect { - origin: Point2D::new(x + PAD_X + col as f32 * (half_w + 8.0), yy), - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - label, - edit.value_for(*focus, &value), - edit.focus == Some(*focus), - edit.caret_at(*focus), - ); + size: Point2D::new(half_w, INPUT_HEIGHT), + }; + let full = Rect { + origin: Point2D::new(x + PAD_X, yy), + size: Point2D::new(usable_w, INPUT_HEIGHT), + }; + match padding_mode { + PaddingEditMode::Single => { + input(PropertyFocus::PaddingTop, "", p.top, full); + } + PaddingEditMode::Axis => { + // Horizontal (left/right) then Vertical (top/bottom). + input(PropertyFocus::PaddingRight, "H", p.right, cell(0.0, 0.0)); + input(PropertyFocus::PaddingTop, "V", p.top, cell(1.0, 0.0)); + } + PaddingEditMode::Individual => { + input(PropertyFocus::PaddingTop, "T", p.top, cell(0.0, 0.0)); + input(PropertyFocus::PaddingRight, "R", p.right, cell(1.0, 0.0)); + input(PropertyFocus::PaddingBottom, "B", p.bottom, cell(0.0, 1.0)); + input(PropertyFocus::PaddingLeft, "L", p.left, cell(1.0, 1.0)); } - yy += INPUT_HEIGHT + PADDING_ROW_GAP; } - yy - PADDING_ROW_GAP + 12.0 + let rows = if padding_mode == PaddingEditMode::Individual { + 2.0 + } else { + 1.0 + }; + yy + INPUT_HEIGHT * rows + PADDING_ROW_GAP * (rows - 1.0) + 12.0 +} + +/// Paint the padding-mode gear popover (title + 3 radio rows). Anchored +/// off the flex section's top `y`, painted as a top overlay so it sits +/// above the sections below the gear. +#[allow(clippy::too_many_arguments)] +pub fn paint_padding_mode_popover( + cx: &mut PaintCx<'_>, + theme: &Theme, + locale: op_editor_core::Locale, + active_mode: PaddingEditMode, + hover: Option, + x0: f32, + y: f32, + width: f32, +) { + let (_gear, pop_box, rows) = padding_mode_popover_layout(x0, y, width); + cx.backend.fill_round_rect(pop_box, 8.0, theme.popover); + cx.backend + .stroke_round_rect(pop_box, 8.0, theme.border, 1.0); + let title = TextLayout::single_run( + op_i18n::translate(locale, "padding.paddingValues"), + "system-ui", + 11.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &title, + Point2D::new(pop_box.origin.x + 12.0, pop_box.origin.y + 16.0), + ); + for (i, rect) in rows.iter().enumerate() { + let mode = PaddingEditMode::ALL[i]; + if hover == Some(i) { + // Muted row wash matching the other dropdown hovers + // (file menu / locale / shape picker). + cx.backend.fill_round_rect(*rect, 6.0, theme.muted); + } + paint_radio_circle( + cx, + theme, + rect.origin.x + 4.0, + rect.origin.y + (rect.size.y - RADIO_SIZE) / 2.0, + mode == active_mode, + ); + let label = TextLayout::single_run( + op_i18n::translate(locale, mode.label_key()), + "system-ui", + 11.0, + to_jian_color(theme.foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &label, + Point2D::new( + rect.origin.x + 4.0 + RADIO_GUTTER, + rect.origin.y + rect.size.y / 2.0 + 4.0, + ), + ); + } } fn paint_alignment_grid( @@ -412,11 +609,15 @@ fn paint_alignment_grid( LayoutJustifyValue::SpaceBetween | LayoutJustifyValue::SpaceAround ); let is_vertical = snapshot.flex_layout == FlexLayout::Vertical; + // Match TS `p-2` (8px) grid padding: the box top sits flush with + // the body-top (`y` == gap-input top); the dot cells are inset by + // GRID_PAD so the grid reads as one box aligned with the right + // column instead of overshooting 6px upward into the sub-label. let bg = Rect { - origin: Point2D::new(x - 6.0, y - 6.0), + origin: Point2D::new(x, y), size: Point2D::new( - GRID_CELL_W * 3.0 + GRID_GAP * 2.0 + 12.0, - GRID_CELL_H * 3.0 + GRID_GAP * 2.0 + 12.0, + GRID_CELL_W * 3.0 + GRID_GAP * 2.0 + GRID_PAD * 2.0, + GRID_CELL_H * 3.0 + GRID_GAP * 2.0 + GRID_PAD * 2.0, ), }; cx.backend.fill_round_rect(bg, 6.0, theme.muted); @@ -439,8 +640,8 @@ fn paint_alignment_grid( }; let cell = Rect { origin: Point2D::new( - x + col as f32 * (GRID_CELL_W + GRID_GAP), - y + row as f32 * (GRID_CELL_H + GRID_GAP), + x + GRID_PAD + col as f32 * (GRID_CELL_W + GRID_GAP), + y + GRID_PAD + row as f32 * (GRID_CELL_H + GRID_GAP), ), size: Point2D::new(GRID_CELL_W, GRID_CELL_H), }; @@ -469,40 +670,51 @@ fn paint_alignment_grid( } #[allow(clippy::too_many_arguments)] -fn paint_gap_mode_button( +/// 14px radio circle — a 1.5px ring (primary when selected, else +/// muted) plus an 8px filled primary dot when selected. Drawn with +/// `stroke/fill_round_rect` at full radius (a circle) to stay +/// dependency-free. +fn paint_radio_circle(cx: &mut PaintCx<'_>, theme: &Theme, x: f32, y: f32, selected: bool) { + let ring = Rect { + origin: Point2D::new(x, y), + size: Point2D::new(RADIO_SIZE, RADIO_SIZE), + }; + let border = if selected { + theme.primary + } else { + theme.muted_foreground + }; + cx.backend + .stroke_round_rect(ring, RADIO_SIZE / 2.0, border, 1.5); + if selected { + let off = (RADIO_SIZE - RADIO_DOT) / 2.0; + let dot = Rect { + origin: Point2D::new(x + off, y + off), + size: Point2D::new(RADIO_DOT, RADIO_DOT), + }; + cx.backend + .fill_round_rect(dot, RADIO_DOT / 2.0, theme.primary); + } +} + +/// 10px muted label vertically centred in a `GAP_ROW_H` radio row. +fn paint_gap_row_label( cx: &mut PaintCx<'_>, theme: &Theme, locale: op_editor_core::Locale, x: f32, - y: f32, - width: f32, - active: bool, + row_y: f32, label_key: &'static str, -) -> f32 { - let rect = Rect { - origin: Point2D::new(x, y), - size: Point2D::new(width, GAP_BUTTON_H), - }; - if active { - cx.backend.fill_round_rect(rect, 5.0, theme.primary); - } - let color = if active { - theme.primary_foreground - } else { - theme.muted_foreground - }; +) { let label = TextLayout::single_run( op_i18n::translate(locale, label_key), "system-ui", 10.0, - to_jian_color(color), + to_jian_color(theme.muted_foreground), Point2D::new(0.0, 0.0), ); - cx.backend.draw_text( - &label, - Point2D::new(rect.origin.x + 8.0, rect.origin.y + 16.0), - ); - y + GAP_BUTTON_H + cx.backend + .draw_text(&label, Point2D::new(x, row_y + GAP_ROW_H / 2.0 + 4.0)); } fn paint_sub_label(cx: &mut PaintCx<'_>, theme: &Theme, label: &str, x: f32, y: f32) { diff --git a/crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs index c18d0b3f..c4177789 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_icon_tests.rs @@ -19,9 +19,19 @@ fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections { create_component: caps.create_component && panel.snapshot.can_create_component, flex_layout: caps.flex_layout, flex_layout_mode: panel.snapshot.flex_layout, + padding_edit_mode: op_editor_core::PaddingEditMode::from_values( + panel.snapshot.layout_padding.top, + panel.snapshot.layout_padding.right, + panel.snapshot.layout_padding.bottom, + panel.snapshot.layout_padding.left, + ), layout_justify: panel.snapshot.layout_justify, layout_align: panel.snapshot.layout_align, size_options: caps.size_options, + size_fill_width: panel.snapshot.size_fill_width, + size_fill_height: panel.snapshot.size_fill_height, + size_hug_width: panel.snapshot.size_hug_width, + size_hug_height: panel.snapshot.size_hug_height, clip_content: panel.snapshot.can_clip_content, text: caps.text && panel.snapshot.text.is_some(), icon: panel.snapshot.icon.is_some(), @@ -57,6 +67,8 @@ fn text_size_section_does_not_emit_clip_content_action() { false, false, false, + false, + false, ); assert!( @@ -91,6 +103,8 @@ fn icon_font_selection_exposes_icon_picker_action() { false, false, false, + false, + false, ) .into_iter() .find(|(action, _)| matches!(action, PropertyPanelAction::OpenSelectedIconPicker)) diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs index 0e20594b..9977667e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_image_fill.rs @@ -46,11 +46,21 @@ fn image_body_rect(panel_rect: Rect, visible: VisibleSections) -> Option { if !visible.image && (!visible.fill || visible.fill_type != op_editor_core::FillType::Image) { return None; } - action_button_rects_with_fill_picker(panel_rect, visible, &[], false, false, false, false) - .into_iter() - .find_map(|(action, rect)| { - matches!(action, PropertyPanelAction::ToggleImageFillPopover).then_some(rect) - }) + action_button_rects_with_fill_picker( + panel_rect, + visible, + &[], + false, + false, + false, + false, + false, + false, + ) + .into_iter() + .find_map(|(action, rect)| { + matches!(action, PropertyPanelAction::ToggleImageFillPopover).then_some(rect) + }) } fn popover_rect(panel_rect: Rect, visible: VisibleSections) -> Option { diff --git a/crates/op-editor-ui/src/widgets/property_panel_image_fill_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_image_fill_tests.rs new file mode 100644 index 00000000..7d2c04b5 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_image_fill_tests.rs @@ -0,0 +1,235 @@ +//! Image-fill section tests for `widgets::property_panel`: the fill +//! body opening the editor popover, painting the selected image +//! preview / thumbnail, i18n labels, and the popover's upload + fit- +//! mode hit rects. + +use super::property_panel::{PropertyPanel, PropertyPanelAction}; +use super::property_panel_sections as sections; +use super::property_panel_test_support::{state_from, visible_for, CountingBackend}; +use crate::widgets::{PaintCx, Widget}; +use crate::{ImageDrawMode, Point2D, Rect}; +use op_editor_core::NodeId; + +fn image_fill_state_with_url(url: &str) -> op_editor_core::EditorState { + let mut state = state_from(&format!( + r##"{{ "version": "0.8.0", "children": [ + {{"type":"rectangle","id":"n60","name":"Photo fill", + "x":40,"y":40,"width":180,"height":120, + "fill":[{{"type":"image","url":"{}","mode":"fill", + "exposure":0,"contrast":0,"saturation":0, + "temperature":0,"tint":0,"highlights":0,"shadows":0}}]}} + ]}}"##, + url + )); + state.set_single_selection(NodeId::new("n60")); + state +} + +fn image_fill_state() -> op_editor_core::EditorState { + image_fill_state_with_url("") +} + +#[test] +fn image_fill_body_click_opens_the_image_popover() { + let state = image_fill_state(); + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + let rects = sections::action_button_rects_with_fill_picker( + rect, + visible_for(&panel), + &panel.snapshot.effects, + false, + false, + false, + false, + false, + false, + ); + let body = rects + .iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::ToggleImageFillPopover)) + .map(|(_, r)| *r) + .expect("image fill body emits popover toggle action"); + let center = Point2D::new( + body.origin.x + body.size.x / 2.0, + body.origin.y + body.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, center), + Some(PropertyPanelAction::ToggleImageFillPopover) + ), + "image fill body click should open the image editor popover", + ); +} + +#[test] +fn open_image_fill_popover_paints_selected_image_preview() { + const PNG_DATA_URL: &str = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + let mut state = image_fill_state_with_url(PNG_DATA_URL); + state.editor_ui.image_fill_popover_open = true; + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + assert_eq!( + panel + .snapshot + .image_fill + .as_ref() + .unwrap() + .image_url + .as_deref(), + Some(PNG_DATA_URL), + ); + + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + panel.paint(&mut cx, rect); + panel.paint_overlays(&mut cx, rect); + } + assert!( + backend.images.iter().any(|(_, _, bytes)| *bytes > 0), + "selected image data URL should be decoded and painted in the upload well", + ); +} + +#[test] +fn image_fill_body_paints_selected_image_thumbnail_with_mode() { + const PNG_DATA_URL: &str = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; + let mut state = image_fill_state_with_url(PNG_DATA_URL); + assert!(state.set_selected_image_fill_mode(op_editor_core::ImageFillMode::Tile)); + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint( + &mut cx, + Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }, + ); + } + + assert!( + backend.image_modes.contains(&ImageDrawMode::Tile), + "fill body thumbnail should paint the selected image using the current image mode", + ); +} + +#[test] +fn image_fill_adjustment_reset_label_uses_i18n() { + let mut state = image_fill_state(); + state.editor_ui.image_fill_popover_open = true; + state.editor_ui.locale = op_editor_core::Locale::ZhCn; + assert!( + state.set_selected_image_adjustment(op_editor_core::ImageAdjustmentField::Exposure, 36.0) + ); + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint_overlays( + &mut cx, + Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }, + ); + } + assert!(backend.texts.iter().any(|s| s == "重置")); + assert!(!backend.texts.iter().any(|s| s == "Reset")); +} + +#[test] +fn open_image_fill_popover_routes_upload_and_mode_actions() { + let mut state = image_fill_state(); + state.editor_ui.image_fill_popover_open = true; + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + let popup_rects = + sections::image_fill_popover_action_rects(rect, visible_for(&panel), &panel.snapshot); + let upload = popup_rects + .iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::PickFillImage)) + .map(|(_, r)| *r) + .expect("open image popover exposes an upload hit rect"); + let upload_center = Point2D::new( + upload.origin.x + upload.size.x / 2.0, + upload.origin.y + upload.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, upload_center), + Some(PropertyPanelAction::PickFillImage) + ), + "upload well should trigger the image file picker", + ); + let crop = popup_rects + .iter() + .find(|(a, _)| { + matches!( + a, + PropertyPanelAction::SetImageFillMode(op_editor_core::ImageFillMode::Crop) + ) + }) + .map(|(_, r)| *r) + .expect("open image popover exposes fit-mode hit rects"); + let crop_center = Point2D::new( + crop.origin.x + crop.size.x / 2.0, + crop.origin.y + crop.size.y / 2.0, + ); + assert!( + matches!( + panel.hit_test_action(rect, crop_center), + Some(PropertyPanelAction::SetImageFillMode( + op_editor_core::ImageFillMode::Crop + )) + ), + "fit-mode chips should dispatch mode updates", + ); +} + +#[test] +fn image_fill_popover_internal_gap_is_consumed_without_action() { + let mut state = image_fill_state(); + state.editor_ui.image_fill_popover_open = true; + let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); + let rect = Rect { + origin: Point2D::new(320.0, 24.0), + size: Point2D::new(280.0, 900.0), + }; + let popup_rects = + sections::image_fill_popover_action_rects(rect, visible_for(&panel), &panel.snapshot); + let upload = popup_rects + .iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::PickFillImage)) + .map(|(_, r)| *r) + .expect("upload rect exists"); + let gap = Point2D::new(upload.origin.x + 20.0, upload.origin.y - 5.0); + + assert_eq!(panel.hit_test_action(rect, gap), None); + assert!( + panel.image_fill_popover_contains(rect, gap), + "clicks in non-interactive popover gaps must be consumed so the popover stays open", + ); +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs index bc99e9de..ec141cf2 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs @@ -4,7 +4,8 @@ //! and input-rect walker stay under the repository file-size cap. use crate::widgets::property_panel_inputs::{ - HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, + CREATE_COMPONENT_BLOCK_H, HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, + SECTION_HEADER_HEIGHT, TAB_HEIGHT, }; use crate::widgets::property_panel_layout::{fill_body_height_with_stops, VisibleSections}; use crate::{Point2D, Rect}; @@ -59,7 +60,7 @@ pub fn editable_input_rects( y += TAB_HEIGHT; y += HEADER_HEIGHT; if visible.create_component { - y += 8.0 + 36.0 + 12.0; + y += CREATE_COMPONENT_BLOCK_H; } y += SECTION_HEADER_HEIGHT; let x_rect = Rect { @@ -96,26 +97,48 @@ pub fn editable_input_rects( y, w, visible.flex_layout_mode, + visible.layout_justify, + visible.padding_edit_mode, + ); + y += crate::widgets::property_panel_flex::flex_section_height( + visible.flex_layout_mode, + visible.padding_edit_mode, ); - y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode); } if visible.size_options { y += SECTION_HEADER_HEIGHT; - rects.push(( - PropertyFocus::SizeW, - Rect { - origin: Point2D::new(x0 + PAD_X, y), - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - )); - rects.push(( - PropertyFocus::SizeH, - Rect { - origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y), - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - )); - y += INPUT_HEIGHT + 10.0; + // Mirror paint_size_section: omit the W/H hit-rect when its + // dimension is fill/hug, and reflow H into the left slot when W + // is hidden — but keep the row's vertical advance fixed so later + // sections don't shift. (TS size-section.tsx: input rendered + // only when the dimension is a concrete number.) + let w_left = Point2D::new(x0 + PAD_X, y); + let h_right = Point2D::new(x0 + PAD_X + half_w + 8.0, y); + let w_visible = !visible.size_fill_width && !visible.size_hug_width; + let h_visible = !visible.size_fill_height && !visible.size_hug_height; + if w_visible { + rects.push(( + PropertyFocus::SizeW, + Rect { + origin: w_left, + size: Point2D::new(half_w, INPUT_HEIGHT), + }, + )); + } + if h_visible { + rects.push(( + PropertyFocus::SizeH, + Rect { + origin: if w_visible { h_right } else { w_left }, + size: Point2D::new(half_w, INPUT_HEIGHT), + }, + )); + } + // Collapse the input row when both dimensions are fill/hug (same + // rule as paint_size_section) so the checkboxes shift up. + if w_visible || h_visible { + y += INPUT_HEIGHT + 10.0; + } let check_h = 22.0; y += check_h * if visible.clip_content { 3.0 } else { 2.0 }; y += 12.0; diff --git a/crates/op-editor-ui/src/widgets/property_panel_inputs.rs b/crates/op-editor-ui/src/widgets/property_panel_inputs.rs index cfe48d3d..d99b6a2e 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_inputs.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_inputs.rs @@ -20,6 +20,17 @@ pub const SECTION_HEADER_HEIGHT: f32 = 24.0; pub const TAB_HEIGHT: f32 = 36.0; pub const HEADER_HEIGHT: f32 = 30.0; +/// "Create component" button metrics — shared by `paint_create_component` +/// and every layout walker so paint ↔ hit-test ↔ section offsets stay in +/// lockstep. The button + its icon are kept compact (icon size below). +pub const CREATE_COMPONENT_PAD_TOP: f32 = 8.0; +pub const CREATE_COMPONENT_BTN_H: f32 = 30.0; +pub const CREATE_COMPONENT_PAD_BOTTOM: f32 = 12.0; +pub const CREATE_COMPONENT_ICON: f32 = 14.0; +/// Total vertical space the create-component block consumes. +pub const CREATE_COMPONENT_BLOCK_H: f32 = + CREATE_COMPONENT_PAD_TOP + CREATE_COMPONENT_BTN_H + CREATE_COMPONENT_PAD_BOTTOM; + pub fn paint_section_label( cx: &mut PaintCx<'_>, theme: &Theme, @@ -161,7 +172,10 @@ pub fn paint_input_with_suffix_focused( .stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5); } let value_x = rect.origin.x + 10.0; - let baseline_y = rect.origin.y + 19.0; + // Centre the text vertically in the box: for the standard 30px input + // this is +19 (unchanged); for the compact 20px gap row it becomes + // +14 so the value isn't pinned to the bottom. + let baseline_y = rect.origin.y + rect.size.y / 2.0 + 4.0; let value_layout = TextLayout::single_run( value, "system-ui", @@ -192,7 +206,7 @@ pub fn paint_input_with_suffix_focused( ); cx.backend.draw_text( &unit_layout, - Point2D::new(rect.origin.x + rect.size.x - 14.0, rect.origin.y + 19.0), + Point2D::new(rect.origin.x + rect.size.x - 14.0, baseline_y), ); } diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index c5844ded..0f7bd347 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -8,7 +8,8 @@ use crate::widgets::property_panel::{EffectKind, EffectSummary, PropertyPanelAction}; use crate::widgets::property_panel_inputs::{ - HEADER_HEIGHT, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, + CREATE_COMPONENT_BLOCK_H, CREATE_COMPONENT_BTN_H, CREATE_COMPONENT_PAD_TOP, HEADER_HEIGHT, + INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, }; use crate::{Point2D, Rect}; use op_editor_core::{EffectField, FillType}; @@ -187,7 +188,9 @@ pub fn action_button_rects( visible: VisibleSections, effects: &[EffectSummary], ) -> Vec<(PropertyPanelAction, Rect)> { - action_button_rects_with_fill_picker(panel_rect, visible, effects, false, false, false, false) + action_button_rects_with_fill_picker( + panel_rect, visible, effects, false, false, false, false, false, false, + ) } /// Height of one row in an Export-section inline select popup. @@ -204,7 +207,7 @@ pub fn property_panel_content_height( effects: &[EffectSummary], ) -> f32 { let actions = action_button_rects_with_fill_picker( - panel_rect, visible, effects, false, false, false, false, + panel_rect, visible, effects, false, false, false, false, false, false, ); let inputs = editable_input_rects(panel_rect, visible); let bottom = actions @@ -222,14 +225,17 @@ pub fn property_panel_content_height( /// scale (3 rows) / format (5 rows) select popups. `effects` drives /// the Effects section's per-effect "✕" and parameter-stepper rects /// + that section's variable height. +#[allow(clippy::too_many_arguments)] pub fn action_button_rects_with_fill_picker( panel_rect: Rect, visible: VisibleSections, effects: &[EffectSummary], fill_picker_open: bool, font_family_picker_open: bool, + font_weight_picker_open: bool, export_scale_picker_open: bool, export_format_picker_open: bool, + padding_mode_popover_open: bool, ) -> Vec<(PropertyPanelAction, Rect)> { let x0 = panel_rect.origin.x; let w = panel_rect.size.x; @@ -244,11 +250,11 @@ pub fn action_button_rects_with_fill_picker( out.push(( PropertyPanelAction::CreateComponent, Rect { - origin: Point2D::new(x0 + PAD_X, y + 8.0), - size: Point2D::new(usable_w, 36.0), + origin: Point2D::new(x0 + PAD_X, y + CREATE_COMPONENT_PAD_TOP), + size: Point2D::new(usable_w, CREATE_COMPONENT_BTN_H), }, )); - y += 8.0 + 36.0 + 12.0; + y += CREATE_COMPONENT_BLOCK_H; } // Position section. y += SECTION_HEADER_HEIGHT; @@ -265,14 +271,24 @@ pub fn action_button_rects_with_fill_picker( w, visible.flex_layout_mode, visible.layout_justify, + padding_mode_popover_open, ); - y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode) - - SECTION_HEADER_HEIGHT; + y += crate::widgets::property_panel_flex::flex_section_height( + visible.flex_layout_mode, + visible.padding_edit_mode, + ) - SECTION_HEADER_HEIGHT; } if visible.size_options { y += SECTION_HEADER_HEIGHT; - y += INPUT_HEIGHT + 10.0; + // The W/H input row collapses when both dimensions are fill/hug + // (matches paint + editable_input_rects), so the size checkbox + // rects below shift up by the row height. + let w_visible = !visible.size_fill_width && !visible.size_hug_width; + let h_visible = !visible.size_fill_height && !visible.size_hug_height; + if w_visible || h_visible { + y += INPUT_HEIGHT + 10.0; + } let row_h = 22.0; out.push(( PropertyPanelAction::ToggleSizeFillWidth, @@ -334,6 +350,13 @@ pub fn action_button_rects_with_fill_picker( ), ); } + if font_weight_picker_open { + out.extend( + crate::widgets::property_panel_text::font_weight_picker_action_rects( + x0, y, usable_w, + ), + ); + } y += crate::widgets::property_panel_text::text_section_height(); y += SECTION_GAP; } diff --git a/crates/op-editor-ui/src/widgets/property_panel_multi_select_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_multi_select_tests.rs new file mode 100644 index 00000000..11189b4d --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_multi_select_tests.rs @@ -0,0 +1,116 @@ +//! Multi-selection behaviour for `widgets::property_panel`: the +//! aggregate snapshot, inert hit-testing, the reduced capability mask, +//! and the selection-scoped padding-mode pin. + +use super::property_panel::PropertyPanel; +use super::property_panel_test_support::{paint_and_count, state_from}; +use crate::{Point2D, Rect}; +use op_editor_core::{EditorState, NodeId}; + +#[test] +fn multi_selection_panel_shows_union_bounds_and_is_inert() { + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + assert_eq!(state.selection_count(), 2); + + let panel = PropertyPanel::for_selection(&state).expect("multi-select must paint"); + assert!(panel.is_multi); + assert_eq!(panel.snapshot.kind, "2 items"); + assert_eq!(panel.snapshot.x, 60); + assert_eq!(panel.snapshot.y, 60); + // Union spans Title (y 60..88) + Button group (y 130..166) → + // x=60, w=240, h≈106. + assert!(panel.snapshot.width >= 240); + assert!(panel.snapshot.height >= 100); + assert!(panel.focus.is_none()); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 600.0), + }; + assert!(panel.hit_test(rect, Point2D::new(140.0, 100.0)).is_none()); + assert!(panel + .hit_test_action(rect, Point2D::new(140.0, 100.0)) + .is_none()); +} + +#[test] +fn multi_select_paint_diverges_from_full_section_paint() { + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + let panel_multi = PropertyPanel::for_selection(&state).expect("multi"); + state.set_single_selection(NodeId::new("n10")); + let panel_frame = PropertyPanel::for_selection(&state).expect("frame"); + assert!(!panel_frame.is_multi); + + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 1200.0), + }; + let multi = paint_and_count(&panel_multi, rect); + let frame = paint_and_count(&panel_frame, rect); + assert_ne!(multi, frame, "multi must paint fewer ops than single-Frame"); + assert!(multi.0 > 5 && multi.1 > 0, "Size section must paint"); +} + +#[test] +fn multi_select_caps_keep_size_hide_fill_and_stroke() { + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.toggle_selection(NodeId::new("n12")); + let panel = PropertyPanel::for_selection(&state).expect("multi-select panel"); + assert!(panel.is_multi); + let caps = panel.capabilities(); + assert!(caps.size_options, "multi-select must paint W/H"); + assert!(!caps.fill, "multi-select must hide fill section"); + assert!(!caps.stroke, "multi-select must hide stroke section"); + assert!(!caps.flex_layout, "multi-select hides flex"); + // A Rect selection routes through `for_kind`, exposing fill/stroke. + state.set_single_selection(NodeId::new("n13")); + let single = PropertyPanel::for_selection(&state).expect("single-select panel"); + let caps_single = single.capabilities(); + assert!(caps_single.fill, "single Rect must paint fill"); + assert!(caps_single.stroke, "single Rect must paint stroke"); +} + +#[test] +fn multi_select_panel_shows_even_when_all_zero_size() { + // Symmetry with single-select: a 0x0 node still shows the panel. + let mut state = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"rectangle","id":"n50","name":"A"}, + {"type":"rectangle","id":"n51","name":"B"} + ]}"##, + ); + state.set_single_selection(NodeId::new("n50")); + state.toggle_selection(NodeId::new("n51")); + assert_eq!(state.selection_count(), 2); + let panel = PropertyPanel::for_selection(&state).expect("0x0 multi-select must paint"); + assert!(panel.is_multi); + assert_eq!(panel.snapshot.width, 0); + assert_eq!(panel.snapshot.height, 0); +} + +#[test] +fn padding_pin_does_not_leak_into_a_different_selection() { + use op_editor_core::PaddingEditMode; + let mut state = state_from( + r#"{"version":"0.8.0","children":[ + {"type":"frame","id":"a","name":"A","x":0,"y":0,"width":100,"height":100}, + {"type":"frame","id":"b","name":"B","x":200,"y":0,"width":100,"height":100} + ]}"#, + ); + // The user pins "Individual" padding mode for node A. + state.editor_ui.padding_edit_mode = Some(PaddingEditMode::Individual); + state.editor_ui.padding_edit_mode_anchor = "a".to_string(); + // While A is selected the pin applies. + state.set_single_selection(NodeId::new("a")); + let panel_a = PropertyPanel::for_selection(&state).unwrap(); + assert_eq!(panel_a.padding_edit_mode, PaddingEditMode::Individual); + // Selecting B must NOT inherit A's pin — B has no padding, so the + // panel derives Single. Regression for the leaked-padding-mode bug. + state.set_single_selection(NodeId::new("b")); + let panel_b = PropertyPanel::for_selection(&state).unwrap(); + assert_eq!(panel_b.padding_edit_mode, PaddingEditMode::Single); +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index c98cac72..c2d76455 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -8,11 +8,12 @@ use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel::NodeSnapshot; use crate::widgets::property_panel_inputs::{ format_color_hex, paint_input_with_icon_focused, paint_input_with_prefix_focused, - paint_section_divider, paint_section_label, to_jian_color, HEADER_HEIGHT, INPUT_HEIGHT, - INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT, + paint_section_divider, paint_section_label, to_jian_color, CREATE_COMPONENT_BLOCK_H, + CREATE_COMPONENT_BTN_H, CREATE_COMPONENT_ICON, CREATE_COMPONENT_PAD_TOP, HEADER_HEIGHT, + INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, TAB_HEIGHT, }; use crate::widgets::PaintCx; -use crate::{Color, Point2D, Rect, TextLayout}; +use crate::{Point2D, Rect, TextLayout}; use op_editor_core::PropertyFocus; // Re-exports — fill paint moved to `property_panel_fill.rs`, @@ -276,8 +277,8 @@ pub fn paint_create_component( y: f32, width: f32, ) -> f32 { - let pad_top = 8.0; - let btn_h = 36.0; + let pad_top = CREATE_COMPONENT_PAD_TOP; + let btn_h = CREATE_COMPONENT_BTN_H; let btn_rect = Rect { origin: Point2D::new(x + PAD_X, y + pad_top), size: Point2D::new(width - PAD_X * 2.0, btn_h), @@ -287,14 +288,19 @@ pub fn paint_create_component( .stroke_round_rect(btn_rect, 8.0, theme.border, 1.0); // TS uses Component icon (cluster of 4 small diamonds) for // the "create component" affordance. Diamond is imported in - // the same file but used elsewhere (instance indicator). + // the same file but used elsewhere (instance indicator). The + // icon is vertically centred in the compact button. + let icon = CREATE_COMPONENT_ICON; draw_icon( cx.backend, Icon::Component, - Point2D::new(btn_rect.origin.x + 12.0, btn_rect.origin.y + 9.0), - 18.0, + Point2D::new( + btn_rect.origin.x + 12.0, + btn_rect.origin.y + (btn_h - icon) / 2.0, + ), + icon, theme.foreground, - 1.4, + 1.3, ); let label = TextLayout::single_run( labels.create_component, @@ -308,10 +314,10 @@ pub fn paint_create_component( &label, Point2D::new( btn_rect.origin.x + (btn_rect.size.x - label_w) / 2.0 + 12.0, - btn_rect.origin.y + 23.0, + btn_rect.origin.y + btn_h / 2.0 + 4.5, ), ); - y + pad_top + btn_h + 12.0 + y + CREATE_COMPONENT_BLOCK_H } // ── Position section ────────────────────────────────────────────── @@ -485,27 +491,47 @@ pub fn paint_size_section( origin: Point2D::new(x + PAD_X + half_w + 8.0, y), size: Point2D::new(half_w, INPUT_HEIGHT), }; - let w_value = snapshot.width.to_string(); - paint_input_with_prefix_focused( - cx, - theme, - w_rect, - "W", - edit.value_for(PropertyFocus::SizeW, &w_value), - edit.focus == Some(PropertyFocus::SizeW), - edit.caret_at(PropertyFocus::SizeW), - ); - let h_value = snapshot.height.to_string(); - paint_input_with_prefix_focused( - cx, - theme, - h_rect, - "H", - edit.value_for(PropertyFocus::SizeH, &h_value), - edit.focus == Some(PropertyFocus::SizeH), - edit.caret_at(PropertyFocus::SizeH), - ); - y += INPUT_HEIGHT + 10.0; + // Hide the W/H box entirely when its dimension is fill/hug — + // matching TS size-section.tsx, which renders the NumberInput only + // when the dimension is a concrete number. Visible dimensions flow + // left-to-right, so when W is hidden, H slides into the left slot + // (no dangling empty half). The fixed `y += INPUT_HEIGHT + 10.0` + // below keeps the row height (and every later section's offset) + // unchanged regardless of how many boxes paint. + let w_visible = !flags.fill_width && !flags.hug_width; + let h_visible = !flags.fill_height && !flags.hug_height; + if w_visible { + let w_value = snapshot.width.to_string(); + paint_input_with_prefix_focused( + cx, + theme, + w_rect, + "W", + edit.value_for(PropertyFocus::SizeW, &w_value), + edit.focus == Some(PropertyFocus::SizeW), + edit.caret_at(PropertyFocus::SizeW), + ); + } + if h_visible { + let h_value = snapshot.height.to_string(); + let h_target = if w_visible { h_rect } else { w_rect }; + paint_input_with_prefix_focused( + cx, + theme, + h_target, + "H", + edit.value_for(PropertyFocus::SizeH, &h_value), + edit.focus == Some(PropertyFocus::SizeH), + edit.caret_at(PropertyFocus::SizeH), + ); + } + // Collapse the whole input row when BOTH dimensions are fill/hug — + // the section shrinks up so the checkboxes sit under the label with + // no dangling empty row. `size_input_row_h` keeps the layout + // walkers in lockstep with this advance. + if w_visible || h_visible { + y += INPUT_HEIGHT + 10.0; + } let row_h = 22.0; paint_check_row( cx, @@ -615,12 +641,7 @@ pub fn paint_stroke_section( ) -> f32 { let mut y = paint_section_label(cx, theme, labels.stroke, x, y, width); let usable_w = width - PAD_X * 2.0; - let stroke_color = snapshot.stroke.map(|s| s.color).unwrap_or(Color { - r: 0x37 as f32 / 255.0, - g: 0x41 as f32 / 255.0, - b: 0x51 as f32 / 255.0, - a: 1.0, - }); + let stroke_color = snapshot.stroke_swatch_color(); let stroke_width = snapshot.stroke.map(|s| s.width).unwrap_or(0.0); let width_w = 60.0; let hex_rect = Rect { diff --git a/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs b/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs index 39edb923..64a86545 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_snapshot.rs @@ -263,6 +263,28 @@ impl EffectSummary { } impl NodeSnapshot { + /// Slate-gray placeholder shown for the stroke swatch + hex input + /// when the node has no parseable solid stroke (`#374151`). The paint + /// routine AND the edit-seed path MUST read this same value so that + /// clicking into the hex input doesn't change the displayed color. + pub(crate) const DEFAULT_STROKE_SWATCH: Color = Color { + r: 0x37 as f32 / 255.0, + g: 0x41 as f32 / 255.0, + b: 0x51 as f32 / 255.0, + a: 1.0, + }; + + /// The color the stroke swatch + hex input should display: the + /// node's solid stroke color, or the slate placeholder when unset. + /// Single source of truth so paint and the click-to-edit seed can + /// never drift (the bug where the seed defaulted to `#000000`). + /// `pub` so the host seed path (op-host-native) reads the same value. + pub fn stroke_swatch_color(&self) -> Color { + self.stroke + .map(|s| s.color) + .unwrap_or(Self::DEFAULT_STROKE_SWATCH) + } + /// Build an aggregate snapshot for a multi-node selection. /// Returns None when nothing on the active page resolves from /// `selected_set`. Uses `Document::selection_bounds` (the union diff --git a/crates/op-editor-ui/src/widgets/property_panel_test_support.rs b/crates/op-editor-ui/src/widgets/property_panel_test_support.rs new file mode 100644 index 00000000..58cb770a --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_test_support.rs @@ -0,0 +1,120 @@ +//! Shared fixtures for the property-panel test modules +//! (`property_panel_tests`, `property_panel_multi_select_tests`, +//! `property_panel_image_fill_tests`). Kept in one place so the topic +//! test files don't duplicate the `EditorState` builder, the visible- +//! section projection, or the paint-op-counting `RenderBackend`. + +use super::property_panel::{PropertyPanel, SectionCapabilities}; +use super::property_panel_sections as sections; +use crate::widgets::{PaintCx, Widget}; +use crate::{Color, ImageDrawMode, Point2D, Rect}; +use op_editor_core::EditorState; + +/// Build an `EditorState` from a canonical-schema JSON fixture. +pub(super) fn state_from(src: &str) -> EditorState { + let doc = jian_ops_schema::load_str(src) + .expect("property-panel fixture parses") + .value; + EditorState::from_document(doc) +} + +/// Project the panel's snapshot onto the `VisibleSections` the layout +/// walkers consume — mirrors what `PropertyPanel::paint` derives. +pub(super) fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections { + let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant); + sections::VisibleSections { + create_component: caps.create_component && panel.snapshot.can_create_component, + flex_layout: caps.flex_layout, + flex_layout_mode: panel.snapshot.flex_layout, + padding_edit_mode: op_editor_core::PaddingEditMode::from_values( + panel.snapshot.layout_padding.top, + panel.snapshot.layout_padding.right, + panel.snapshot.layout_padding.bottom, + panel.snapshot.layout_padding.left, + ), + layout_justify: panel.snapshot.layout_justify, + layout_align: panel.snapshot.layout_align, + size_options: caps.size_options, + size_fill_width: panel.snapshot.size_fill_width, + size_fill_height: panel.snapshot.size_fill_height, + size_hug_width: panel.snapshot.size_hug_width, + size_hug_height: panel.snapshot.size_hug_height, + clip_content: panel.snapshot.can_clip_content, + text: caps.text && panel.snapshot.text.is_some(), + icon: panel.snapshot.icon.is_some(), + image: caps.image && panel.snapshot.is_image_node, + opacity: caps.opacity, + corner_radius: panel.snapshot.has_corner_radius, + polygon_sides: panel.snapshot.polygon_sides.is_some(), + ellipse_arc: panel.snapshot.ellipse_arc.is_some(), + fill: caps.fill, + stroke: caps.stroke, + effects: caps.effects, + export: caps.export, + fill_type: panel.fill_type, + gradient_stop_count: panel.snapshot.gradient_stops.len(), + } +} + +/// Minimal `RenderBackend` that counts paint ops + records what was +/// drawn, so paint tests can assert on the output without a real GPU. +#[derive(Default)] +pub(super) struct CountingBackend { + pub(super) text: usize, + pub(super) texts: Vec, + pub(super) round_rects: usize, + pub(super) images: Vec<(Rect, u64, usize)>, + pub(super) image_modes: Vec, +} +impl crate::RenderBackend for CountingBackend { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: Color) {} + fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} + fn draw_text(&mut self, layout: &crate::TextLayout, _: Point2D) { + self.text += 1; + if let Some(run) = layout.runs().first() { + self.texts.push(run.content.clone()); + } + } + fn clip_rect(&mut self, _: Rect) {} + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} + fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) { + self.round_rects += 1; + } + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} + fn draw_image(&mut self, rect: Rect, image_id: u64, encoded: &[u8]) { + self.images.push((rect, image_id, encoded.len())); + } + fn draw_image_with_mode( + &mut self, + rect: Rect, + image_id: u64, + encoded: &[u8], + mode: ImageDrawMode, + ) { + self.images.push((rect, image_id, encoded.len())); + self.image_modes.push(mode); + } + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +/// Paint `panel` into a `CountingBackend` and return (text-ops, +/// round-rect-ops). +pub(super) fn paint_and_count(panel: &PropertyPanel, rect: Rect) -> (usize, usize) { + let mut backend = CountingBackend::default(); + { + let mut cx = PaintCx { + backend: &mut backend, + }; + panel.paint(&mut cx, rect); + } + (backend.text, backend.round_rects) +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_tests.rs index 5c551ad8..b01656db 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_tests.rs @@ -4,20 +4,13 @@ //! Phase 6: the panel builds from `op_editor_core::EditorState`, so //! the fixtures construct `EditorState` values. -use super::property_panel::{PropertyPanel, PropertyPanelAction, SectionCapabilities}; +use super::property_panel::{PropertyPanel, PropertyPanelAction}; use super::property_panel_sections as sections; -use crate::widgets::{PaintCx, Widget}; -use crate::{Color, ImageDrawMode, Point2D, Rect}; +use super::property_panel_test_support::{state_from, visible_for}; +use crate::widgets::Widget; +use crate::{Color, Point2D, Rect}; use op_editor_core::{EditorState, NodeId}; -/// Build an `EditorState` from a canonical `.op` JSON string. -fn state_from(src: &str) -> EditorState { - let doc = jian_ops_schema::load_str(src) - .expect("property-panel fixture parses") - .value; - EditorState::from_document(doc) -} - #[test] fn for_selection_with_real_node_builds_snapshot() { let state = EditorState::sample(); @@ -67,33 +60,6 @@ fn group_snapshot_aggregates_child_bounds() { assert!(panel.snapshot.height > 0); } -/// Build a `VisibleSections` from a panel's per-kind capabilities. -fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections { - let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant); - sections::VisibleSections { - create_component: caps.create_component && panel.snapshot.can_create_component, - flex_layout: caps.flex_layout, - flex_layout_mode: panel.snapshot.flex_layout, - layout_justify: panel.snapshot.layout_justify, - layout_align: panel.snapshot.layout_align, - size_options: caps.size_options, - clip_content: panel.snapshot.can_clip_content, - text: caps.text && panel.snapshot.text.is_some(), - icon: panel.snapshot.icon.is_some(), - image: caps.image && panel.snapshot.is_image_node, - opacity: caps.opacity, - corner_radius: panel.snapshot.has_corner_radius, - polygon_sides: panel.snapshot.polygon_sides.is_some(), - ellipse_arc: panel.snapshot.ellipse_arc.is_some(), - fill: caps.fill, - stroke: caps.stroke, - effects: caps.effects, - export: caps.export, - fill_type: panel.fill_type, - gradient_stop_count: panel.snapshot.gradient_stops.len(), - } -} - #[test] fn polygon_selection_exposes_sides_layer_input() { let mut state = state_from( @@ -185,6 +151,8 @@ fn hit_test_action_export_section_returns_picker_toggles() { false, false, false, + false, + false, ); // The Export section emits a scale-dropdown + a format-dropdown // toggle rect — clicking neither opens the Export modal. @@ -222,6 +190,194 @@ fn hit_test_action_export_section_returns_picker_toggles() { ); } +#[test] +fn fill_width_hides_the_w_input_but_keeps_h_and_row_height() { + use op_editor_core::PropertyFocus; + let fill = { + let mut s = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"ff","name":"Frame", + "x":40,"y":40,"width":"fill_container","height":240, + "layout":"vertical","children":[]} + ]}"##, + ); + s.set_single_selection(NodeId::new("ff")); + PropertyPanel::for_selection(&s).expect("fill-width frame panel") + }; + assert!(fill.snapshot.size_fill_width, "width sizing should be fill"); + assert!( + !fill.snapshot.size_fill_height, + "height stays a concrete number" + ); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 1200.0), + }; + let fill_rects = sections::editable_input_rects(rect, visible_for(&fill)); + // W is omitted (fill); H remains (numeric). + assert!( + !fill_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW), + "SizeW must be hidden when width is fill" + ); + let fill_h = fill_rects + .iter() + .find(|(f, _)| *f == PropertyFocus::SizeH) + .map(|(_, r)| *r) + .expect("SizeH must remain"); + + // A fixed-width frame keeps both — and SizeH sits at the SAME y, so + // hiding W never collapses the row / shifts later sections. + let fixed = { + let mut s = state_from( + r##"{ "version": "0.8.0", "children": [ + {"type":"frame","id":"ff","name":"Frame", + "x":40,"y":40,"width":360,"height":240, + "layout":"vertical","children":[]} + ]}"##, + ); + s.set_single_selection(NodeId::new("ff")); + PropertyPanel::for_selection(&s).expect("fixed-width frame panel") + }; + let fixed_rects = sections::editable_input_rects(rect, visible_for(&fixed)); + assert!( + fixed_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW), + "fixed width keeps SizeW" + ); + let fixed_w = fixed_rects + .iter() + .find(|(f, _)| *f == PropertyFocus::SizeW) + .map(|(_, r)| *r) + .expect("SizeW present for fixed width"); + let fixed_h = fixed_rects + .iter() + .find(|(f, _)| *f == PropertyFocus::SizeH) + .map(|(_, r)| *r) + .expect("SizeH present for fixed width"); + assert!( + (fill_h.origin.y - fixed_h.origin.y).abs() < 0.01, + "hiding W must not move H's row (row height preserved)" + ); + // With W hidden, H reflows into the (now-empty) LEFT slot. + assert!( + (fill_h.origin.x - fixed_w.origin.x).abs() < 0.01, + "H must slide into the left slot when W is hidden" + ); +} + +#[test] +fn both_dimensions_fill_collapses_the_size_input_row() { + use op_editor_core::PropertyFocus; + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 1200.0), + }; + let panel_for = |w: &str, h: &str| { + let json = format!( + r##"{{ "version": "0.8.0", "children": [ + {{"type":"frame","id":"ff","name":"Frame", + "x":40,"y":40,"width":{w},"height":{h}, + "layout":"vertical","children":[]}} + ]}}"## + ); + let mut s = state_from(&json); + s.set_single_selection(NodeId::new("ff")); + PropertyPanel::for_selection(&s).expect("frame panel") + }; + // The size checkboxes sit BELOW the W/H input row. When both + // dimensions are fill, the whole input row collapses, so the first + // checkbox (填充宽度) shifts up by exactly one input row. + let chk_y = |p: &PropertyPanel| { + sections::action_button_rects_with_fill_picker( + rect, + visible_for(p), + &p.snapshot.effects, + false, + false, + false, + false, + false, + false, + ) + .into_iter() + .find(|(a, _)| matches!(a, PropertyPanelAction::ToggleSizeFillWidth)) + .map(|(_, r)| r.origin.y) + .expect("fill-width checkbox rect") + }; + // One dimension numeric (row present) vs both fill (row collapsed). + let one = panel_for("\"fill_container\"", "240"); + let both = panel_for("\"fill_container\"", "\"fill_container\""); + assert!(one.snapshot.size_fill_width && both.snapshot.size_fill_height); + // INPUT_HEIGHT (30) + 10 gap = 40 px of collapse. + let delta = chk_y(&one) - chk_y(&both); + assert!( + (delta - 40.0).abs() < 0.01, + "both-hidden must collapse the input row (~40px up), got {delta}" + ); + // Neither W nor H emits a focus rect when both are hidden. + let both_inputs = sections::editable_input_rects(rect, visible_for(&both)); + assert!( + !both_inputs + .iter() + .any(|(f, _)| matches!(f, PropertyFocus::SizeW | PropertyFocus::SizeH)), + "no W/H hit-rect when both dimensions are fill" + ); +} + +#[test] +fn padding_mode_derives_from_values_and_drives_input_count() { + use op_editor_core::{PaddingEditMode, PropertyFocus}; + // from_values mirrors TS parsePaddingValues. + assert_eq!( + PaddingEditMode::from_values(10.0, 10.0, 10.0, 10.0), + PaddingEditMode::Single + ); + assert_eq!( + PaddingEditMode::from_values(10.0, 20.0, 10.0, 20.0), + PaddingEditMode::Axis + ); + assert_eq!( + PaddingEditMode::from_values(8.0, 24.0, 32.0, 24.0), + PaddingEditMode::Individual + ); + + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 1200.0), + }; + let padding_rects = |padding: &str| { + let json = format!( + r##"{{ "version": "0.8.0", "children": [ + {{"type":"frame","id":"f","name":"F","x":0,"y":0, + "width":300,"height":200,"layout":"vertical", + "padding":{padding},"children":[]}} + ]}}"## + ); + let mut s = state_from(&json); + s.set_single_selection(NodeId::new("f")); + let panel = PropertyPanel::for_selection(&s).expect("frame panel"); + sections::editable_input_rects(rect, visible_for(&panel)) + .into_iter() + .filter(|(f, _)| { + matches!( + f, + PropertyFocus::PaddingTop + | PropertyFocus::PaddingRight + | PropertyFocus::PaddingBottom + | PropertyFocus::PaddingLeft + ) + }) + .count() + }; + // Single → 1 input, Axis → 2, Individual → 4. + assert_eq!(padding_rects("12"), 1, "uniform padding → 1 input"); + assert_eq!(padding_rects("[10, 20]"), 2, "axis padding → 2 inputs"); + assert_eq!( + padding_rects("[8, 24, 32, 24]"), + 4, + "individual padding → 4 inputs" + ); +} + #[test] fn flex_advanced_rows_do_not_overlap_gap_modes() { let mut state = state_from( @@ -247,6 +403,8 @@ fn flex_advanced_rows_do_not_overlap_gap_modes() { false, false, false, + false, + false, ); let last_gap_mode = actions .iter() @@ -290,6 +448,8 @@ fn font_family_picker_rows_are_clickable() { true, false, false, + false, + false, ); let georgia = rects .iter() @@ -333,8 +493,10 @@ fn export_scale_picker_open_emits_option_rows() { &panel.snapshot.effects, false, false, + false, true, false, + false, ); let rows: Vec<_> = rects .iter() @@ -366,368 +528,13 @@ fn format_color_hex_pads_to_six_chars() { } #[test] -fn multi_selection_panel_shows_union_bounds_and_is_inert() { - let mut state = EditorState::sample(); - state.set_single_selection(NodeId::new("n11")); - state.toggle_selection(NodeId::new("n12")); - assert_eq!(state.selection_count(), 2); - - let panel = PropertyPanel::for_selection(&state).expect("multi-select must paint"); - assert!(panel.is_multi); - assert_eq!(panel.snapshot.kind, "2 items"); - assert_eq!(panel.snapshot.x, 60); - assert_eq!(panel.snapshot.y, 60); - // Union spans Title (y 60..88) + Button group (y 130..166) → - // x=60, w=240, h≈106. - assert!(panel.snapshot.width >= 240); - assert!(panel.snapshot.height >= 100); - assert!(panel.focus.is_none()); - let rect = Rect { - origin: Point2D::new(0.0, 0.0), - size: Point2D::new(280.0, 600.0), - }; - assert!(panel.hit_test(rect, Point2D::new(140.0, 100.0)).is_none()); - assert!(panel - .hit_test_action(rect, Point2D::new(140.0, 100.0)) - .is_none()); -} - -/// Minimal `RenderBackend` that counts paint ops. -#[derive(Default)] -struct CountingBackend { - text: usize, - texts: Vec, - round_rects: usize, - images: Vec<(Rect, u64, usize)>, - image_modes: Vec, -} -impl crate::RenderBackend for CountingBackend { - fn begin_frame(&mut self) {} - fn end_frame(&mut self) {} - fn fill_rect(&mut self, _: Rect, _: Color) {} - fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} - fn draw_text(&mut self, layout: &crate::TextLayout, _: Point2D) { - self.text += 1; - if let Some(run) = layout.runs().first() { - self.texts.push(run.content.clone()); - } - } - fn clip_rect(&mut self, _: Rect) {} - fn save(&mut self) {} - fn restore(&mut self) {} - fn translate(&mut self, _: Point2D) {} - fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} - fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) { - self.round_rects += 1; - } - fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} - fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} - fn draw_image(&mut self, rect: Rect, image_id: u64, encoded: &[u8]) { - self.images.push((rect, image_id, encoded.len())); - } - fn draw_image_with_mode( - &mut self, - rect: Rect, - image_id: u64, - encoded: &[u8], - mode: ImageDrawMode, - ) { - self.images.push((rect, image_id, encoded.len())); - self.image_modes.push(mode); - } - fn resize(&mut self, _: u32, _: u32) {} - fn dpi_scale(&self) -> f32 { - 1.0 - } -} - -#[test] -fn multi_select_paint_diverges_from_full_section_paint() { - let mut state = EditorState::sample(); - state.set_single_selection(NodeId::new("n11")); - state.toggle_selection(NodeId::new("n12")); - let panel_multi = PropertyPanel::for_selection(&state).expect("multi"); - state.set_single_selection(NodeId::new("n10")); - let panel_frame = PropertyPanel::for_selection(&state).expect("frame"); - assert!(!panel_frame.is_multi); - - let rect = Rect { - origin: Point2D::new(0.0, 0.0), - size: Point2D::new(280.0, 1200.0), - }; - let multi = paint_and_count(&panel_multi, rect); - let frame = paint_and_count(&panel_frame, rect); - assert_ne!(multi, frame, "multi must paint fewer ops than single-Frame"); - assert!(multi.0 > 5 && multi.1 > 0, "Size section must paint"); -} - -fn paint_and_count(panel: &PropertyPanel, rect: Rect) -> (usize, usize) { - let mut backend = CountingBackend::default(); - { - let mut cx = PaintCx { - backend: &mut backend, - }; - panel.paint(&mut cx, rect); - } - (backend.text, backend.round_rects) -} - -#[test] -fn multi_select_caps_keep_size_hide_fill_and_stroke() { - let mut state = EditorState::sample(); - state.set_single_selection(NodeId::new("n11")); - state.toggle_selection(NodeId::new("n12")); - let panel = PropertyPanel::for_selection(&state).expect("multi-select panel"); - assert!(panel.is_multi); - let caps = panel.capabilities(); - assert!(caps.size_options, "multi-select must paint W/H"); - assert!(!caps.fill, "multi-select must hide fill section"); - assert!(!caps.stroke, "multi-select must hide stroke section"); - assert!(!caps.flex_layout, "multi-select hides flex"); - // A Rect selection routes through `for_kind`, exposing fill/stroke. - state.set_single_selection(NodeId::new("n13")); - let single = PropertyPanel::for_selection(&state).expect("single-select panel"); - let caps_single = single.capabilities(); - assert!(caps_single.fill, "single Rect must paint fill"); - assert!(caps_single.stroke, "single Rect must paint stroke"); -} - -#[test] -fn multi_select_panel_shows_even_when_all_zero_size() { - // Symmetry with single-select: a 0x0 node still shows the panel. - let mut state = state_from( - r##"{ "version": "0.8.0", "children": [ - {"type":"rectangle","id":"n50","name":"A"}, - {"type":"rectangle","id":"n51","name":"B"} - ]}"##, - ); - state.set_single_selection(NodeId::new("n50")); - state.toggle_selection(NodeId::new("n51")); - assert_eq!(state.selection_count(), 2); - let panel = PropertyPanel::for_selection(&state).expect("0x0 multi-select must paint"); - assert!(panel.is_multi); - assert_eq!(panel.snapshot.width, 0); - assert_eq!(panel.snapshot.height, 0); -} - -fn image_fill_state_with_url(url: &str) -> EditorState { - let mut state = state_from(&format!( - r##"{{ "version": "0.8.0", "children": [ - {{"type":"rectangle","id":"n60","name":"Photo fill", - "x":40,"y":40,"width":180,"height":120, - "fill":[{{"type":"image","url":"{}","mode":"fill", - "exposure":0,"contrast":0,"saturation":0, - "temperature":0,"tint":0,"highlights":0,"shadows":0}}]}} - ]}}"##, - url - )); - state.set_single_selection(NodeId::new("n60")); - state -} - -fn image_fill_state() -> EditorState { - image_fill_state_with_url("") -} - -#[test] -fn image_fill_body_click_opens_the_image_popover() { - let state = image_fill_state(); - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - let rect = Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }; - let rects = sections::action_button_rects_with_fill_picker( - rect, - visible_for(&panel), - &panel.snapshot.effects, - false, - false, - false, - false, - ); - let body = rects - .iter() - .find(|(a, _)| matches!(a, PropertyPanelAction::ToggleImageFillPopover)) - .map(|(_, r)| *r) - .expect("image fill body emits popover toggle action"); - let center = Point2D::new( - body.origin.x + body.size.x / 2.0, - body.origin.y + body.size.y / 2.0, - ); - assert!( - matches!( - panel.hit_test_action(rect, center), - Some(PropertyPanelAction::ToggleImageFillPopover) - ), - "image fill body click should open the image editor popover", - ); -} - -#[test] -fn open_image_fill_popover_paints_selected_image_preview() { - const PNG_DATA_URL: &str = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; - let mut state = image_fill_state_with_url(PNG_DATA_URL); - state.editor_ui.image_fill_popover_open = true; - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - assert_eq!( - panel - .snapshot - .image_fill - .as_ref() - .unwrap() - .image_url - .as_deref(), - Some(PNG_DATA_URL), - ); - - let mut backend = CountingBackend::default(); - { - let mut cx = PaintCx { - backend: &mut backend, - }; - let rect = Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }; - panel.paint(&mut cx, rect); - panel.paint_overlays(&mut cx, rect); - } - assert!( - backend.images.iter().any(|(_, _, bytes)| *bytes > 0), - "selected image data URL should be decoded and painted in the upload well", - ); -} - -#[test] -fn image_fill_body_paints_selected_image_thumbnail_with_mode() { - const PNG_DATA_URL: &str = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII="; - let mut state = image_fill_state_with_url(PNG_DATA_URL); - assert!(state.set_selected_image_fill_mode(op_editor_core::ImageFillMode::Tile)); - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - - let mut backend = CountingBackend::default(); - { - let mut cx = PaintCx { - backend: &mut backend, - }; - panel.paint( - &mut cx, - Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }, - ); - } - - assert!( - backend.image_modes.contains(&ImageDrawMode::Tile), - "fill body thumbnail should paint the selected image using the current image mode", - ); -} - -#[test] -fn image_fill_adjustment_reset_label_uses_i18n() { - let mut state = image_fill_state(); - state.editor_ui.image_fill_popover_open = true; - state.editor_ui.locale = op_editor_core::Locale::ZhCn; - assert!( - state.set_selected_image_adjustment(op_editor_core::ImageAdjustmentField::Exposure, 36.0) - ); - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - - let mut backend = CountingBackend::default(); - { - let mut cx = PaintCx { - backend: &mut backend, - }; - panel.paint_overlays( - &mut cx, - Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }, - ); - } - assert!(backend.texts.iter().any(|s| s == "重置")); - assert!(!backend.texts.iter().any(|s| s == "Reset")); -} - -#[test] -fn open_image_fill_popover_routes_upload_and_mode_actions() { - let mut state = image_fill_state(); - state.editor_ui.image_fill_popover_open = true; - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - let rect = Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }; - let popup_rects = - sections::image_fill_popover_action_rects(rect, visible_for(&panel), &panel.snapshot); - let upload = popup_rects - .iter() - .find(|(a, _)| matches!(a, PropertyPanelAction::PickFillImage)) - .map(|(_, r)| *r) - .expect("open image popover exposes an upload hit rect"); - let upload_center = Point2D::new( - upload.origin.x + upload.size.x / 2.0, - upload.origin.y + upload.size.y / 2.0, - ); - assert!( - matches!( - panel.hit_test_action(rect, upload_center), - Some(PropertyPanelAction::PickFillImage) - ), - "upload well should trigger the image file picker", - ); - let crop = popup_rects - .iter() - .find(|(a, _)| { - matches!( - a, - PropertyPanelAction::SetImageFillMode(op_editor_core::ImageFillMode::Crop) - ) - }) - .map(|(_, r)| *r) - .expect("open image popover exposes fit-mode hit rects"); - let crop_center = Point2D::new( - crop.origin.x + crop.size.x / 2.0, - crop.origin.y + crop.size.y / 2.0, - ); - assert!( - matches!( - panel.hit_test_action(rect, crop_center), - Some(PropertyPanelAction::SetImageFillMode( - op_editor_core::ImageFillMode::Crop - )) - ), - "fit-mode chips should dispatch mode updates", - ); -} - -#[test] -fn image_fill_popover_internal_gap_is_consumed_without_action() { - let mut state = image_fill_state(); - state.editor_ui.image_fill_popover_open = true; - let panel = PropertyPanel::for_selection(&state).expect("image fill panel"); - let rect = Rect { - origin: Point2D::new(320.0, 24.0), - size: Point2D::new(280.0, 900.0), - }; - let popup_rects = - sections::image_fill_popover_action_rects(rect, visible_for(&panel), &panel.snapshot); - let upload = popup_rects - .iter() - .find(|(a, _)| matches!(a, PropertyPanelAction::PickFillImage)) - .map(|(_, r)| *r) - .expect("upload rect exists"); - let gap = Point2D::new(upload.origin.x + 20.0, upload.origin.y - 5.0); - - assert_eq!(panel.hit_test_action(rect, gap), None); - assert!( - panel.image_fill_popover_contains(rect, gap), - "clicks in non-interactive popover gaps must be consumed so the popover stays open", - ); +fn no_stroke_swatch_defaults_to_slate_not_black() { + // Regression: clicking the stroke hex used to seed #000000 while the + // swatch painted slate. Paint and the edit-seed now read ONE source + // (`stroke_swatch_color`), whose no-stroke default is `#374151`. + use crate::widgets::property_panel_inputs::format_color_hex; + use crate::widgets::property_panel_snapshot::NodeSnapshot; + let hex = format_color_hex(NodeSnapshot::DEFAULT_STROKE_SWATCH); + assert_eq!(hex, "#374151"); + assert_ne!(hex, "#000000"); } diff --git a/crates/op-editor-ui/src/widgets/property_panel_text.rs b/crates/op-editor-ui/src/widgets/property_panel_text.rs index 72392981..b8ba6345 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_text.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_text.rs @@ -3,12 +3,13 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel::{ - FontFamilyChoice, NodeSnapshot, PropertyPanelAction, TextAlignValue, TextGrowthValue, - TextVerticalAlignValue, + FontFamilyChoice, FontWeightChoice, NodeSnapshot, PropertyPanelAction, TextAlignValue, + TextGrowthValue, TextVerticalAlignValue, }; use crate::widgets::property_panel_inputs::{ - paint_input_with_prefix_focused, paint_section_divider, paint_section_label, to_jian_color, - INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, + paint_input_with_icon_focused, paint_input_with_prefix_focused, paint_section_divider, + paint_section_label, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, SECTION_GAP, + SECTION_HEADER_HEIGHT, }; use crate::widgets::property_panel_sections::EditContext; use crate::widgets::PaintCx; @@ -19,6 +20,9 @@ const FAMILY_ROW_GAP: f32 = 6.0; const ALIGN_LABEL_H: f32 = 18.0; const BUTTON_H: f32 = 28.0; const TEXT_LAYOUT_BLOCK_H: f32 = SECTION_HEADER_HEIGHT + BUTTON_H + 12.0; +/// Height of the small 行高 / 字间距 caption row painted above the +/// line-height / letter-spacing inputs (TS `text-[9px]` label row). +const LH_LS_LABEL_H: f32 = 14.0; pub fn text_section_height() -> f32 { TEXT_LAYOUT_BLOCK_H @@ -27,6 +31,7 @@ pub fn text_section_height() -> f32 { + FAMILY_ROW_GAP + INPUT_HEIGHT + 6.0 + + LH_LS_LABEL_H + INPUT_HEIGHT + 8.0 + ALIGN_LABEL_H @@ -45,13 +50,8 @@ pub fn push_text_input_rects( ) { let half_w = (usable_w - 8.0) / 2.0; let mut y = y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + FAMILY_ROW_GAP; - rects.push(( - PropertyFocus::FontWeight, - Rect { - origin: Point2D::new(x0 + PAD_X, y), - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - )); + // Weight (left half) is now a dropdown (a ToggleFontWeightPicker + // action rect), not a focusable input — only Font Size remains here. rects.push(( PropertyFocus::FontSize, Rect { @@ -59,7 +59,9 @@ pub fn push_text_input_rects( size: Point2D::new(half_w, INPUT_HEIGHT), }, )); - y += INPUT_HEIGHT + 6.0; + // +LH_LS_LABEL_H to skip the 行高/字间距 caption row that paints + // above the inputs (keeps hit-test aligned with paint). + y += INPUT_HEIGHT + 6.0 + LH_LS_LABEL_H; rects.push(( PropertyFocus::LineHeight, Rect { @@ -110,6 +112,17 @@ pub fn text_action_rects(x0: f32, y: f32, usable_w: f32) -> Vec<(PropertyPanelAc size: Point2D::new(usable_w, INPUT_HEIGHT), }, )); + // Weight dropdown trigger — left half of the weight/size row. + let weight_row_y = + y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + FAMILY_ROW_GAP; + let weight_half_w = (usable_w - 8.0) / 2.0; + out.push(( + PropertyPanelAction::ToggleFontWeightPicker, + Rect { + origin: Point2D::new(x0 + PAD_X, weight_row_y), + size: Point2D::new(weight_half_w, INPUT_HEIGHT), + }, + )); let mut y = y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT @@ -188,6 +201,38 @@ pub fn font_family_picker_action_rects( .collect() } +/// Dropdown rows for the weight picker — opens below the left-half +/// weight trigger of the weight/size row. +pub fn font_weight_picker_action_rects( + x0: f32, + y: f32, + usable_w: f32, +) -> Vec<(PropertyPanelAction, Rect)> { + // Full-width rows so the "number + name" labels (e.g. "800 Extra + // Bold") fit; the trigger sits on the left half but a dropdown may + // be wider than its trigger. + let weight_y = y + + TEXT_LAYOUT_BLOCK_H + + SECTION_HEADER_HEIGHT + + INPUT_HEIGHT + + FAMILY_ROW_GAP + + INPUT_HEIGHT + + 4.0; + FontWeightChoice::ALL + .into_iter() + .enumerate() + .map(|(i, choice)| { + ( + PropertyPanelAction::SetFontWeight(choice), + Rect { + origin: Point2D::new(x0 + PAD_X, weight_y + i as f32 * 28.0), + size: Point2D::new(usable_w, 28.0), + }, + ) + }) + .collect() +} + #[allow(clippy::too_many_arguments)] pub fn paint_text_section( cx: &mut PaintCx<'_>, @@ -252,18 +297,39 @@ pub fn paint_text_section( y += INPUT_HEIGHT + FAMILY_ROW_GAP; let half_w = (usable_w - 8.0) / 2.0; - let weight = text.font_weight.to_string(); - paint_input_with_prefix_focused( - cx, - theme, - Rect { - origin: Point2D::new(x + PAD_X, y), - size: Point2D::new(half_w, INPUT_HEIGHT), - }, - "W", - edit.value_for(PropertyFocus::FontWeight, &weight), - edit.focus == Some(PropertyFocus::FontWeight), - edit.caret_at(PropertyFocus::FontWeight), + // Weight dropdown trigger — named weight (粗体 / 常规 / …) + chevron, + // mirroring the font-family trigger (TS Select parity). + let weight_rect = Rect { + origin: Point2D::new(x + PAD_X, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }; + cx.backend + .fill_round_rect(weight_rect, INPUT_RADIUS, theme.muted); + let weight_label = op_i18n::translate( + locale, + FontWeightChoice::nearest(text.font_weight).label_key(), + ); + let weight_text = TextLayout::single_run( + weight_label, + "system-ui", + 12.0, + to_jian_color(theme.foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &weight_text, + Point2D::new(weight_rect.origin.x + 10.0, weight_rect.origin.y + 19.0), + ); + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new( + weight_rect.origin.x + weight_rect.size.x - 18.0, + weight_rect.origin.y + 8.0, + ), + 14.0, + theme.muted_foreground, + 1.5, ); let font_size = format_panel_number(text.font_size); paint_input_with_prefix_focused( @@ -280,19 +346,50 @@ pub fn paint_text_section( ); y += INPUT_HEIGHT + 6.0; + // Caption row — 行高 (left) / 字间距 (right), small muted labels + // above the inputs (TS `text-[9px] justify-between`). + let caption_color = to_jian_color(theme.muted_foreground); + let lh_caption = TextLayout::single_run( + op_i18n::translate(locale, "text.lineHeight"), + "system-ui", + 9.0, + caption_color, + Point2D::new(0.0, 0.0), + ); + cx.backend + .draw_text(&lh_caption, Point2D::new(x + PAD_X + 2.0, y + 10.0)); + let ls_label = op_i18n::translate(locale, "text.letterSpacing"); + let ls_caption = TextLayout::single_run( + ls_label, + "system-ui", + 9.0, + caption_color, + Point2D::new(0.0, 0.0), + ); + let ls_caption_w = cx.backend.measure_text(ls_label, 9.0); + cx.backend.draw_text( + &ls_caption, + Point2D::new(x + width - PAD_X - 2.0 - ls_caption_w, y + 10.0), + ); + y += LH_LS_LABEL_H; + + // Line-height — icon prefix + value + `%` suffix (TS NumberInput + // with `icon={LineHeightIcon}` + `suffix="%"`). let line_height = format_panel_number(text.line_height_percent); - paint_input_with_prefix_focused( + paint_input_with_icon_focused( cx, theme, Rect { origin: Point2D::new(x + PAD_X, y), size: Point2D::new(half_w, INPUT_HEIGHT), }, - "LH", + Icon::LineHeight, edit.value_for(PropertyFocus::LineHeight, &line_height), + Some("%"), edit.focus == Some(PropertyFocus::LineHeight), edit.caret_at(PropertyFocus::LineHeight), ); + // Letter-spacing — `|A|` text prefix (TS NumberInput `label="|A|"`). let letter_spacing = format_panel_number(text.letter_spacing); paint_input_with_prefix_focused( cx, @@ -301,7 +398,7 @@ pub fn paint_text_section( origin: Point2D::new(x + PAD_X + half_w + 8.0, y), size: Point2D::new(half_w, INPUT_HEIGHT), }, - "LS", + "|A|", edit.value_for(PropertyFocus::LetterSpacing, &letter_spacing), edit.focus == Some(PropertyFocus::LetterSpacing), edit.caret_at(PropertyFocus::LetterSpacing), @@ -381,6 +478,84 @@ pub fn paint_font_family_picker( } } +#[allow(clippy::too_many_arguments)] +pub fn paint_font_weight_picker( + cx: &mut PaintCx<'_>, + theme: &Theme, + panel_rect: Rect, + visible: crate::widgets::property_panel_layout::VisibleSections, + locale: op_editor_core::Locale, + active_weight: u16, + hover: Option, +) { + let x0 = panel_rect.origin.x; + let w = panel_rect.size.x; + let usable_w = w - PAD_X * 2.0; + let Some(text_y) = text_section_top(panel_rect, visible) else { + return; + }; + let rows = font_weight_picker_action_rects(x0, text_y, usable_w); + if rows.is_empty() { + return; + } + let first = rows.first().map(|(_, r)| *r).unwrap(); + let last = rows.last().map(|(_, r)| *r).unwrap(); + let pop = Rect { + origin: Point2D::new(first.origin.x, first.origin.y - 6.0), + size: Point2D::new( + first.size.x, + last.origin.y + last.size.y - first.origin.y + 12.0, + ), + }; + cx.backend.fill_round_rect(pop, 8.0, theme.popover); + cx.backend.stroke_round_rect(pop, 8.0, theme.border, 1.0); + let active = FontWeightChoice::nearest(active_weight); + for (i, (action, row)) in rows.into_iter().enumerate() { + let PropertyPanelAction::SetFontWeight(choice) = action else { + continue; + }; + let is_active = choice == active; + if is_active { + cx.backend + .fill_round_rect(row, 6.0, theme.row_selected_primary); + } else if hover == Some(i) { + // Muted hover wash matching the other dropdowns. + cx.backend.fill_round_rect(row, 6.0, theme.button_hover); + } + // "number + name" — e.g. `400 Regular`, `800 Extra Bold`. + let row_label = format!( + "{} {}", + choice.numeric_label(), + op_i18n::translate(locale, choice.label_key()) + ); + let label = TextLayout::single_run( + &row_label, + "system-ui", + 12.0, + to_jian_color(if is_active { + theme.primary + } else { + theme.foreground + }), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &label, + Point2D::new(row.origin.x + 10.0, row.origin.y + 19.0), + ); + if is_active { + draw_icon( + cx.backend, + Icon::Check, + Point2D::new(row.origin.x + row.size.x - 22.0, row.origin.y + 7.0), + 14.0, + theme.primary, + 1.6, + ); + } + } +} + fn text_section_top( panel_rect: Rect, visible: crate::widgets::property_panel_layout::VisibleSections, @@ -392,14 +567,17 @@ fn text_section_top( y += crate::widgets::property_panel_inputs::TAB_HEIGHT; y += crate::widgets::property_panel_inputs::HEADER_HEIGHT; if visible.create_component { - y += 8.0 + 36.0 + 12.0; + y += crate::widgets::property_panel_inputs::CREATE_COMPONENT_BLOCK_H; } y += SECTION_HEADER_HEIGHT; y += INPUT_HEIGHT + 6.0; y += INPUT_HEIGHT + 12.0; y += SECTION_GAP; if visible.flex_layout { - y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode); + y += crate::widgets::property_panel_flex::flex_section_height( + visible.flex_layout_mode, + visible.padding_edit_mode, + ); } if visible.size_options { y += SECTION_HEADER_HEIGHT; diff --git a/crates/op-editor-ui/src/widgets/property_panel_visibility.rs b/crates/op-editor-ui/src/widgets/property_panel_visibility.rs index 0f7921a7..1a720839 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_visibility.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_visibility.rs @@ -180,9 +180,19 @@ pub struct VisibleSections { pub create_component: bool, pub flex_layout: bool, pub flex_layout_mode: op_editor_core::FlexLayout, + /// Resolved padding edit mode (UI pin or derived from values) — + /// drives the padding row count so all walkers agree. + pub padding_edit_mode: op_editor_core::PaddingEditMode, pub layout_justify: LayoutJustifyValue, pub layout_align: LayoutAlignValue, pub size_options: bool, + /// Per-dimension sizing masks — when set, the matching W/H input is + /// hidden (fill/hug replaces the numeric field, TS size-section + /// parity). Read by `editable_input_rects` to gate the hit-rect. + pub size_fill_width: bool, + pub size_fill_height: bool, + pub size_hug_width: bool, + pub size_hug_height: bool, pub clip_content: bool, pub text: bool, pub icon: bool, @@ -204,9 +214,14 @@ impl VisibleSections { create_component: true, flex_layout: true, flex_layout_mode: op_editor_core::FlexLayout::Free, + padding_edit_mode: op_editor_core::PaddingEditMode::Individual, layout_justify: LayoutJustifyValue::Start, layout_align: LayoutAlignValue::Start, size_options: true, + size_fill_width: false, + size_fill_height: false, + size_hug_width: false, + size_hug_height: false, clip_content: true, text: false, icon: false, diff --git a/crates/op-host-desktop/src/app_handler.rs b/crates/op-host-desktop/src/app_handler.rs index dc0be9f7..f48c554a 100644 --- a/crates/op-host-desktop/src/app_handler.rs +++ b/crates/op-host-desktop/src/app_handler.rs @@ -61,9 +61,20 @@ impl ApplicationHandler for DesktopApp { #[cfg(target_os = "macos")] { use winit::platform::macos::WindowAttributesExtMacOS; - // Push the traffic lights down so they sit roughly - // centred in the 40 px `TopBar` rather than the 28 px - // native title bar. Tuned by eye. + // Push the traffic lights down so their centre lines up with + // the `TopBar`'s app icon GLYPHS. The icon buttons paint their + // glyph centred at `TOP_BAR_HEIGHT / 2` = 20 px below the + // window top (the 28 px hit box top+8 is wider than the glyph + // and is NOT the visual centre). AppKit's default button centre + // for a fullsize-content / transparent titlebar window is + // `casement`'s `reposition_traffic_lights` lowers the button + // by `inset` points from AppKit's default baseline. The + // geometric centre is ~6, but by eye the user wants more top + // margin so the dots drop onto the icon row. The casement + // fork now applies this on `windowDidBecomeKey` (it was a + // no-op at window creation — the buttons didn't exist yet, + // so the inset only took effect after a resize). 4 px lands + // the dots on the icon-glyph row (tuned by eye with the user). attrs = attrs .with_titlebar_transparent(true) .with_fullsize_content_view(true) @@ -304,7 +315,27 @@ impl ApplicationHandler for DesktopApp { self.win_pos = Some((pos.x, pos.y)); } } + WindowEvent::HoveredFile(_path) => { + // A file is being dragged over the window — show the + // full-canvas drop overlay so the target is obvious. + if !self.host.editor_state().editor_ui.file_drop_active { + self.host.editor_state_mut().editor_ui.file_drop_active = true; + self.host.mark_editor_state_dirty(); + self.request_redraw(true); + } + } + WindowEvent::HoveredFileCancelled => { + // The drag left the window without dropping — hide it. + if self.host.editor_state().editor_ui.file_drop_active { + self.host.editor_state_mut().editor_ui.file_drop_active = false; + self.host.mark_editor_state_dirty(); + self.request_redraw(true); + } + } WindowEvent::DroppedFile(path) => { + // Clear the drag overlay now that the drop has landed. + self.host.editor_state_mut().editor_ui.file_drop_active = false; + self.host.mark_editor_state_dirty(); // Drag-and-drop open. `.op` / `.pen` documents route // through the canonical loader; `.fig` Figma exports // route through the background Figma import worker diff --git a/crates/op-host-desktop/src/git_host.rs b/crates/op-host-desktop/src/git_host.rs index 5243f604..ca86cc00 100644 --- a/crates/op-host-desktop/src/git_host.rs +++ b/crates/op-host-desktop/src/git_host.rs @@ -38,6 +38,10 @@ impl DesktopApp { panel.changed_files.clear(); panel.remotes.clear(); panel.recent_commits.clear(); + // No repo → no ready view → no header popovers. + panel.branch_picker_open = false; + panel.overflow_open = false; + panel.overflow_view = op_editor_core::GitOverflowView::Menu; // Cleared synchronously — there is nothing to wait for. panel.loading = false; } @@ -82,6 +86,15 @@ impl DesktopApp { panel.changed_files = snap.changed_files; panel.remotes = snap.remotes; panel.recent_commits = snap.recent_commits; + // The header popovers only exist in the clean ready view; if the + // refresh left that state (no repo / merging / a dirty tree), + // clear the popover flags so they can't go stale and dead-end + // input (the press-time modal guard keys off these flags). + if !panel.in_repo || panel.merging || !panel.changed_files.is_empty() { + panel.branch_picker_open = false; + panel.overflow_open = false; + panel.overflow_view = op_editor_core::GitOverflowView::Menu; + } // The fresh snapshot has landed — leave the loading state. let was_loading = panel.loading; panel.loading = false; @@ -202,6 +215,62 @@ impl DesktopApp { } } } + GitPanelAction::CommitMilestone => { + let message = self + .host + .editor_state() + .editor_ui + .git_panel + .commit_message + .trim() + .to_string(); + // Ready-view "Save milestone": snapshot the live design + // as a commit in one step — write the editor's current + // state to the tracked .op, stage that file, then commit + // (the TS `commitMilestone` flow). stage_tracked is + // explicitly designed to refresh the index blob after a + // save, so a milestone captures exactly what's on screen. + if !message.is_empty() { + match self.git_session.tracked_file().map(|p| p.to_path_buf()) { + Some(path) => { + match persistence::save_to_path(self.host.editor_state(), &path) { + Ok(()) => { + self.mark_document_saved(); + let committed = self + .git_session + .stage_tracked() + .and_then(|()| self.git_session.commit_staged(&message)); + match committed { + Ok(()) => { + let panel = &mut self + .host + .editor_state_mut() + .editor_ui + .git_panel; + panel.commit_message.clear(); + panel.commit_focused = false; + } + Err(err) => self.show_git_op_error_dialog("commit", &err), + } + } + Err(detail) => persistence::show_error_dialog_public( + &self.host, + persistence::ErrorKind::Save, + Some(&path), + &detail, + ), + } + } + None => persistence::show_error_dialog_public( + &self.host, + persistence::ErrorKind::Save, + None, + "the open document is not tracked under this repository — \ + save it into the repository folder first", + ), + } + } + } GitPanelAction::SwitchBranch(name) => { self.run_reloading_git_op("switch", |repo| repo.switch_branch(&name)); } diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index 941ec6e6..2f94048c 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -40,7 +40,10 @@ fn fresh_app_fits_blank_frame_like_ts_canvas_init() { let app = DesktopApp::new(None); let v = app.host.editor_state().viewport; - assert!((v.zoom - 0.66).abs() < 1e-3, "zoom {}", v.zoom); + // Golden fit values track `property_panel_width` (the right rail is + // shown on the fresh app, so the canvas region = 1440 − panel). At + // the TS-matching `w-64` (256 px) panel the blank frame fits at 0.68. + assert!((v.zoom - 0.68).abs() < 1e-3, "zoom {}", v.zoom); assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x); - assert!((v.pan_y - 166.0).abs() < 1e-2, "pan_y {}", v.pan_y); + assert!((v.pan_y - 158.0).abs() < 1e-2, "pan_y {}", v.pan_y); } diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 70a017b0..9136e7bc 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -62,6 +62,7 @@ mod press; mod press_helpers; mod property_dispatch; mod property_layout_dispatch; +mod property_popovers; mod scroll; mod settings_dispatch; mod shape_picker_press; @@ -241,6 +242,12 @@ pub enum PanelResizeKind { pub(in crate::widget_host) struct NodeDragState { pub(in crate::widget_host) last_screen_x: f32, pub(in crate::widget_host) last_screen_y: f32, + /// Press point in screen px — the drag-threshold anchor. + pub(in crate::widget_host) press_screen_x: f32, + pub(in crate::widget_host) press_screen_y: f32, + /// Latches true once the cursor travels past `NODE_DRAG_THRESHOLD_PX` + /// so a pure click with sub-pixel jitter never moves anything. + pub(in crate::widget_host) moved: bool, } /// Active handle-drag — captures the press cursor anchor + the @@ -667,6 +674,17 @@ impl WidgetHostNative { 500, )); } + // Git commit textarea caret — same 530 ms cadence the ready + // panel paints at (`git_panel_ready.rs`). Without this wake the + // window never repaints while the commit box is focused, so the + // caret sits static instead of blinking. + if self.editor_state.editor_ui.git_panel.commit_focused { + return Some(jian_core::anim::next_blink_flip_ms( + self.now_ms, + self.editor_state.editor_ui.git_panel.commit_caret_anchor_ms, + 530, + )); + } None } } 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 9c692946..be1e9c80 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 @@ -22,6 +22,60 @@ fn host_with_git_panel_open() -> WidgetHostNative { host } +#[test] +fn open_git_popover_is_modal_and_dismisses_on_any_outside_press() { + let mut host = WidgetHostNative::new(); + { + let panel = &mut host.editor_state_mut().editor_ui.git_panel; + panel.open = true; + panel.loading = false; + panel.in_repo = true; // clean bound repo → ready view + panel.branch = Some("main".to_string()); + panel.branch_picker_open = true; + } + let (vw, vh) = (1440.0, 900.0); + // A press far from the panel (top-left of the canvas) must close the + // open branch-picker popover AND be consumed (modal). + let consumed = host.apply_press(8.0, 220.0, vw, vh); + assert!(consumed, "a press while a Git popover is open is consumed"); + assert!( + !host.editor_state().editor_ui.git_panel.branch_picker_open, + "an outside press must dismiss the open branch-picker popover" + ); +} + +#[test] +fn stale_git_popover_flag_does_not_dead_end_input() { + // A popover flag left `true` while the panel is NOT in the ready + // view (here: a dirty working tree) must not swallow + drop every + // press: the modal guard only consumes a press the Git panel + // actually handled, so an outside click still reaches the canvas. + let mut host = WidgetHostNative::new(); + { + 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()); + // Dirty tree → NOT the ready view (so `hit_test` won't capture + // the popover), yet the flag is stale-true. + panel.changed_files = vec![op_editor_core::GitFileEntry { + path: "x.op".into(), + staged: false, + status: 'M', + }]; + panel.branch_picker_open = true; + } + let (vw, vh) = (1440.0, 900.0); + // A press on the empty canvas (centre, well below the panel) must + // reach the empty-canvas handler (which consumes it) rather than + // being dead-ended to `false` by the modal guard. + assert!( + host.apply_press(700.0, 600.0, vw, vh), + "a stale popover flag must not dead-end the press" + ); +} + #[test] fn git_panel_hangs_centred_under_the_git_button() { let host = host_with_git_panel_open(); 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 fb1d2f4d..fd7943d3 100644 --- a/crates/op-host-native/src/widget_host/git_press.rs +++ b/crates/op-host-native/src/widget_host/git_press.rs @@ -6,7 +6,7 @@ //! after the rail blocks (which already skip a click that lands //! inside the Git-panel rect) and before the canvas overlays. -use op_editor_core::{GitDiffTarget, GitPanelAction}; +use op_editor_core::{GitDiffTarget, GitOverflowView, GitPanelAction}; use op_editor_ui::widgets::{GitPanel, GitPanelHit}; use op_editor_ui::Point2D; @@ -49,10 +49,12 @@ impl WidgetHostNative { let on_caret = self .git_panel_outer_rect(viewport_width, viewport_height) .is_some_and(|r| rect_contains(r, Point2D::new(x, y))); + let now = self.now_ms; let panel = &mut self.editor_state.editor_ui.git_panel; match hit { Some(GitPanelHit::CommitInput) => { panel.commit_focused = true; + panel.commit_caret_anchor_ms = now; panel.remote_focused = false; panel.https_focused = false; } @@ -90,6 +92,14 @@ impl WidgetHostNative { panel.pending_action = Some(GitPanelAction::Commit); } } + Some(GitPanelHit::CommitMilestone) => { + // Ready-view Save milestone — saves the live design to + // the tracked .op + stages + commits in one step, so it + // only needs a non-empty message (no pre-staged file). + if !panel.commit_message.trim().is_empty() { + panel.pending_action = Some(GitPanelAction::CommitMilestone); + } + } Some(GitPanelHit::EmptyInit) => { panel.pending_action = Some(GitPanelAction::InitRepo); } @@ -108,6 +118,40 @@ impl WidgetHostNative { Some(GitPanelHit::Push) => { panel.pending_action = Some(GitPanelAction::Push); } + Some(GitPanelHit::BranchPicker) => { + // 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.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + } + Some(GitPanelHit::Overflow) => { + // 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_view = GitOverflowView::Menu; + panel.branch_picker_open = false; + } + Some(GitPanelHit::OverflowRemoteSettings) => { + panel.overflow_view = GitOverflowView::RemoteSettings; + } + Some(GitPanelHit::OverflowSshKeys) => { + panel.pending_action = Some(GitPanelAction::SetupSshAuth); + panel.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + } + Some(GitPanelHit::OverflowBack) => { + panel.overflow_view = GitOverflowView::Menu; + } + Some(GitPanelHit::DismissPopover) => { + // Click outside an open popover — close it + swallow. + panel.branch_picker_open = false; + panel.overflow_open = false; + panel.overflow_view = GitOverflowView::Menu; + panel.commit_focused = false; + panel.remote_focused = false; + panel.https_focused = false; + } Some(GitPanelHit::AbortMerge) => { panel.pending_action = Some(GitPanelAction::AbortMerge); } @@ -118,11 +162,14 @@ impl WidgetHostNative { if let Some(name) = panel.branches.get(index).cloned() { panel.pending_action = Some(GitPanelAction::SwitchBranch(name)); } + // Close the branch-picker dropdown after a pick. + panel.branch_picker_open = false; } 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; } Some(GitPanelHit::ShowWorkingDiff) => { panel.pending_action = Some(GitPanelAction::ShowDiff(GitDiffTarget::WorkingTree)); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index e72b7092..dfc05c5a 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -4,6 +4,11 @@ use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH}; use super::{PanelResizeKind, WidgetHostNative}; use op_editor_ui::{Point2D, Rect}; +/// Minimum cursor travel (logical px) from the node-drag press point +/// before a move is committed. A pure click with sub-pixel jitter then +/// never mutates the document — kills "first click breaks the layout". +const NODE_DRAG_THRESHOLD_PX: f32 = 4.0; + impl WidgetHostNative { /// True iff a text-input surface owns the keyboard. pub(in crate::widget_host) fn input_active(&self) -> bool { @@ -212,6 +217,14 @@ impl WidgetHostNative { { return true; } + // Padding-mode gear popover row hover (no-op when closed). + if self.update_padding_mode_popover_hover(x, y) { + return true; + } + // Font-weight dropdown row hover (no-op when closed). + if self.update_font_weight_picker_hover(x, y) { + return true; + } // TopBar window-control cluster — hovering it reveals the // close / minimise / maximise glyphs on the 3 dots. { @@ -296,6 +309,22 @@ impl WidgetHostNative { return true; } if let Some(drag) = self.node_drag { + // Threshold gate: a pure click with sub-pixel jitter must not + // move (or re-flow) anything. Until the cursor has travelled + // past NODE_DRAG_THRESHOLD_PX from the press point, swallow the + // move; once it crosses, the drag latches and moves for the + // rest of the gesture. + if !drag.moved + && (x - drag.press_screen_x).abs() <= NODE_DRAG_THRESHOLD_PX + && (y - drag.press_screen_y).abs() <= NODE_DRAG_THRESHOLD_PX + { + return false; + } + if !drag.moved { + if let Some(d) = self.node_drag.as_mut() { + d.moved = true; + } + } let zoom = self.editor_state.viewport.zoom.max(0.0001); let prev_screen_x = drag.last_screen_x; let prev_screen_y = drag.last_screen_y; diff --git a/crates/op-host-native/src/widget_host/input_drag_tests.rs b/crates/op-host-native/src/widget_host/input_drag_tests.rs index 99c967de..c575a45a 100644 --- a/crates/op-host-native/src/widget_host/input_drag_tests.rs +++ b/crates/op-host-native/src/widget_host/input_drag_tests.rs @@ -209,6 +209,11 @@ fn node_drag_snap_does_not_trap_incremental_cursor_motion() { host.node_drag = Some(NodeDragState { last_screen_x: 500.0, last_screen_y: 500.0, + press_screen_x: 500.0, + press_screen_y: 500.0, + // This test exercises smart-guide accumulation on an already- + // in-progress drag, not the press-time threshold gate. + moved: true, }); for x in (502..=522).step_by(2) { diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index ad767de0..cc0d9de8 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -43,7 +43,11 @@ impl WidgetHostNative { // Git panel's commit-message input owns the keyboard next. if self.git_commit_focus_active() { if !c.is_control() { - self.editor_state.editor_ui.git_panel.commit_message.push(c); + let now = self.now_ms; + let panel = &mut self.editor_state.editor_ui.git_panel; + panel.commit_message.push(c); + // Keep the caret solid while typing (reset the blink). + panel.commit_caret_anchor_ms = now; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 0eff3274..dae7c1af 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -182,7 +182,12 @@ impl WidgetHostNative { .as_ref() .map(|v| !v.is_empty()) .unwrap_or(false); - if has_variables { + // The PropertyPanel owns the right rail whenever a node is + // selected (TS parity); painting the opaque VariablesPanel over + // it obscured the inspector (e.g. the flex alignment grid) and + // its press handler swallowed those clicks. Only show Variables + // when there is no selection inspector. + if has_variables && !has_property { let vars = VariablesPanel::for_editor(&self.editor_state); let intrinsic = vars.intrinsic_height(); let top_y = if has_property { @@ -325,7 +330,7 @@ impl WidgetHostNative { // top-left this was moot; centred under the button it now // overlaps the align toolbar, so the orders must agree. if let Some(panel_rect) = self.git_panel_rect(viewport_width, viewport_height) { - let panel = GitPanel::for_editor(&self.editor_state) + let panel = GitPanel::for_editor_at(&self.editor_state, self.now_ms) .expect("git_panel_rect is Some, so the panel is open"); let git_theme = theme_for(&self.editor_state.editor_ui); let mut cx = PaintCx { @@ -530,5 +535,22 @@ impl WidgetHostNative { }; panel.paint(&mut cx, panel_rect); } + + // 13. File-drop overlay — top-most layer, above every panel and + // modal, while a file is dragged over the window. + if self.editor_state.editor_ui.file_drop_active { + let (drop_left, _y, drop_w, drop_h) = + self.canvas_region(viewport_width, viewport_height); + let drop_rect = Rect { + origin: Point2D::new(drop_left, TOP_BAR_HEIGHT), + size: Point2D::new(drop_w, drop_h), + }; + op_editor_ui::widgets::file_drop_overlay::paint_file_drop_overlay( + &mut *frame, + &self.theme, + self.editor_state.editor_ui.locale, + drop_rect, + ); + } } } diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index fb99e415..802cf9f0 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -190,6 +190,31 @@ impl WidgetHostNative { return true; } + // 0-git-modal. While a Git ready-state header popover (branch + // picker / overflow / its subview) is open it is MODAL: route + // every press to the Git panel first so a click anywhere + // outside the popover dismisses it. The popover extends past the + // panel rect, so the rail / top-bar / canvas blocks below cannot + // be relied on to forward an outside click — `hit_test` returns + // `DismissPopover` for any outside-popover point. + // + // IMPORTANT: fall through on `false`. If the popover flags are + // stale (set while the panel was ready, but it has since left + // the ready state — gone dirty / merging / loading, so + // `hit_test`'s ready-gated popover capture no longer fires), + // `dispatch_git_panel_press` returns `false` for an outside + // click. Returning that directly would dead-end EVERY press; so + // only consume when it actually handled the click. + { + let gp = &self.editor_state.editor_ui.git_panel; + if gp.open + && (gp.branch_picker_open || gp.overflow_open) + && self.dispatch_git_panel_press(x, y, viewport_width, viewport_height) + { + return true; + } + } + // StatusBar search icon → frame the page content in the // viewport (floating bottom-right, so it hit-tests above the // canvas / toolbar). @@ -402,6 +427,20 @@ impl WidgetHostNative { return true; } + // 0c0a2. Text font-weight picker — outside-click dismiss. + if !in_git_panel + && self.dismiss_font_weight_picker_on_press(x, y, viewport_width, viewport_height) + { + return true; + } + + // 0c0a3. Padding mode-selector popover — outside-click dismiss. + if !in_git_panel + && self.dismiss_padding_mode_popover_on_press(x, y, viewport_width, viewport_height) + { + return true; + } + // 0c0b. Export scale / format inline select popup — // outside-click dismiss (`property_dispatch.rs`). if !in_git_panel @@ -712,6 +751,9 @@ impl WidgetHostNative { self.node_drag = Some(NodeDragState { last_screen_x: x, last_screen_y: y, + press_screen_x: x, + press_screen_y: y, + moved: false, }); } self.mark_dirty(); @@ -727,6 +769,9 @@ impl WidgetHostNative { self.node_drag = Some(NodeDragState { last_screen_x: x, last_screen_y: y, + press_screen_x: x, + press_screen_y: y, + moved: false, }); self.mark_dirty(); return true; diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index ed2a2418..ae1390a8 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -593,11 +593,10 @@ pub(in crate::widget_host) fn property_focus_initial( .fill .map(color_to_hex) .unwrap_or_else(|| "#FFFFFF".to_string()), - F::StrokeHex => panel - .snapshot - .stroke - .map(|s| color_to_hex(s.color)) - .unwrap_or_else(|| "#000000".to_string()), + // Seed the SAME color the stroke swatch paints (the real stroke + // when set, else the slate placeholder) so clicking the hex input + // doesn't flip it to #000000. + F::StrokeHex => color_to_hex(panel.snapshot.stroke_swatch_color()), F::StrokeWidth => panel .snapshot .stroke 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 a9ba8236..67497fd1 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -56,6 +56,7 @@ impl WidgetHostNative { ui.fill_type_picker_open = !ui.fill_type_picker_open; ui.image_fill_popover_open = false; ui.font_family_picker_open = false; + ui.font_weight_picker_open = false; } A::SetFillType(t) => { self.editor_state.set_selected_fill_type(t); @@ -83,6 +84,7 @@ impl WidgetHostNative { ui.image_fill_popover_open = !ui.image_fill_popover_open; ui.fill_type_picker_open = false; ui.font_family_picker_open = false; + ui.font_weight_picker_open = false; ui.export_scale_picker_open = false; ui.export_format_picker_open = false; } @@ -110,6 +112,7 @@ impl WidgetHostNative { ui.fill_type_picker_open = false; ui.image_fill_popover_open = false; ui.font_family_picker_open = false; + ui.font_weight_picker_open = false; ui.export_scale_picker_open = false; ui.export_format_picker_open = false; } @@ -125,6 +128,7 @@ impl WidgetHostNative { A::ToggleFontFamilyPicker => { let ui = &mut self.editor_state.editor_ui; ui.font_family_picker_open = !ui.font_family_picker_open; + ui.font_weight_picker_open = false; ui.fill_type_picker_open = false; ui.image_fill_popover_open = false; ui.export_scale_picker_open = false; @@ -134,6 +138,43 @@ impl WidgetHostNative { self.set_selected_text_font_family(choice.family()); self.editor_state.editor_ui.font_family_picker_open = false; } + A::ToggleFontWeightPicker => { + let ui = &mut self.editor_state.editor_ui; + ui.font_weight_picker_open = !ui.font_weight_picker_open; + ui.font_weight_picker_hover = None; + ui.font_family_picker_open = false; + ui.fill_type_picker_open = false; + ui.image_fill_popover_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::SetFontWeight(choice) => { + self.set_selected_font_weight(choice.value()); + self.editor_state.editor_ui.font_weight_picker_open = false; + self.editor_state.editor_ui.font_weight_picker_hover = None; + } + A::TogglePaddingModePopover => { + let ui = &mut self.editor_state.editor_ui; + ui.padding_mode_popover_open = !ui.padding_mode_popover_open; + ui.padding_mode_popover_hover = None; + ui.font_weight_picker_open = false; + ui.font_family_picker_open = false; + ui.fill_type_picker_open = false; + ui.image_fill_popover_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::SetPaddingMode(mode) => { + // Scope the pin to the node it was set for so it can't + // leak into the next selection. + let anchor = self.editor_state.selection.anchor.as_str().to_string(); + self.editor_state.editor_ui.padding_edit_mode = Some(mode); + self.editor_state.editor_ui.padding_edit_mode_anchor = anchor; + self.editor_state.editor_ui.padding_mode_popover_open = false; + self.editor_state.editor_ui.padding_mode_popover_hover = None; + self.editor_state.commit_history(); + let _ = self.editor_state.set_selected_padding_mode_shape(mode); + } A::OpenColorPicker(target) => { // Fallback anchor when called outside the press path. let _ = self @@ -145,6 +186,7 @@ impl WidgetHostNative { ui.export_scale_picker_open = !ui.export_scale_picker_open; ui.export_format_picker_open = false; ui.font_family_picker_open = false; + ui.font_weight_picker_open = false; ui.export_picker_hover = None; } A::ToggleExportFormatPicker => { @@ -152,6 +194,7 @@ impl WidgetHostNative { ui.export_format_picker_open = !ui.export_format_picker_open; ui.export_scale_picker_open = false; ui.font_family_picker_open = false; + ui.font_weight_picker_open = false; ui.export_picker_hover = None; } A::SetExportScale(scale) => { @@ -240,134 +283,6 @@ impl WidgetHostNative { self.mark_dirty(); } - /// Image-fill popover outside-click dismiss. Returns `true` - /// when the popover was open and the press was consumed. - pub(in crate::widget_host) fn dismiss_image_fill_popover_on_press( - &mut self, - x: f32, - y: f32, - viewport_width: f32, - viewport_height: f32, - ) -> bool { - use op_editor_ui::widgets::{PropertyPanel, TOP_BAR_HEIGHT}; - use op_editor_ui::{Point2D, Rect}; - if !self.editor_state.editor_ui.image_fill_popover_open { - return false; - } - self.refresh_layout_scene(); - if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { - let property_rect = Rect { - origin: Point2D::new( - viewport_width - self.editor_state.editor_ui.property_panel_width, - TOP_BAR_HEIGHT, - ), - size: Point2D::new( - self.editor_state.editor_ui.property_panel_width, - (viewport_height - TOP_BAR_HEIGHT).max(0.0), - ), - }; - if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { - self.apply_property_action(action); - return true; - } - if panel.image_fill_popover_contains(property_rect, Point2D::new(x, y)) { - return true; - } - } - self.editor_state.editor_ui.image_fill_popover_open = false; - self.mark_dirty(); - true - } - - /// Outside-click dismiss for the Export section's inline scale / - /// format select popups. Returns `true` when a picker was open - /// and the press was consumed — an option / toggle was applied, - /// or the press fell outside and dismissed the popup. The caller - /// must stop dispatching the press in that case. `false` when no - /// picker was open (press dispatch continues normally). - pub(in crate::widget_host) fn dismiss_export_picker_on_press( - &mut self, - x: f32, - y: f32, - viewport_width: f32, - viewport_height: f32, - ) -> bool { - use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; - use op_editor_ui::{Point2D, Rect}; - if !self.editor_state.editor_ui.export_scale_picker_open - && !self.editor_state.editor_ui.export_format_picker_open - { - return false; - } - self.refresh_layout_scene(); - if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { - let property_rect = Rect { - origin: Point2D::new( - viewport_width - self.editor_state.editor_ui.property_panel_width, - TOP_BAR_HEIGHT, - ), - size: Point2D::new( - self.editor_state.editor_ui.property_panel_width, - (viewport_height - TOP_BAR_HEIGHT).max(0.0), - ), - }; - if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { - if matches!( - action, - A::SetExportScale(_) - | A::SetExportFormat(_) - | A::ToggleExportScalePicker - | A::ToggleExportFormatPicker - ) { - self.apply_property_action(action); - return true; - } - } - } - let ui = &mut self.editor_state.editor_ui; - ui.export_scale_picker_open = false; - ui.export_format_picker_open = false; - ui.export_picker_hover = None; - self.mark_dirty(); - true - } - - pub(in crate::widget_host) fn dismiss_font_family_picker_on_press( - &mut self, - x: f32, - y: f32, - viewport_width: f32, - viewport_height: f32, - ) -> bool { - use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; - use op_editor_ui::{Point2D, Rect}; - if !self.editor_state.editor_ui.font_family_picker_open { - return false; - } - self.refresh_layout_scene(); - if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { - let property_rect = Rect { - origin: Point2D::new( - viewport_width - self.editor_state.editor_ui.property_panel_width, - TOP_BAR_HEIGHT, - ), - size: Point2D::new( - self.editor_state.editor_ui.property_panel_width, - (viewport_height - TOP_BAR_HEIGHT).max(0.0), - ), - }; - if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { - if matches!(action, A::SetFontFamily(_) | A::ToggleFontFamilyPicker) { - self.apply_property_action(action); - return true; - } - } - } - self.editor_state.editor_ui.font_family_picker_open = false; - self.mark_dirty(); - true - } - /// Export-dialog press dispatcher. pub(in crate::widget_host) fn dispatch_export_dialog_press( &mut self, @@ -618,6 +533,12 @@ impl WidgetHostNative { viewport_width: f32, viewport_height: f32, ) -> bool { + // Lockstep with paint: when a node is selected the PropertyPanel + // owns the right rail, so the VariablesPanel neither paints nor + // hit-tests there (otherwise it would eat inspector clicks). + if self.editor_state.property_panel_visible() { + return false; + } let has_variables = self .editor_state .doc diff --git a/crates/op-host-native/src/widget_host/property_layout_dispatch.rs b/crates/op-host-native/src/widget_host/property_layout_dispatch.rs index fc99d7a0..d2361541 100644 --- a/crates/op-host-native/src/widget_host/property_layout_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_layout_dispatch.rs @@ -169,6 +169,18 @@ impl WidgetHostNative { self.set_selected_layout_keyword("fontFamily", family); } + /// Commit a named-weight choice from the typography weight dropdown. + pub(in crate::widget_host) fn set_selected_font_weight(&mut self, weight: u16) { + let id = self.editor_state.selection.anchor.clone(); + if !id.is_real() { + return; + } + self.editor_state.commit_history(); + let _ = self + .editor_state + .commit_property_edit(op_editor_core::PropertyFocus::FontWeight, weight as f32); + } + fn set_selected_layout_keyword(&mut self, property: &str, keyword: &str) { let id = self.editor_state.selection.anchor.clone(); if !id.is_real() { diff --git a/crates/op-host-native/src/widget_host/property_popovers.rs b/crates/op-host-native/src/widget_host/property_popovers.rs new file mode 100644 index 00000000..fefcfaac --- /dev/null +++ b/crates/op-host-native/src/widget_host/property_popovers.rs @@ -0,0 +1,288 @@ +//! Property-panel popover dismissal + hover tracking, split out of +//! `property_dispatch.rs` to keep both files under the 800-line cap. + +use super::WidgetHostNative; + +impl WidgetHostNative { + /// Image-fill popover outside-click dismiss. Returns `true` + /// when the popover was open and the press was consumed. + pub(in crate::widget_host) fn dismiss_image_fill_popover_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.image_fill_popover_open { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + self.apply_property_action(action); + return true; + } + if panel.image_fill_popover_contains(property_rect, Point2D::new(x, y)) { + return true; + } + } + self.editor_state.editor_ui.image_fill_popover_open = false; + self.mark_dirty(); + true + } + + /// Outside-click dismiss for the Export section's inline scale / + /// format select popups. Returns `true` when a picker was open + /// and the press was consumed — an option / toggle was applied, + /// or the press fell outside and dismissed the popup. The caller + /// must stop dispatching the press in that case. `false` when no + /// picker was open (press dispatch continues normally). + pub(in crate::widget_host) fn dismiss_export_picker_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.export_scale_picker_open + && !self.editor_state.editor_ui.export_format_picker_open + { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + if matches!( + action, + A::SetExportScale(_) + | A::SetExportFormat(_) + | A::ToggleExportScalePicker + | A::ToggleExportFormatPicker + ) { + self.apply_property_action(action); + return true; + } + } + } + let ui = &mut self.editor_state.editor_ui; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + ui.export_picker_hover = None; + self.mark_dirty(); + true + } + + pub(in crate::widget_host) fn dismiss_font_family_picker_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.font_family_picker_open { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + if matches!(action, A::SetFontFamily(_) | A::ToggleFontFamilyPicker) { + self.apply_property_action(action); + return true; + } + } + } + self.editor_state.editor_ui.font_family_picker_open = false; + self.mark_dirty(); + true + } + + pub(in crate::widget_host) fn dismiss_font_weight_picker_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.font_weight_picker_open { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + if matches!(action, A::SetFontWeight(_) | A::ToggleFontWeightPicker) { + self.apply_property_action(action); + return true; + } + } + } + self.editor_state.editor_ui.font_weight_picker_open = false; + self.editor_state.editor_ui.font_weight_picker_hover = None; + self.mark_dirty(); + true + } + + pub(in crate::widget_host) fn dismiss_padding_mode_popover_on_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.padding_mode_popover_open { + return false; + } + self.refresh_layout_scene(); + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + if matches!(action, A::SetPaddingMode(_) | A::TogglePaddingModePopover) { + self.apply_property_action(action); + return true; + } + } + } + self.editor_state.editor_ui.padding_mode_popover_open = false; + self.editor_state.editor_ui.padding_mode_popover_hover = None; + self.mark_dirty(); + true + } + + /// Track the padding-mode popover row under the cursor so the open + /// popover paints a hover wash. Returns true when the hovered row + /// changed (a repaint is due). No-op when the popover is closed. + pub(in crate::widget_host) fn update_padding_mode_popover_hover( + &mut self, + x: f32, + y: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.padding_mode_popover_open { + return false; + } + self.refresh_layout_scene(); + let new_hover = PropertyPanel::for_selection(&self.editor_state).and_then(|panel| { + let property_rect = Rect { + origin: Point2D::new( + self.last_viewport_w - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (self.last_viewport_h - TOP_BAR_HEIGHT).max(0.0), + ), + }; + match panel.hit_test_action(property_rect, Point2D::new(x, y)) { + Some(A::SetPaddingMode(mode)) => op_editor_core::PaddingEditMode::ALL + .iter() + .position(|m| *m == mode), + _ => None, + } + }); + if new_hover != self.editor_state.editor_ui.padding_mode_popover_hover { + self.editor_state.editor_ui.padding_mode_popover_hover = new_hover; + self.mark_dirty(); + return true; + } + false + } + + /// Track the font-weight dropdown row under the cursor so the open + /// dropdown paints a hover wash. Returns true when the hovered row + /// changed (a repaint is due). No-op when the dropdown is closed. + pub(in crate::widget_host) fn update_font_weight_picker_hover( + &mut self, + x: f32, + y: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction as A, TOP_BAR_HEIGHT}; + use op_editor_ui::{Point2D, Rect}; + if !self.editor_state.editor_ui.font_weight_picker_open { + return false; + } + self.refresh_layout_scene(); + let new_hover = PropertyPanel::for_selection(&self.editor_state).and_then(|panel| { + let property_rect = Rect { + origin: Point2D::new( + self.last_viewport_w - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (self.last_viewport_h - TOP_BAR_HEIGHT).max(0.0), + ), + }; + match panel.hit_test_action(property_rect, Point2D::new(x, y)) { + Some(A::SetFontWeight(choice)) => op_editor_ui::widgets::FontWeightChoice::ALL + .iter() + .position(|c| *c == choice), + _ => None, + } + }); + if new_hover != self.editor_state.editor_ui.font_weight_picker_hover { + self.editor_state.editor_ui.font_weight_picker_hover = new_hover; + self.mark_dirty(); + return true; + } + false + } +} diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index c1ee2450..0632fd07 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -319,6 +319,46 @@ impl WidgetHost { return true; } + // 0c0c. Font-weight dropdown + padding mode-selector popover — + // outside-click dismiss. A click on a picker row / toggle + // is applied; any other click closes the popover and is + // swallowed (mirrors the native host's dismiss handlers). + if self.editor_state.editor_ui.font_weight_picker_open + || self.editor_state.editor_ui.padding_mode_popover_open + { + use op_editor_ui::widgets::PropertyPanelAction as A; + if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) { + let property_rect = Rect { + origin: Point2D::new( + viewport_width - self.editor_state.editor_ui.property_panel_width, + TOP_BAR_HEIGHT, + ), + size: Point2D::new( + self.editor_state.editor_ui.property_panel_width, + (viewport_height - TOP_BAR_HEIGHT).max(0.0), + ), + }; + if let Some(action) = panel.hit_test_action(property_rect, Point2D::new(x, y)) { + if matches!( + action, + A::SetFontWeight(_) + | A::ToggleFontWeightPicker + | A::SetPaddingMode(_) + | A::TogglePaddingModePopover + ) { + self.apply_property_action(action); + return true; + } + } + } + self.editor_state.editor_ui.font_weight_picker_open = false; + self.editor_state.editor_ui.font_weight_picker_hover = None; + self.editor_state.editor_ui.padding_mode_popover_open = false; + self.editor_state.editor_ui.padding_mode_popover_hover = None; + self.mark_dirty(); + return true; + } + // 0c. PropertyPanel button / checkbox — flex modes + size // flags. Runs AFTER locale picker + TopBar so the // dropdown overlays still win. diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index f73b5833..3b441fc2 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -159,6 +159,49 @@ impl WidgetHost { // now. A future implementation would surface a // `` via the JS bridge. } + A::ToggleFontWeightPicker => { + let ui = &mut self.editor_state.editor_ui; + ui.font_weight_picker_open = !ui.font_weight_picker_open; + ui.font_weight_picker_hover = None; + ui.font_family_picker_open = false; + ui.fill_type_picker_open = false; + ui.image_fill_popover_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::SetFontWeight(choice) => { + let id = self.editor_state.selection.anchor.clone(); + if id.is_real() { + self.editor_state.commit_history(); + let _ = self.editor_state.commit_property_edit( + op_editor_core::PropertyFocus::FontWeight, + choice.value() as f32, + ); + } + self.editor_state.editor_ui.font_weight_picker_open = false; + self.editor_state.editor_ui.font_weight_picker_hover = None; + } + A::TogglePaddingModePopover => { + let ui = &mut self.editor_state.editor_ui; + ui.padding_mode_popover_open = !ui.padding_mode_popover_open; + ui.padding_mode_popover_hover = None; + ui.font_weight_picker_open = false; + ui.fill_type_picker_open = false; + ui.image_fill_popover_open = false; + ui.export_scale_picker_open = false; + ui.export_format_picker_open = false; + } + A::SetPaddingMode(mode) => { + // Scope the pin to the node it was set for (no leak into + // the next selection). + let anchor = self.editor_state.selection.anchor.as_str().to_string(); + self.editor_state.editor_ui.padding_edit_mode = Some(mode); + self.editor_state.editor_ui.padding_edit_mode_anchor = anchor; + self.editor_state.editor_ui.padding_mode_popover_open = false; + self.editor_state.editor_ui.padding_mode_popover_hover = None; + self.editor_state.commit_history(); + let _ = self.editor_state.set_selected_padding_mode_shape(mode); + } _ => {} } self.mark_dirty(); diff --git a/crates/op-i18n/src/i18n/en.rs b/crates/op-i18n/src/i18n/en.rs index 6d958f9f..9c08e5a7 100644 --- a/crates/op-i18n/src/i18n/en.rs +++ b/crates/op-i18n/src/i18n/en.rs @@ -173,11 +173,13 @@ pub fn lookup(key: &str) -> Option<&'static str> { "text.middle" => "Middle", "text.bottom" => "Bottom", "text.weight.thin" => "Thin", + "text.weight.extralight" => "Extra Light", "text.weight.light" => "Light", "text.weight.regular" => "Regular", "text.weight.medium" => "Medium", "text.weight.semibold" => "Semibold", "text.weight.bold" => "Bold", + "text.weight.extrabold" => "Extra Bold", "text.weight.black" => "Black", "text.font.search" => "Search fonts…", "text.font.bundled" => "Bundled", @@ -419,6 +421,7 @@ pub fn lookup(key: &str) -> Option<&'static str> { "acp.localDesktopOnly" => "Local agents are only available in the desktop app.", "figma.title" => "Import from Figma", "figma.dropFile" => "Drop a .fig file here", + "dialog.dropToOpen" => "Drop to open · .op / .pen / .fig", "figma.orBrowse" => "or click to browse", "figma.exportTip" => "Export from Figma: File → Save local copy (.fig)", "figma.selectFigFile" => "Please select a .fig file", diff --git a/crates/op-i18n/src/i18n/zh_cn.rs b/crates/op-i18n/src/i18n/zh_cn.rs index b8bb1f9a..e49d54a5 100644 --- a/crates/op-i18n/src/i18n/zh_cn.rs +++ b/crates/op-i18n/src/i18n/zh_cn.rs @@ -173,11 +173,13 @@ pub fn lookup(key: &str) -> Option<&'static str> { "text.middle" => "居中", "text.bottom" => "底部", "text.weight.thin" => "极细", + "text.weight.extralight" => "特细", "text.weight.light" => "细体", "text.weight.regular" => "常规", "text.weight.medium" => "中等", "text.weight.semibold" => "半粗", "text.weight.bold" => "粗体", + "text.weight.extrabold" => "特粗", "text.weight.black" => "极粗", "text.font.search" => "搜索字体…", "text.font.bundled" => "内置字体", @@ -425,6 +427,7 @@ pub fn lookup(key: &str) -> Option<&'static str> { "acp.localDesktopOnly" => "本地 Agent 仅在桌面应用中可用。", "figma.title" => "从 Figma 导入", "figma.dropFile" => "将 .fig 文件拖放到此处", + "dialog.dropToOpen" => "拖放以打开 · .op / .pen / .fig", "figma.orBrowse" => "或点击浏览", "figma.exportTip" => "从 Figma 导出:文件 → 保存本地副本 (.fig)", "figma.selectFigFile" => "请选择一个 .fig 文件", diff --git a/crates/op-i18n/src/i18n/zh_tw.rs b/crates/op-i18n/src/i18n/zh_tw.rs index 3c71293b..323be63e 100644 --- a/crates/op-i18n/src/i18n/zh_tw.rs +++ b/crates/op-i18n/src/i18n/zh_tw.rs @@ -173,11 +173,13 @@ pub fn lookup(key: &str) -> Option<&'static str> { "text.middle" => "居中", "text.bottom" => "底部", "text.weight.thin" => "極細", + "text.weight.extralight" => "特細", "text.weight.light" => "細體", "text.weight.regular" => "標準", "text.weight.medium" => "中等", "text.weight.semibold" => "半粗", "text.weight.bold" => "粗體", + "text.weight.extrabold" => "特粗", "text.weight.black" => "極粗", "text.font.search" => "搜尋字型…", "text.font.bundled" => "內建", @@ -425,6 +427,7 @@ pub fn lookup(key: &str) -> Option<&'static str> { "acp.localDesktopOnly" => "本機 Agent 僅在桌面應用中可用。", "figma.title" => "從 Figma 匯入", "figma.dropFile" => "將 .fig 檔案拖放至此處", + "dialog.dropToOpen" => "拖放以開啟 · .op / .pen / .fig", "figma.orBrowse" => "或點擊瀏覽", "figma.exportTip" => "從 Figma 匯出:檔案 → 儲存本機副本 (.fig)", "figma.selectFigFile" => "請選擇一個 .fig 檔案", diff --git a/vendor/casement b/vendor/casement index 5fbfb109..451173eb 160000 --- a/vendor/casement +++ b/vendor/casement @@ -1 +1 @@ -Subproject commit 5fbfb109d00d0ee9ad550621eba2188e56229e96 +Subproject commit 451173eb3353a4166d2d0f241f2e0606051064bd