mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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.
This commit is contained in:
parent
8e2f5ac3dd
commit
130a9b14a3
60 changed files with 4111 additions and 900 deletions
|
|
@ -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<NodeId> = 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
|
||||
|
|
|
|||
|
|
@ -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)]);
|
||||
|
|
|
|||
|
|
@ -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<u8>,
|
||||
/// 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 `⎇ <branch> ▾` button) is open.
|
||||
pub branch_picker_open: bool,
|
||||
/// Current branch name of that repository.
|
||||
pub branch: Option<String>,
|
||||
/// 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<GitPanelAction>,
|
||||
|
|
@ -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<PaddingEditMode>,
|
||||
/// 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<usize>,
|
||||
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<usize>,
|
||||
/// Active-theme axis whose value picker is open; `None` = closed.
|
||||
pub axis_dropdown_open: Option<String>,
|
||||
/// 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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<f64>;
|
||||
|
|
@ -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<f64> {
|
||||
match self {
|
||||
PenNode::Frame(n) => sizing_px(&n.container.width),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
) -> 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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
81
crates/op-editor-ui/src/widgets/file_drop_overlay.rs
Normal file
81
crates/op-editor-ui/src/widgets/file_drop_overlay.rs
Normal file
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
@ -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 `⎇ <branch> ▾` 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<GitPanel<'a>> {
|
||||
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<GitPanel<'a>> {
|
||||
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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<GitPanelHit> {
|
||||
// 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.
|
||||
|
|
|
|||
478
crates/op-editor-ui/src/widgets/git_panel_menus.rs
Normal file
478
crates/op-editor-ui/src/widgets/git_panel_menus.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
//! Ready-state header popovers for [`GitPanel`] — the branch-picker
|
||||
//! dropdown (`⎇ <branch> ▾`) 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<Rect> {
|
||||
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<GitPanelHit> {
|
||||
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<Rect> {
|
||||
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<GitPanelHit> {
|
||||
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<GitPanelHit> {
|
||||
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<GitPanelHit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
357
crates/op-editor-ui/src/widgets/git_panel_ready.rs
Normal file
357
crates/op-editor-ui/src/widgets/git_panel_ready.rs
Normal file
|
|
@ -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 — `⎇ <branch> ▾`, 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 — `⎇ <branch> ▾`.
|
||||
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<Rect> {
|
||||
// +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<GitPanelHit> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
43
crates/op-editor-ui/src/widgets/git_panel_text.rs
Normal file
43
crates/op-editor-ui/src/widgets/git_panel_text.rs
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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::{
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
/// 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<usize>,
|
||||
/// 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) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -46,11 +46,21 @@ fn image_body_rect(panel_rect: Rect, visible: VisibleSections) -> Option<Rect> {
|
|||
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<Rect> {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
crates/op-editor-ui/src/widgets/property_panel_test_support.rs
Normal file
120
crates/op-editor-ui/src/widgets/property_panel_test_support.rs
Normal file
|
|
@ -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<String>,
|
||||
pub(super) round_rects: usize,
|
||||
pub(super) images: Vec<(Rect, u64, usize)>,
|
||||
pub(super) image_modes: Vec<ImageDrawMode>,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
|
@ -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<String>,
|
||||
round_rects: usize,
|
||||
images: Vec<(Rect, u64, usize)>,
|
||||
image_modes: Vec<ImageDrawMode>,
|
||||
}
|
||||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<usize>,
|
||||
) {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
288
crates/op-host-native/src/widget_host/property_popovers.rs
Normal file
288
crates/op-host-native/src/widget_host/property_popovers.rs
Normal file
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -159,6 +159,49 @@ impl WidgetHost {
|
|||
// now. A future implementation would surface a
|
||||
// `<input type="file">` 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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 文件",
|
||||
|
|
|
|||
|
|
@ -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 檔案",
|
||||
|
|
|
|||
2
vendor/casement
vendored
2
vendor/casement
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit 5fbfb109d00d0ee9ad550621eba2188e56229e96
|
||||
Subproject commit 451173eb3353a4166d2d0f241f2e0606051064bd
|
||||
Loading…
Reference in a new issue