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:
Kayshen-X 2026-05-31 01:02:47 +08:00
parent 8e2f5ac3dd
commit 130a9b14a3
60 changed files with 4111 additions and 900 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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),
);
}

View file

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

View file

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

View 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
}
}

View 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
}
}

View file

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

View 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))
}

View file

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

View file

@ -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",
];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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",
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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
}
}

View file

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

View file

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

View file

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

View 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 文件",

View 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 檔案",

2
vendor/casement vendored

@ -1 +1 @@
Subproject commit 5fbfb109d00d0ee9ad550621eba2188e56229e96
Subproject commit 451173eb3353a4166d2d0f241f2e0606051064bd