feat(panels): reorder multiple fill layers

This commit is contained in:
Kayshen-X 2026-07-12 09:10:49 +08:00
parent df5a9aa8b2
commit cb0842a5c9
16 changed files with 1264 additions and 777 deletions

View file

@ -0,0 +1,49 @@
//! Fill-layer reordering for canonical [`PenNode`] fill lists.
use crate::fills::{node_fills, node_fills_mut};
use crate::walkers::find_node_mut;
use crate::EditorState;
use jian_ops_schema::node::PenNode;
/// Move one fill from `from` to the final index `to`.
///
/// Invalid or identical indices return `false` without materializing an
/// absent fill list or otherwise changing the node.
pub fn move_fill(node: &mut PenNode, from: usize, to: usize) -> bool {
let Some(len) = node_fills(node).map(Vec::len) else {
return false;
};
if from == to || from >= len || to >= len {
return false;
}
let fills = node_fills_mut(node).expect("validated fill-bearing node");
let moved = fills.remove(from);
fills.insert(to, moved);
true
}
impl EditorState {
/// Reorder a selected node's fills as one undoable document edit.
///
/// The variable side table binds a node's primary fill rather than a
/// particular fill layer. Crossing index 0 therefore clears that cache;
/// the authored `$token`, when present, remains on the fill that moved.
pub fn move_selected_fill(&mut self, from: usize, to: usize) -> bool {
let selected = self.selection.anchor.clone();
if !selected.is_real() || !self.is_editable(&selected) {
return false;
}
let snapshot = self.snapshot_for_history();
let Some(node) = find_node_mut(self.active_children_mut(), &selected) else {
return false;
};
if !move_fill(node, from, to) {
return false;
}
if from == 0 || to == 0 {
self.ui.variables.fill_refs.remove(&selected);
}
self.history_push_past(snapshot);
true
}
}

View file

@ -1,8 +1,150 @@
#![cfg(test)]
use crate::fills::node_stroke_width;
use crate::node_id::NodeId;
use crate::walkers::find_node;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::{PenStroke, SidedThickness, StrokeThickness};
use jian_ops_schema::style::{PenFill, PenStroke, SidedThickness, StrokeThickness};
fn three_fill_state() -> crate::EditorState {
let src = r##"{
"version":"0.8.0",
"children":[{
"type":"rectangle","id":"n1","name":"R",
"x":0,"y":0,"width":10,"height":10,
"fill":[
{"type":"solid","color":"#111111"},
{"type":"solid","color":"#222222"},
{"type":"solid","color":"#333333"}
]
}]
}"##;
let doc = jian_ops_schema::load_str(src)
.expect("three-fill fixture parses")
.value;
let mut state = crate::EditorState::from_document(doc);
state.set_single_selection(NodeId::new("n1"));
state
}
fn selected_fill_colors(state: &crate::EditorState) -> Vec<String> {
let node = find_node(state.active_children(), &NodeId::new("n1")).expect("n1 exists");
crate::fills::node_fills(node)
.expect("rectangle has fills")
.iter()
.map(|fill| match fill {
PenFill::Solid(body) => body.color.clone(),
other => panic!("fixture fill must stay solid, got {other:?}"),
})
.collect()
}
#[test]
fn move_fill_primitive_moves_two_to_zero() {
let mut state = three_fill_state();
let node = crate::walkers::find_node_mut(state.active_children_mut(), &NodeId::new("n1"))
.expect("n1 exists");
assert!(crate::move_fill(node, 2, 0));
assert_eq!(
selected_fill_colors(&state),
["#333333", "#111111", "#222222"]
);
}
#[test]
fn move_fill_primitive_rejects_invalid_and_same_indices_without_mutating() {
for (from, to) in [(0, 0), (3, 0), (0, 3)] {
let mut state = three_fill_state();
let before = serde_json::to_value(&state.doc).expect("document serializes");
let node = crate::walkers::find_node_mut(state.active_children_mut(), &NodeId::new("n1"))
.expect("n1 exists");
assert!(!crate::move_fill(node, from, to), "case {from}->{to}");
assert_eq!(
serde_json::to_value(&state.doc).expect("document serializes"),
before,
"case {from}->{to} must preserve the full document"
);
}
}
#[test]
fn move_selected_fill_is_one_undoable_history_entry() {
let mut state = three_fill_state();
assert!(state.move_selected_fill(2, 0));
assert_eq!(state.history.past.len(), 1);
assert_eq!(
selected_fill_colors(&state),
["#333333", "#111111", "#222222"]
);
assert!(state.undo());
assert_eq!(
selected_fill_colors(&state),
["#111111", "#222222", "#333333"]
);
}
#[test]
fn move_selected_fill_invalid_and_same_indices_do_not_push_history() {
for (from, to) in [(0, 0), (3, 0), (0, 3)] {
let mut state = three_fill_state();
let before = serde_json::to_value(&state.doc).expect("document serializes");
assert!(!state.move_selected_fill(from, to), "case {from}->{to}");
assert_eq!(state.history.past.len(), 0, "case {from}->{to}");
assert_eq!(
serde_json::to_value(&state.doc).expect("document serializes"),
before,
"case {from}->{to} must preserve the full document"
);
}
}
#[test]
fn moving_across_primary_keeps_ref_with_its_fill_and_clears_primary_cache() {
let mut state = three_fill_state();
assert!(state.set_selected_fill_hex_at(0, "$brand"));
state
.ui
.variables
.fill_refs
.insert(NodeId::new("n1"), "brand".to_string());
assert!(state.move_selected_fill(2, 0));
assert_eq!(
selected_fill_colors(&state),
["#333333", "$brand", "#222222"],
"the authored variable token must travel with its original fill"
);
assert!(
!state
.ui
.variables
.fill_refs
.contains_key(&NodeId::new("n1")),
"the node-level primary cache must not bind the new fill at index 0"
);
assert!(state.undo());
assert_eq!(
selected_fill_colors(&state),
["$brand", "#222222", "#333333"]
);
assert_eq!(
state
.ui
.variables
.fill_refs
.get(&NodeId::new("n1"))
.map(String::as_str),
Some("brand"),
"undo must restore the primary-fill cache with the authored token"
);
}
/// Removing a fill must also drop the node's `fill_refs` variable binding.
/// Otherwise the scene resolver's `fill_for` (a registered fill ref wins

View file

@ -15,8 +15,10 @@
//! types-only (Task 4.5 ports the mutator `impl`s).
use crate::history_snapshot::{SharedComponents, SharedDoc};
use crate::node_id::NodeId;
use crate::selection::SelectionState;
use std::collections::VecDeque;
use crate::state::EditorState;
use std::collections::{HashMap, VecDeque};
/// Largest number of undo entries kept. Past this the oldest entry is
/// dropped (`VecDeque::pop_front`) — matches shell-core's cap.
@ -52,6 +54,14 @@ pub struct EditorSnapshot {
/// no longer carries — later merges would be silently skipped or
/// mis-resolved against a stale owner.
pub app_state_owner: std::collections::BTreeMap<String, usize>,
/// Primary fill-variable cache at snapshot time. The cache is
/// document-derived but must round-trip with undo when an edit changes
/// which authored fill occupies index 0.
pub fill_refs: HashMap<NodeId, String>,
/// Stroke-variable companion to [`Self::fill_refs`]. Keeping both caches
/// together prevents a history restore from producing asymmetric token
/// resolution state.
pub stroke_refs: HashMap<NodeId, String>,
/// Document revision at snapshot time. Restoring it lets undo back
/// to a saved snapshot clear the dirty marker naturally.
pub revision: u64,
@ -82,6 +92,98 @@ impl History {
}
}
impl EditorState {
/// Snapshot the editor's undoable state without pushing it.
///
/// The snapshot covers the document, selection, active page, component
/// registry, and transient variable-reference caches. View-only UI state
/// such as collapsed layers is intentionally excluded.
pub fn snapshot_for_history(&self) -> EditorSnapshot {
// After an undo, the redo back is the state we came from and therefore
// the closest sharing anchor. Normal forward editing falls through to
// the previous undo entry.
let anchor = self
.history
.future
.back()
.or_else(|| self.history.past.back());
self.snapshot_for_history_with_anchor(anchor)
}
/// [`snapshot_for_history`](Self::snapshot_for_history) with an explicit
/// adjacent snapshot whose unchanged top-level subtrees may be shared.
pub fn snapshot_for_history_with_anchor(
&self,
anchor: Option<&EditorSnapshot>,
) -> EditorSnapshot {
EditorSnapshot {
doc: SharedDoc::capture(&self.doc, anchor.map(|snapshot| &snapshot.doc)),
selection: self.selection.clone(),
active_page_index: self.ui.active_page_index,
components: SharedComponents::capture(
&self.components,
anchor.map(|snapshot| &snapshot.components),
),
app_state_owner: self.app_state_owner.clone(),
fill_refs: self.ui.variables.fill_refs.clone(),
stroke_refs: self.ui.variables.stroke_refs.clone(),
revision: self.revision,
}
}
/// Push a snapshot onto the undo stack, clear redo, and enforce the cap.
pub fn history_push_past(&mut self, snapshot: EditorSnapshot) {
self.history_push_count = self.history_push_count.saturating_add(1);
self.history.past.push_back(snapshot);
if self.history.past.len() > HISTORY_CAP {
self.history.past.pop_front();
}
self.history.future.clear();
self.mark_document_changed();
}
/// Push the current state before a transactional edit.
pub fn commit_history(&mut self) {
let snapshot = self.snapshot_for_history();
self.history_push_past(snapshot);
}
/// Materialize and restore all undoable editor state from a snapshot.
pub(crate) fn restore(&mut self, snapshot: EditorSnapshot) {
self.doc = snapshot.doc.materialize();
self.selection = snapshot.selection;
self.ui.active_page_index = snapshot.active_page_index;
self.components = snapshot.components.materialize();
self.app_state_owner = snapshot.app_state_owner;
self.ui.variables.fill_refs = snapshot.fill_refs;
self.ui.variables.stroke_refs = snapshot.stroke_refs;
self.revision = snapshot.revision;
self.sync_dirty_flag();
}
/// Undo the last change. Returns false when the undo stack is empty.
pub fn undo(&mut self) -> bool {
let Some(previous) = self.history.past.pop_back() else {
return false;
};
let current = self.snapshot_for_history_with_anchor(Some(&previous));
self.history.future.push_back(current);
self.restore(previous);
true
}
/// Redo the last undone change. Returns false when redo is empty.
pub fn redo(&mut self) -> bool {
let Some(next) = self.history.future.pop_back() else {
return false;
};
let current = self.snapshot_for_history_with_anchor(Some(&next));
self.history.past.push_back(current);
self.restore(next);
true
}
}
#[cfg(test)]
mod tests {
use super::*;

View file

@ -47,6 +47,7 @@ pub mod drag_mutators;
pub mod editor_ui_state;
pub mod export_dialog_state;
pub mod figma_import_state;
pub mod fill_order;
pub mod fills;
pub mod geometry;
pub mod git_button_state;
@ -219,6 +220,7 @@ pub use editor_ui_state::{
};
pub use export_dialog_state::ExportDialogButton;
pub use figma_import_state::FigmaImportButton;
pub use fill_order::move_fill;
pub use fills::{
first_fill_type, first_image_fill_summary, first_solid_fill_hex, first_solid_fill_opacity,
first_solid_stroke_hex, node_effects, ImageFillSummary,

View file

@ -13,7 +13,6 @@
//! mutators stay page-model-agnostic.
use crate::geometry::{union_aggregate_bounds, DocRect};
use crate::history::EditorSnapshot;
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::selection::SelectionState;
@ -22,9 +21,6 @@ use crate::walkers::{self, find_node, find_node_mut, reorder_in_children, Reorde
use jian_ops_schema::node::PenNode;
use std::collections::HashSet;
/// Largest number of undo entries kept (matches shell-core's cap).
const HISTORY_CAP: usize = 100;
impl EditorState {
// --- Active-page node access -------------------------------------
@ -258,116 +254,6 @@ impl EditorState {
true
}
// --- History -----------------------------------------------------
/// Snapshot the editor's undoable state without pushing it.
///
/// The snapshot covers `doc` / `selection` / `active_page_index`
/// only. View-only UI state — notably `editor_ui.collapsed_layers`
/// — is intentionally NOT captured: layer-collapse is a view-only
/// toggle, deliberately excluded from the undo snapshot and from
/// file persistence. Expanding / collapsing a layer is not an
/// undoable edit.
pub fn snapshot_for_history(&self) -> EditorSnapshot {
// Default anchor: the adjacent history state. After an undo the
// redo stack is non-empty and its back is the state we came
// FROM (the closest neighbour of the current live state) — so a
// divergent edit captured here shares subtrees with it, and the
// anchor is read BEFORE `history_push_past` clears the redo
// stack. In normal forward editing the redo stack is empty, so
// the anchor falls through to the previous undo entry.
let anchor = self
.history
.future
.back()
.or_else(|| self.history.past.back());
self.snapshot_for_history_with_anchor(anchor)
}
/// [`snapshot_for_history`](Self::snapshot_for_history) with an
/// explicit anchor. `anchor` is the adjacent history snapshot whose
/// unchanged top-level subtrees the new snapshot should share by
/// `Arc`; `None` produces an all-fresh snapshot. Undo / redo pass
/// the popped destination here so the parked entry shares with
/// where the editor is going.
pub fn snapshot_for_history_with_anchor(
&self,
anchor: Option<&EditorSnapshot>,
) -> EditorSnapshot {
EditorSnapshot {
doc: crate::history_snapshot::SharedDoc::capture(&self.doc, anchor.map(|s| &s.doc)),
selection: self.selection.clone(),
active_page_index: self.ui.active_page_index,
components: crate::history_snapshot::SharedComponents::capture(
&self.components,
anchor.map(|s| &s.components),
),
app_state_owner: self.app_state_owner.clone(),
revision: self.revision,
}
}
/// Push a snapshot onto the undo stack + clear redo. Cap = 100.
pub fn history_push_past(&mut self, snap: EditorSnapshot) {
self.history_push_count = self.history_push_count.saturating_add(1);
self.history.past.push_back(snap);
if self.history.past.len() > HISTORY_CAP {
self.history.past.pop_front();
}
self.history.future.clear();
self.mark_document_changed();
}
/// Push the current state onto the undo stack. Call BEFORE a
/// transactional change so undo reverts to here.
pub fn commit_history(&mut self) {
let snap = self.snapshot_for_history();
self.history_push_past(snap);
}
/// Restore the editor state from a snapshot. Materializes the
/// shared document + components back into owned values. `pub(crate)`
/// so batch rollback ([`crate::command_batch`]) routes through the
/// same materializing path instead of moving shared state into live
/// fields.
pub(crate) fn restore(&mut self, snap: EditorSnapshot) {
self.doc = snap.doc.materialize();
self.selection = snap.selection;
self.ui.active_page_index = snap.active_page_index;
self.components = snap.components.materialize();
self.app_state_owner = snap.app_state_owner;
self.revision = snap.revision;
self.sync_dirty_flag();
}
/// Undo the last change. False when the undo stack is empty.
pub fn undo(&mut self) -> bool {
let Some(prev) = self.history.past.pop_back() else {
return false;
};
// Capture the current (live) state as the redo entry, anchored
// on the POPPED destination — before it is materialized — so the
// parked snapshot shares every unchanged subtree with where the
// editor is about to land.
let cur = self.snapshot_for_history_with_anchor(Some(&prev));
self.history.future.push_back(cur);
self.restore(prev);
true
}
/// Redo the last undone change. False when the redo stack is empty.
pub fn redo(&mut self) -> bool {
let Some(next) = self.history.future.pop_back() else {
return false;
};
// Symmetric to undo: anchor the parked undo entry on the popped
// redo destination.
let cur = self.snapshot_for_history_with_anchor(Some(&next));
self.history.past.push_back(cur);
self.restore(next);
true
}
// --- Node flag toggles -------------------------------------------
/// Toggle the `visible` flag on the node. True on success.

View file

@ -476,6 +476,8 @@ mod tests {
active_page_index: 0,
components: crate::history_snapshot::SharedComponents::default(),
app_state_owner: std::collections::BTreeMap::new(),
fill_refs: std::collections::HashMap::new(),
stroke_refs: std::collections::HashMap::new(),
revision: s.revision,
});
s.ui.pen_in_progress = Some(crate::NodeId::new("n7"));

View file

@ -194,6 +194,11 @@ pub enum PropertyPanelAction {
/// User clicked fill `index`'s row remove button — removes that
/// fill.
RemoveFill(usize),
/// User moved one fill layer to an adjacent final index.
MoveFill {
from: usize,
to: usize,
},
/// User clicked the gradient-stops header "+".
AddGradientStop,
/// User clicked a gradient stop row remove button.

View file

@ -31,6 +31,63 @@ pub use jian_widgets::components::select::SelectHit;
use jian_widgets::components::select::{Select, SelectItem, SelectState};
use op_editor_core::PropertyFocus;
const FILL_SWATCH_SIZE: f32 = 22.0;
const FILL_HEAD_GAP: f32 = 6.0;
const FILL_OPACITY_WIDTH: f32 = 50.0;
const FILL_MOVE_WIDTH: f32 = 20.0;
const FILL_REMOVE_WIDTH: f32 = 22.0;
/// Shared head-row geometry used by paint and every input/action walker.
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct FillHeadRects {
pub(crate) swatch: Rect,
pub(crate) dropdown: Rect,
pub(crate) opacity: Rect,
pub(crate) move_up: Rect,
pub(crate) move_down: Rect,
pub(crate) remove: Rect,
}
pub(crate) fn fill_head_rects(x: f32, y: f32, width: f32) -> FillHeadRects {
let right = x + width - PAD_X;
let remove = Rect {
origin: Point2D::new(right - FILL_REMOVE_WIDTH, y),
size: Point2D::new(FILL_REMOVE_WIDTH, INPUT_HEIGHT),
};
let move_down = Rect {
origin: Point2D::new(remove.origin.x - FILL_MOVE_WIDTH, y),
size: Point2D::new(FILL_MOVE_WIDTH, INPUT_HEIGHT),
};
let move_up = Rect {
origin: Point2D::new(move_down.origin.x - FILL_MOVE_WIDTH, y),
size: Point2D::new(FILL_MOVE_WIDTH, INPUT_HEIGHT),
};
let opacity = Rect {
origin: Point2D::new(move_up.origin.x - FILL_HEAD_GAP - FILL_OPACITY_WIDTH, y),
size: Point2D::new(FILL_OPACITY_WIDTH, INPUT_HEIGHT),
};
let swatch = Rect {
origin: Point2D::new(x + PAD_X, y + 2.0),
size: Point2D::new(FILL_SWATCH_SIZE, FILL_SWATCH_SIZE),
};
let dropdown_x = swatch.origin.x + swatch.size.x + FILL_HEAD_GAP;
let dropdown = Rect {
origin: Point2D::new(dropdown_x, y),
size: Point2D::new(
(opacity.origin.x - FILL_HEAD_GAP - dropdown_x).max(0.0),
INPUT_HEIGHT,
),
};
FillHeadRects {
swatch,
dropdown,
opacity,
move_up,
move_down,
remove,
}
}
/// Display label for a fill-type variant (Solid / Gradient /
/// Image), localised against `locale` via the `fill.*` keys.
pub fn fill_type_label(
@ -219,12 +276,9 @@ fn paint_one_fill(
) -> f32 {
let mut y = y;
let fill_type = fill.fill_type;
let usable_w = width - PAD_X * 2.0;
let fill_color = fill.color;
let swatch_rect = Rect {
origin: Point2D::new(x + PAD_X, y + 2.0),
size: Point2D::new(22.0, 22.0),
};
let head = fill_head_rects(x, y, width);
let swatch_rect = head.swatch;
// Swatch icon depends on the fill type so the head row reads
// as a small preview of what's rendered below.
use op_editor_core::FillType;
@ -317,10 +371,7 @@ fn paint_one_fill(
);
}
}
let dropdown_rect = Rect {
origin: Point2D::new(swatch_rect.origin.x + swatch_rect.size.x + 6.0, y),
size: Point2D::new(usable_w - 22.0 - 6.0 - 50.0 - 22.0 - 12.0, INPUT_HEIGHT),
};
let dropdown_rect = head.dropdown;
jian_widgets::components::select_trigger::SelectTrigger {
icon_paths: None,
label: fill_type_label(locale, fill_type),
@ -337,10 +388,7 @@ fn paint_one_fill(
&crate::widgets::button::tokens_from_theme(theme),
);
let opacity_focus = PropertyFocus::FillOpacity(fill_index);
let pct_rect = Rect {
origin: Point2D::new(dropdown_rect.origin.x + dropdown_rect.size.x + 6.0, y),
size: Point2D::new(50.0, INPUT_HEIGHT),
};
let pct_rect = head.opacity;
cx.backend
.fill_round_rect(pct_rect, INPUT_RADIUS, theme.muted);
let opacity_focused = edit.focus == Some(opacity_focus);
@ -412,16 +460,39 @@ fn paint_one_fill(
pct_rect.origin.y + 19.0,
),
);
let move_icon_size = 12.0;
if fill_index > 0 {
draw_icon(
cx.backend,
Icon::ArrowUp,
Point2D::new(
head.move_up.origin.x + (head.move_up.size.x - move_icon_size) / 2.0,
head.move_up.origin.y + (head.move_up.size.y - move_icon_size) / 2.0,
),
move_icon_size,
theme.muted_foreground,
1.4,
);
}
if fill_index + 1 < snapshot.fills.len() {
draw_icon(
cx.backend,
Icon::ArrowDown,
Point2D::new(
head.move_down.origin.x + (head.move_down.size.x - move_icon_size) / 2.0,
head.move_down.origin.y + (head.move_down.size.y - move_icon_size) / 2.0,
),
move_icon_size,
theme.muted_foreground,
1.4,
);
}
draw_icon(
cx.backend,
Icon::Close,
// Centre the 14px glyph inside the RemoveFill hover-wash cell
// (origin x + width - PAD_X - 22, size 28×30 — see the action
// walker), instead of chaining off the opacity input, so the
// icon sits centred in the gray wash on hover.
Point2D::new(
x + width - PAD_X - 22.0 + (28.0 - 14.0) / 2.0,
y + (INPUT_HEIGHT - 14.0) / 2.0,
head.remove.origin.x + (head.remove.size.x - 14.0) / 2.0,
head.remove.origin.y + (head.remove.size.y - 14.0) / 2.0,
),
14.0,
theme.muted_foreground,

View file

@ -30,6 +30,22 @@ fn selected_rect_state() -> op_editor_core::EditorState {
state
}
fn selected_three_fill_state() -> op_editor_core::EditorState {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"rect","name":"Rect",
"x":40,"y":40,"width":160,"height":100,
"fill":[
{"type":"solid","color":"#112233"},
{"type":"solid","color":"#445566"},
{"type":"solid","color":"#778899"}
]}
]}"##,
);
state.set_single_selection(NodeId::new("rect"));
state
}
fn panel_for(state: &op_editor_core::EditorState) -> PropertyPanel {
PropertyPanel::for_selection(state).expect("rectangle panel")
}
@ -157,6 +173,63 @@ fn stacked_fills_emit_add_and_per_index_remove_actions() {
assert!(actions.contains(&PropertyPanelAction::RemoveFill(1)));
}
#[test]
fn three_fill_rows_emit_exact_boundary_move_actions_in_row_order() {
let state = selected_three_fill_state();
let panel = panel_for(&state);
let moves: Vec<_> = sections::action_button_rects(
panel_rect(),
visible_for(&panel),
&panel.snapshot.effects,
&panel.snapshot.fills,
)
.into_iter()
.filter_map(|(action, _)| {
matches!(action, PropertyPanelAction::MoveFill { .. }).then_some(action)
})
.collect();
assert_eq!(
moves,
vec![
PropertyPanelAction::MoveFill { from: 0, to: 1 },
PropertyPanelAction::MoveFill { from: 1, to: 0 },
PropertyPanelAction::MoveFill { from: 1, to: 2 },
PropertyPanelAction::MoveFill { from: 2, to: 1 },
],
"first has down only, middle has up then down, last has up only"
);
}
#[test]
fn fill_move_action_rect_centres_hit_the_exact_emitted_actions() {
let state = selected_three_fill_state();
let panel = panel_for(&state);
let move_rects: Vec<_> = sections::action_button_rects(
panel_rect(),
visible_for(&panel),
&panel.snapshot.effects,
&panel.snapshot.fills,
)
.into_iter()
.filter(|(action, _)| matches!(action, PropertyPanelAction::MoveFill { .. }))
.collect();
assert_eq!(move_rects.len(), 4);
for (expected, rect) in move_rects {
let centre = Point2D::new(
rect.origin.x + rect.size.x / 2.0,
rect.origin.y + rect.size.y / 2.0,
);
assert_eq!(
panel.hit_test_action(panel_rect(), centre),
Some(expected),
"the painted move control's action rect must be its hit region"
);
}
}
#[test]
fn remove_selected_fill_removes_requested_index() {
let mut state = selected_rect_state();

View file

@ -4,7 +4,7 @@
//! and input-rect walker stay under the repository file-size cap.
use crate::widgets::property_panel::{FillSummary, PropertyPanelAction};
use crate::widgets::property_panel_fill::fill_row_body_height;
use crate::widgets::property_panel_fill::{fill_head_rects, fill_row_body_height};
use crate::widgets::property_panel_fill_picker::{
fill_type_at, fill_type_picker_rect, FILL_TYPE_COUNT, FILL_TYPE_ROW_HEIGHT,
};
@ -223,10 +223,7 @@ pub fn editable_input_rects(
// Head row: per-fill opacity input (the `%` box).
rects.push((
PropertyFocus::FillOpacity(fi),
Rect {
origin: Point2D::new(x0 + w - PAD_X - 78.0, y),
size: Point2D::new(50.0, INPUT_HEIGHT),
},
fill_head_rects(x0, y, w).opacity,
));
y += INPUT_HEIGHT + 6.0;
match fill_type {
@ -361,20 +358,29 @@ pub(crate) fn push_fill_action_rects(
for (fi, fill) in fills.iter().enumerate() {
let is_primary = fi == 0;
let fill_type = fill.fill_type;
// Head row: type dropdown + opacity input + ×.
let dropdown_rect = Rect {
origin: Point2D::new(x0 + PAD_X + 22.0 + 6.0, y),
size: Point2D::new(usable_w - 22.0 - 6.0 - 50.0 - 22.0 - 12.0, INPUT_HEIGHT),
};
// Head row: type dropdown + opacity + adjacent reorder / remove controls.
let head = fill_head_rects(x0, y, w);
let dropdown_rect = head.dropdown;
out.push((PropertyPanelAction::ToggleFillTypePicker(fi), dropdown_rect));
// The × sits to the right of the opacity box on the head row.
out.push((
PropertyPanelAction::RemoveFill(fi),
Rect {
origin: Point2D::new(x0 + w - PAD_X - 22.0, y),
size: Point2D::new(28.0, INPUT_HEIGHT),
},
));
if fi > 0 {
out.push((
PropertyPanelAction::MoveFill {
from: fi,
to: fi - 1,
},
head.move_up,
));
}
if fi + 1 < fills.len() {
out.push((
PropertyPanelAction::MoveFill {
from: fi,
to: fi + 1,
},
head.move_down,
));
}
out.push((PropertyPanelAction::RemoveFill(fi), head.remove));
// This fill's open type-picker overlay rows.
if fill_picker_open && fill_type_picker_index == fi {
let picker_rect = fill_type_picker_rect(dropdown_rect);

View file

@ -7,6 +7,7 @@
use super::WidgetHostNative;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::PenFill;
use op_editor_core::{NodeId, PenNodeExt};
const COMPONENT_DOC: &str = r##"{
@ -34,6 +35,90 @@ fn seeded_host() -> WidgetHostNative {
host
}
#[test]
fn native_move_fill_action_dispatches_as_one_undoable_edit() {
let mut host = WidgetHostNative::new();
let doc = jian_ops_schema::load_str(
r##"{"version":"0.8.0","children":[{
"type":"rectangle","id":"rect","name":"Rect",
"x":0,"y":0,"width":10,"height":10,
"fill":[
{"type":"solid","color":"#111111"},
{"type":"solid","color":"#222222"},
{"type":"solid","color":"#333333"}
]
}]}"##,
)
.expect("fixture parses")
.value;
*host.editor_state_mut() = op_editor_core::EditorState::from_document(doc);
host.editor_state_mut()
.set_single_selection(NodeId::new("rect"));
host.apply_property_action(op_editor_ui::widgets::PropertyPanelAction::MoveFill {
from: 2,
to: 0,
});
let node = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("rect"),
)
.expect("rect exists");
let colors: Vec<_> = op_editor_core::fills::node_fills(node)
.expect("fills exist")
.iter()
.map(|fill| match fill {
PenFill::Solid(body) => body.color.as_str(),
other => panic!("expected solid, got {other:?}"),
})
.collect();
assert_eq!(colors, ["#333333", "#111111", "#222222"]);
assert_eq!(host.editor_state().history.past.len(), 1);
}
#[test]
fn native_instance_move_fill_undo_restores_the_original_ref() {
let mut host = WidgetHostNative::new();
let doc = jian_ops_schema::load_str(
r##"{"version":"0.8.0","children":[
{"type":"rectangle","id":"master","name":"Master","reusable":true,
"x":0,"y":0,"width":10,"height":10,
"fill":[
{"type":"solid","color":"#111111"},
{"type":"solid","color":"#222222"},
{"type":"solid","color":"#333333"}
]},
{"type":"ref","id":"inst","ref":"master","x":20,"y":0}
]}"##,
)
.expect("fixture parses")
.value;
*host.editor_state_mut() = op_editor_core::EditorState::from_document(doc);
host.editor_state_mut()
.set_single_selection(NodeId::new("inst"));
host.apply_property_action(op_editor_ui::widgets::PropertyPanelAction::MoveFill {
from: 2,
to: 0,
});
assert_eq!(host.editor_state().history.past.len(), 1);
assert!(host.editor_state_mut().undo());
let node = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("inst"),
)
.expect("instance exists after undo");
let PenNode::Ref(reference) = node else {
panic!("undo must restore a Ref, got {node:?}");
};
assert!(
reference.descendants.is_none(),
"undo must remove the fill-order override"
);
}
fn ref_node(host: &WidgetHostNative) -> &jian_ops_schema::node::RefNode {
match op_editor_core::walkers::find_node(
host.editor_state().active_children(),

View file

@ -5,11 +5,12 @@
//! layout-resolved `LayoutScene` (canvas); results feed `EditorState`
//! mutators (the host's source of truth).
use super::helpers::parse_hex_color;
#[path = "property_input_dispatch.rs"]
mod property_input_dispatch;
use super::WidgetHostNative;
use jian_ops_schema::sizing::SizingKeyword;
use jian_ops_schema::variable::VariableKind;
use op_editor_core::PropertyFocus;
impl WidgetHostNative {
pub(in crate::widget_host) fn apply_property_action(
@ -119,6 +120,9 @@ impl WidgetHostNative {
A::AddFill => {
let _ = self.editor_state.add_selected_fill();
}
A::MoveFill { from, to } => {
let _ = self.editor_state.move_selected_fill(from, to);
}
A::RemoveFill(index) => {
let _ = self.editor_state.remove_selected_fill(index);
self.editor_state.editor_ui.close_fill_type_picker();
@ -560,304 +564,6 @@ impl WidgetHostNative {
}
self.mark_dirty();
}
/// Export-dialog press dispatcher.
pub(in crate::widget_host) fn dispatch_export_dialog_press(
&mut self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::export_dialog::{
scale_from_index, ExportDialog, ExportDialogHit,
};
let dlg = ExportDialog::centered(viewport_w, viewport_h);
let point = op_editor_ui::Point2D::new(x, y);
let hit = dlg.hit_test(point);
self.editor_state.editor_ui.pressed_button = hit
.map(op_editor_ui::widgets::editor_state_ext::export_dialog_button)
.map(op_editor_core::ButtonPressTarget::ExportDialog);
match hit {
Some(ExportDialogHit::Format(f)) => {
self.editor_state.editor_ui.export_format =
op_editor_ui::widgets::editor_state_ext::export_format(f);
}
Some(ExportDialogHit::Scale(i)) => {
self.editor_state.editor_ui.export_scale = scale_from_index(i);
}
Some(ExportDialogHit::Cancel) => {
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
}
Some(ExportDialogHit::Export) => {
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
self.editor_state.editor_ui.pending_file_action =
Some(FileAction::ExportImageConfirm);
}
None => {
// No control hit — blank press (inside chrome or
// outside the dialog): blur the chrome text inputs.
self.blur_text_inputs_on_blank_press();
if !dlg.contains(point) {
// Outside click — dismiss like Cancel.
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
}
}
}
self.mark_dirty();
}
/// Figma-import-modal press dispatcher.
pub(in crate::widget_host) fn dispatch_figma_import_press(
&mut self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::figma_import::{FigmaImportHit, FigmaImportModal};
let modal = FigmaImportModal::for_editor(&self.editor_state);
let panel_rect = modal.rect(viewport_w, viewport_h);
let hit = modal.hit_test(panel_rect, op_editor_ui::Point2D::new(x, y));
self.editor_state.editor_ui.pressed_button =
op_editor_ui::widgets::editor_state_ext::figma_import_button(hit)
.map(op_editor_core::ButtonPressTarget::FigmaImport);
match hit {
FigmaImportHit::Close => {
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::Outside => {
// Outside click — blank press: dismiss + blur inputs.
self.blur_text_inputs_on_blank_press();
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::DropZone => {
self.editor_state.editor_ui.pending_file_action = Some(FileAction::ImportFigma);
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::Inside => {
// Blank press on modal chrome — blur chrome inputs.
self.blur_text_inputs_on_blank_press();
}
}
self.mark_dirty();
}
/// File-menu press dispatcher.
pub(in crate::widget_host) fn dispatch_file_menu_press(
&mut self,
x: f32,
y: f32,
viewport_width: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice, MenuHit};
use op_editor_ui::widgets::top_bar::TopBar;
self.refresh_layout_scene();
let top_bar_rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, 0.0),
size: op_editor_ui::Point2D::new(viewport_width, op_editor_ui::widgets::TOP_BAR_HEIGHT),
};
let anchor =
TopBar::file_menu_rect(top_bar_rect, self.editor_state.editor_ui.window_fullscreen);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, now_secs);
let menu_rect = menu.rect_at(anchor);
let point = op_editor_ui::Point2D::new(x, y);
match menu.hit(menu_rect, point) {
MenuHit::Row(row) => {
let Some(choice) = menu.choice_for_row(row) else {
return;
};
self.editor_state.editor_ui.pending_file_action = Some(match choice {
FileMenuChoice::NewFile => FileAction::New,
FileMenuChoice::OpenFile => FileAction::Open,
FileMenuChoice::Save => FileAction::Save,
FileMenuChoice::SaveAs => FileAction::SaveAs,
FileMenuChoice::ExportImage => FileAction::ExportImage,
FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i),
FileMenuChoice::ClearRecent => FileAction::ClearRecent,
});
self.editor_state.editor_ui.file_menu_open = false;
self.editor_state.editor_ui.file_menu.hover = None;
self.mark_dirty();
}
MenuHit::Inside => {}
MenuHit::Outside => {
// Miss — the dismissing click is a blank press.
self.blur_text_inputs_on_blank_press();
self.editor_state.editor_ui.file_menu_open = false;
self.editor_state.editor_ui.file_menu.hover = None;
self.mark_dirty();
}
}
}
/// Commit a pending effect-parameter edit (Effects section's
/// editable value box). Parses the shared draft and writes it
/// via `SetEffectParam`; a non-numeric draft is discarded.
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
if let Ok(value) = draft.trim().parse::<f32>() {
if value.is_finite() {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
// Instance-write redirect (GAP #10) — see
// `apply_property_action` for the choke-point note.
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: focus.effect as u32,
field: focus.field,
value,
});
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
}
}
}
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
// Commit any pending variable-row / effect-param edit first.
self.commit_variables_panel_header_focus_if_any();
self.commit_variable_row_focus_if_any();
self.commit_effect_param_focus_if_any();
let Some(focus) = self.editor_state.ui.property_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
// Instance-write redirect (GAP #10) — see `apply_property_action`
// for the choke-point note.
let before = self.editor_state.snapshot_for_history();
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
match focus {
PropertyFocus::FillHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let hex = super::helpers::color_to_hex(color);
// The primary fill (index 0) keeps `set_selected_color`
// (prepends a solid + colour-variable-aware); a
// non-primary row writes its own solid fill by index.
if index == 0 {
let _ = self.editor_state.set_selected_color(true, &hex);
} else {
let _ = self.editor_state.set_selected_fill_hex_at(index, &hex);
}
}
}
}
PropertyFocus::StrokeHex => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let _ = self
.editor_state
.set_selected_color(false, &super::helpers::color_to_hex(color));
}
}
}
PropertyFocus::GradientStopHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
// The input pill never paints alpha digits,
// so re-attach the stop's existing alpha here
// — a transparent stop must stay transparent
// after the user edits its RGB.
let existing_alpha = self
.editor_state
.selected_node()
.and_then(|n| current_stop_alpha(n, index))
.unwrap_or(1.0);
let with_alpha = op_editor_ui::Color {
r: color.r,
g: color.g,
b: color.b,
a: existing_alpha,
};
let _ = self.editor_state.set_selected_gradient_stop_hex(
index,
&super::helpers::color_to_hex_with_alpha(with_alpha),
);
}
}
}
PropertyFocus::WidgetPlaceholder => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::Placeholder,
draft.trim(),
);
}
PropertyFocus::WidgetValue => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Value, draft.trim());
}
PropertyFocus::WidgetLabel => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Label, draft.trim());
}
PropertyFocus::WidgetLeadingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::LeadingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetTrailingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::TrailingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetBindKey => {
let _ = self
.editor_state
.set_selected_widget_bind_value(draft.trim());
}
_ => {
if let Ok(value) = draft.trim().parse::<f32>() {
let _ = self.editor_state.commit_property_edit(focus, value);
}
}
}
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
if self.editor_state.snapshot_for_history() != before {
self.editor_state.history_push_past(before);
}
self.mark_dirty();
}
}
/// Read the live alpha of gradient stop `index` on `node`, parsed

View file

@ -0,0 +1,306 @@
//! Native property/modal dispatchers extracted from the main action match.
use super::super::helpers::parse_hex_color;
use super::super::WidgetHostNative;
use super::current_stop_alpha;
use op_editor_core::PropertyFocus;
impl WidgetHostNative {
/// Export-dialog press dispatcher.
pub(in crate::widget_host) fn dispatch_export_dialog_press(
&mut self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::export_dialog::{
scale_from_index, ExportDialog, ExportDialogHit,
};
let dlg = ExportDialog::centered(viewport_w, viewport_h);
let point = op_editor_ui::Point2D::new(x, y);
let hit = dlg.hit_test(point);
self.editor_state.editor_ui.pressed_button = hit
.map(op_editor_ui::widgets::editor_state_ext::export_dialog_button)
.map(op_editor_core::ButtonPressTarget::ExportDialog);
match hit {
Some(ExportDialogHit::Format(f)) => {
self.editor_state.editor_ui.export_format =
op_editor_ui::widgets::editor_state_ext::export_format(f);
}
Some(ExportDialogHit::Scale(i)) => {
self.editor_state.editor_ui.export_scale = scale_from_index(i);
}
Some(ExportDialogHit::Cancel) => {
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
}
Some(ExportDialogHit::Export) => {
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
self.editor_state.editor_ui.pending_file_action =
Some(FileAction::ExportImageConfirm);
}
None => {
// No control hit — blank press (inside chrome or
// outside the dialog): blur the chrome text inputs.
self.blur_text_inputs_on_blank_press();
if !dlg.contains(point) {
// Outside click — dismiss like Cancel.
self.editor_state.editor_ui.export_dialog_open = false;
self.editor_state.editor_ui.export_dialog_hover = None;
}
}
}
self.mark_dirty();
}
/// Figma-import-modal press dispatcher.
pub(in crate::widget_host) fn dispatch_figma_import_press(
&mut self,
x: f32,
y: f32,
viewport_w: f32,
viewport_h: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::figma_import::{FigmaImportHit, FigmaImportModal};
let modal = FigmaImportModal::for_editor(&self.editor_state);
let panel_rect = modal.rect(viewport_w, viewport_h);
let hit = modal.hit_test(panel_rect, op_editor_ui::Point2D::new(x, y));
self.editor_state.editor_ui.pressed_button =
op_editor_ui::widgets::editor_state_ext::figma_import_button(hit)
.map(op_editor_core::ButtonPressTarget::FigmaImport);
match hit {
FigmaImportHit::Close => {
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::Outside => {
// Outside click — blank press: dismiss + blur inputs.
self.blur_text_inputs_on_blank_press();
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::DropZone => {
self.editor_state.editor_ui.pending_file_action = Some(FileAction::ImportFigma);
self.editor_state.editor_ui.figma_import_open = false;
self.editor_state.editor_ui.figma_import_hover = None;
}
FigmaImportHit::Inside => {
// Blank press on modal chrome — blur chrome inputs.
self.blur_text_inputs_on_blank_press();
}
}
self.mark_dirty();
}
/// File-menu press dispatcher.
pub(in crate::widget_host) fn dispatch_file_menu_press(
&mut self,
x: f32,
y: f32,
viewport_width: f32,
) {
use op_editor_core::editor_ui_state::FileAction;
use op_editor_ui::widgets::file_menu::{FileMenu, FileMenuChoice, MenuHit};
use op_editor_ui::widgets::top_bar::TopBar;
self.refresh_layout_scene();
let top_bar_rect = op_editor_ui::Rect {
origin: op_editor_ui::Point2D::new(0.0, 0.0),
size: op_editor_ui::Point2D::new(viewport_width, op_editor_ui::widgets::TOP_BAR_HEIGHT),
};
let anchor =
TopBar::file_menu_rect(top_bar_rect, self.editor_state.editor_ui.window_fullscreen);
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let menu = FileMenu::from_editor_ui(&self.editor_state.editor_ui, now_secs);
let menu_rect = menu.rect_at(anchor);
let point = op_editor_ui::Point2D::new(x, y);
match menu.hit(menu_rect, point) {
MenuHit::Row(row) => {
let Some(choice) = menu.choice_for_row(row) else {
return;
};
self.editor_state.editor_ui.pending_file_action = Some(match choice {
FileMenuChoice::NewFile => FileAction::New,
FileMenuChoice::OpenFile => FileAction::Open,
FileMenuChoice::Save => FileAction::Save,
FileMenuChoice::SaveAs => FileAction::SaveAs,
FileMenuChoice::ExportImage => FileAction::ExportImage,
FileMenuChoice::OpenRecent(i) => FileAction::OpenRecent(i),
FileMenuChoice::ClearRecent => FileAction::ClearRecent,
});
self.editor_state.editor_ui.file_menu_open = false;
self.editor_state.editor_ui.file_menu.hover = None;
self.mark_dirty();
}
MenuHit::Inside => {}
MenuHit::Outside => {
// Miss — the dismissing click is a blank press.
self.blur_text_inputs_on_blank_press();
self.editor_state.editor_ui.file_menu_open = false;
self.editor_state.editor_ui.file_menu.hover = None;
self.mark_dirty();
}
}
}
/// Commit a pending effect-parameter edit (Effects section's
/// editable value box). Parses the shared draft and writes it
/// via `SetEffectParam`; a non-numeric draft is discarded.
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
if let Ok(value) = draft.trim().parse::<f32>() {
if value.is_finite() {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
// Instance-write redirect (GAP #10) — see
// `apply_property_action` for the choke-point note.
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: focus.effect as u32,
field: focus.field,
value,
});
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
}
}
}
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
// Commit any pending variable-row / effect-param edit first.
self.commit_variables_panel_header_focus_if_any();
self.commit_variable_row_focus_if_any();
self.commit_effect_param_focus_if_any();
let Some(focus) = self.editor_state.ui.property_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
// Instance-write redirect (GAP #10) — see `apply_property_action`
// for the choke-point note.
let before = self.editor_state.snapshot_for_history();
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
match focus {
PropertyFocus::FillHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let hex = super::super::helpers::color_to_hex(color);
// The primary fill (index 0) keeps `set_selected_color`
// (prepends a solid + colour-variable-aware); a
// non-primary row writes its own solid fill by index.
if index == 0 {
let _ = self.editor_state.set_selected_color(true, &hex);
} else {
let _ = self.editor_state.set_selected_fill_hex_at(index, &hex);
}
}
}
}
PropertyFocus::StrokeHex => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let _ = self
.editor_state
.set_selected_color(false, &super::super::helpers::color_to_hex(color));
}
}
}
PropertyFocus::GradientStopHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
// The input pill never paints alpha digits,
// so re-attach the stop's existing alpha here
// — a transparent stop must stay transparent
// after the user edits its RGB.
let existing_alpha = self
.editor_state
.selected_node()
.and_then(|n| current_stop_alpha(n, index))
.unwrap_or(1.0);
let with_alpha = op_editor_ui::Color {
r: color.r,
g: color.g,
b: color.b,
a: existing_alpha,
};
let _ = self.editor_state.set_selected_gradient_stop_hex(
index,
&super::super::helpers::color_to_hex_with_alpha(with_alpha),
);
}
}
}
PropertyFocus::WidgetPlaceholder => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::Placeholder,
draft.trim(),
);
}
PropertyFocus::WidgetValue => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Value, draft.trim());
}
PropertyFocus::WidgetLabel => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Label, draft.trim());
}
PropertyFocus::WidgetLeadingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::LeadingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetTrailingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::TrailingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetBindKey => {
let _ = self
.editor_state
.set_selected_widget_bind_value(draft.trim());
}
_ => {
if let Ok(value) = draft.trim().parse::<f32>() {
let _ = self.editor_state.commit_property_edit(focus, value);
}
}
}
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
if self.editor_state.snapshot_for_history() != before {
self.editor_state.history_push_past(before);
}
self.mark_dirty();
}
}

View file

@ -2,14 +2,15 @@
//! action dispatch. Split out of `press.rs` to keep that file under
//! the 800-line cap (mirrors the native host's `property_dispatch.rs`).
#[path = "property_input_dispatch.rs"]
mod property_input_dispatch;
pub(in crate::widget_host) use property_input_dispatch::property_focus_initial;
use super::WidgetHost;
use jian_ops_schema::sizing::SizingKeyword;
use jian_ops_schema::variable::VariableKind;
use op_editor_core::{EffectField, PropertyFocus};
use op_editor_ui::util::{
color_to_hex, color_to_hex_with_alpha, format_panel_number, parse_hex_color,
};
use op_editor_ui::{widgets::PropertyPanel, Color};
use op_editor_core::EffectField;
use op_editor_ui::widgets::PropertyPanel;
fn effect_param_snapshot_value(
state: &op_editor_core::EditorState,
@ -153,6 +154,9 @@ impl WidgetHost {
A::AddFill => {
let _ = self.editor_state.add_selected_fill();
}
A::MoveFill { from, to } => {
let _ = self.editor_state.move_selected_fill(from, to);
}
A::RemoveFill(index) => {
let _ = self.editor_state.remove_selected_fill(index);
self.editor_state.editor_ui.close_fill_type_picker();
@ -549,146 +553,6 @@ impl WidgetHost {
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
if let Ok(value) = draft.trim().parse::<f32>() {
if value.is_finite() {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: focus.effect as u32,
field: focus.field,
value,
});
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
}
}
}
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
self.commit_variables_panel_header_focus_if_any();
self.commit_variable_row_focus_if_any();
self.commit_effect_param_focus_if_any();
let Some(focus) = self.editor_state.ui.property_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
let before = self.editor_state.snapshot_for_history();
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
match focus {
PropertyFocus::FillHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let hex = color_to_hex(color);
if index == 0 {
let _ = self.editor_state.set_selected_color(true, &hex);
} else {
let _ = self.editor_state.set_selected_fill_hex_at(index, &hex);
}
}
}
}
PropertyFocus::StrokeHex => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let _ = self
.editor_state
.set_selected_color(false, &color_to_hex(color));
}
}
}
PropertyFocus::GradientStopHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let existing_alpha = self
.editor_state
.selected_node()
.and_then(|n| current_stop_alpha(n, index))
.unwrap_or(1.0);
let with_alpha = Color {
r: color.r,
g: color.g,
b: color.b,
a: existing_alpha,
};
let _ = self.editor_state.set_selected_gradient_stop_hex(
index,
&color_to_hex_with_alpha(with_alpha),
);
}
}
}
PropertyFocus::WidgetPlaceholder => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::Placeholder,
draft.trim(),
);
}
PropertyFocus::WidgetValue => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Value, draft.trim());
}
PropertyFocus::WidgetLabel => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Label, draft.trim());
}
PropertyFocus::WidgetLeadingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::LeadingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetTrailingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::TrailingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetBindKey => {
let _ = self
.editor_state
.set_selected_widget_bind_value(draft.trim());
}
_ => {
if let Ok(value) = draft.trim().parse::<f32>() {
let _ = self.editor_state.commit_property_edit(focus, value);
}
}
}
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
if self.editor_state.snapshot_for_history() != before {
self.editor_state.history_push_past(before);
}
self.mark_dirty();
}
/// Dispatch a Code-panel action. `SelectFramework` is pure
/// `editor_state.codegen` state (works without the `codegen`
/// feature); `Generate` / `Regenerate` / `Cancel` raise the pending
@ -807,185 +671,6 @@ fn color_variable_name_at(state: &op_editor_core::EditorState, index: usize) ->
.map(|(name, _)| name.clone())
}
pub(in crate::widget_host) fn property_focus_initial(
focus: PropertyFocus,
panel: &op_editor_ui::widgets::PropertyPanel,
) -> String {
match focus {
PropertyFocus::PositionX => panel.snapshot.x.to_string(),
PropertyFocus::PositionY => panel.snapshot.y.to_string(),
PropertyFocus::SizeW => panel.snapshot.width.to_string(),
PropertyFocus::SizeH => panel.snapshot.height.to_string(),
PropertyFocus::LayoutGap => format_panel_number(panel.snapshot.layout_gap),
PropertyFocus::PaddingTop
| PropertyFocus::PaddingRight
| PropertyFocus::PaddingBottom
| PropertyFocus::PaddingLeft => panel
.snapshot
.layout_padding
.value_for(focus)
.map(format_panel_number)
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
PropertyFocus::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
PropertyFocus::Opacity => "100".to_string(),
PropertyFocus::PolygonSides => panel.snapshot.polygon_sides.unwrap_or(3).to_string(),
PropertyFocus::EllipseStart => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.start_deg)
.unwrap_or(0.0),
),
PropertyFocus::EllipseSweep => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.sweep_deg)
.unwrap_or(360.0),
),
PropertyFocus::EllipseInnerRadius => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.inner_percent)
.unwrap_or(0.0),
),
PropertyFocus::FontSize => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.font_size))
.unwrap_or_else(|| "16".to_string()),
PropertyFocus::FontWeight => panel
.snapshot
.text
.as_ref()
.map(|t| t.font_weight.to_string())
.unwrap_or_else(|| "400".to_string()),
PropertyFocus::LineHeight => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.line_height_percent))
.unwrap_or_else(|| "120".to_string()),
PropertyFocus::LetterSpacing => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.letter_spacing))
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::FillOpacity(index) => {
let opacity = panel
.snapshot
.fills
.get(index)
.map(|f| f.opacity)
.unwrap_or(panel.snapshot.fill_opacity);
((opacity * 100.0).round() as i32).to_string()
}
PropertyFocus::FillHex(index) => panel
.snapshot
.fills
.get(index)
.map(|f| f.color)
.or(panel.snapshot.fill)
.map(color_to_hex)
.unwrap_or_else(|| "#FFFFFF".to_string()),
PropertyFocus::StrokeHex => color_to_hex(panel.snapshot.stroke_swatch_color()),
// Seed the SAME width the inline input paints (0 when unset, and
// un-rounded) so clicking in never changes the displayed value.
PropertyFocus::StrokeWidth => {
format_panel_number(panel.snapshot.stroke.map(|s| s.width).unwrap_or(0.0))
}
PropertyFocus::StrokeTopWidth
| PropertyFocus::StrokeRightWidth
| PropertyFocus::StrokeBottomWidth
| PropertyFocus::StrokeLeftWidth => panel
.snapshot
.stroke_side_width_for(focus)
.map(format_panel_number)
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::GradientAngle => {
let a = panel.snapshot.gradient_angle.unwrap_or(0.0);
if a.fract() == 0.0 {
format!("{}", a as i32)
} else {
format!("{a}")
}
}
PropertyFocus::GradientStopHex(i) => panel
.snapshot
.gradient_stops
.get(i)
.map(|s| op_editor_ui::widgets::property_panel_fill::stop_hex_rgb_only(&s.hex))
.unwrap_or_else(|| "#000000".to_string()),
PropertyFocus::GradientStopOffset(i) => panel
.snapshot
.gradient_stops
.get(i)
.map(|s| ((s.offset * 100.0).round() as i32).to_string())
.unwrap_or_else(|| "0".to_string()),
// Widget-section fields — seed the input draft from the selected
// widget's current value so editing starts from it (mirrors the
// native `property_focus_initial`).
PropertyFocus::WidgetPlaceholder => panel
.snapshot
.widget
.as_ref()
.map(|w| w.placeholder.clone())
.unwrap_or_default(),
PropertyFocus::WidgetValue => panel
.snapshot
.widget
.as_ref()
.map(|w| w.value.clone())
.unwrap_or_default(),
PropertyFocus::WidgetLabel => panel
.snapshot
.widget
.as_ref()
.map(|w| w.label.clone())
.unwrap_or_default(),
PropertyFocus::WidgetLeadingIcon => panel
.snapshot
.widget
.as_ref()
.map(|w| w.leading_icon.clone())
.unwrap_or_default(),
PropertyFocus::WidgetTrailingIcon => panel
.snapshot
.widget
.as_ref()
.map(|w| w.trailing_icon.clone())
.unwrap_or_default(),
PropertyFocus::WidgetBindKey => panel
.snapshot
.widget
.as_ref()
.map(|w| w.bind_key.clone())
.unwrap_or_default(),
PropertyFocus::WidgetMin => panel
.snapshot
.widget
.as_ref()
.map(|w| w.min.clone())
.unwrap_or_default(),
PropertyFocus::WidgetMax => panel
.snapshot
.widget
.as_ref()
.map(|w| w.max.clone())
.unwrap_or_default(),
PropertyFocus::WidgetStep => panel
.snapshot
.widget
.as_ref()
.map(|w| w.step.clone())
.unwrap_or_default(),
}
}
fn current_stop_alpha(node: &jian_ops_schema::node::PenNode, index: usize) -> Option<f32> {
use jian_ops_schema::style::PenFill;
let first = op_editor_core::fills::node_fills(node).and_then(|f| f.first())?;

View file

@ -0,0 +1,330 @@
//! Web property-input commits and focus-value formatting.
use super::super::WidgetHost;
use super::current_stop_alpha;
use op_editor_core::PropertyFocus;
use op_editor_ui::util::{
color_to_hex, color_to_hex_with_alpha, format_panel_number, parse_hex_color,
};
use op_editor_ui::Color;
impl WidgetHost {
pub(in crate::widget_host) fn commit_effect_param_focus_if_any(&mut self) {
let Some(focus) = self.editor_state.editor_ui.effect_param_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
if let Ok(value) = draft.trim().parse::<f32>() {
if value.is_finite() {
let id = self.editor_state.selection.anchor.clone();
if id.is_real() {
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
self.editor_state.commit_history();
let _ =
self.editor_state
.apply(op_editor_core::EditorCommand::SetEffectParam {
node_id: id,
index: focus.effect as u32,
field: focus.field,
value,
});
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
}
}
}
self.mark_dirty();
}
pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) {
self.commit_variables_panel_header_focus_if_any();
self.commit_variable_row_focus_if_any();
self.commit_effect_param_focus_if_any();
let Some(focus) = self.editor_state.ui.property_focus.take() else {
return;
};
self.editor_state.ui.property_draft_select_all = false;
let draft = self.editor_state.ui.property_input.text().to_owned();
self.editor_state.ui.property_input.set_text("");
self.editor_state.ui.property_input_draft.clear();
self.editor_state.ui.property_caret_pos = 0;
let before = self.editor_state.snapshot_for_history();
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
match focus {
PropertyFocus::FillHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let hex = color_to_hex(color);
if index == 0 {
let _ = self.editor_state.set_selected_color(true, &hex);
} else {
let _ = self.editor_state.set_selected_fill_hex_at(index, &hex);
}
}
}
}
PropertyFocus::StrokeHex => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let _ = self
.editor_state
.set_selected_color(false, &color_to_hex(color));
}
}
}
PropertyFocus::GradientStopHex(index) => {
let stripped = draft.trim().trim_start_matches('#');
if !stripped.is_empty() {
if let Some(color) = parse_hex_color(draft.trim()) {
let existing_alpha = self
.editor_state
.selected_node()
.and_then(|n| current_stop_alpha(n, index))
.unwrap_or(1.0);
let with_alpha = Color {
r: color.r,
g: color.g,
b: color.b,
a: existing_alpha,
};
let _ = self.editor_state.set_selected_gradient_stop_hex(
index,
&color_to_hex_with_alpha(with_alpha),
);
}
}
}
PropertyFocus::WidgetPlaceholder => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::Placeholder,
draft.trim(),
);
}
PropertyFocus::WidgetValue => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Value, draft.trim());
}
PropertyFocus::WidgetLabel => {
let _ = self
.editor_state
.set_selected_widget_text(op_editor_core::WidgetTextField::Label, draft.trim());
}
PropertyFocus::WidgetLeadingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::LeadingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetTrailingIcon => {
let _ = self.editor_state.set_selected_widget_text(
op_editor_core::WidgetTextField::TrailingIcon,
draft.trim(),
);
}
PropertyFocus::WidgetBindKey => {
let _ = self
.editor_state
.set_selected_widget_bind_value(draft.trim());
}
_ => {
if let Ok(value) = draft.trim().parse::<f32>() {
let _ = self.editor_state.commit_property_edit(focus, value);
}
}
}
if let Some(scope) = instance_scope {
self.editor_state.finish_instance_write(scope);
}
if self.editor_state.snapshot_for_history() != before {
self.editor_state.history_push_past(before);
}
self.mark_dirty();
}
}
pub(in crate::widget_host) fn property_focus_initial(
focus: PropertyFocus,
panel: &op_editor_ui::widgets::PropertyPanel,
) -> String {
match focus {
PropertyFocus::PositionX => panel.snapshot.x.to_string(),
PropertyFocus::PositionY => panel.snapshot.y.to_string(),
PropertyFocus::SizeW => panel.snapshot.width.to_string(),
PropertyFocus::SizeH => panel.snapshot.height.to_string(),
PropertyFocus::LayoutGap => format_panel_number(panel.snapshot.layout_gap),
PropertyFocus::PaddingTop
| PropertyFocus::PaddingRight
| PropertyFocus::PaddingBottom
| PropertyFocus::PaddingLeft => panel
.snapshot
.layout_padding
.value_for(focus)
.map(format_panel_number)
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
PropertyFocus::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
PropertyFocus::Opacity => "100".to_string(),
PropertyFocus::PolygonSides => panel.snapshot.polygon_sides.unwrap_or(3).to_string(),
PropertyFocus::EllipseStart => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.start_deg)
.unwrap_or(0.0),
),
PropertyFocus::EllipseSweep => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.sweep_deg)
.unwrap_or(360.0),
),
PropertyFocus::EllipseInnerRadius => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.inner_percent)
.unwrap_or(0.0),
),
PropertyFocus::FontSize => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.font_size))
.unwrap_or_else(|| "16".to_string()),
PropertyFocus::FontWeight => panel
.snapshot
.text
.as_ref()
.map(|t| t.font_weight.to_string())
.unwrap_or_else(|| "400".to_string()),
PropertyFocus::LineHeight => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.line_height_percent))
.unwrap_or_else(|| "120".to_string()),
PropertyFocus::LetterSpacing => panel
.snapshot
.text
.as_ref()
.map(|t| format_panel_number(t.letter_spacing))
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::FillOpacity(index) => {
let opacity = panel
.snapshot
.fills
.get(index)
.map(|f| f.opacity)
.unwrap_or(panel.snapshot.fill_opacity);
((opacity * 100.0).round() as i32).to_string()
}
PropertyFocus::FillHex(index) => panel
.snapshot
.fills
.get(index)
.map(|f| f.color)
.or(panel.snapshot.fill)
.map(color_to_hex)
.unwrap_or_else(|| "#FFFFFF".to_string()),
PropertyFocus::StrokeHex => color_to_hex(panel.snapshot.stroke_swatch_color()),
// Seed the SAME width the inline input paints (0 when unset, and
// un-rounded) so clicking in never changes the displayed value.
PropertyFocus::StrokeWidth => {
format_panel_number(panel.snapshot.stroke.map(|s| s.width).unwrap_or(0.0))
}
PropertyFocus::StrokeTopWidth
| PropertyFocus::StrokeRightWidth
| PropertyFocus::StrokeBottomWidth
| PropertyFocus::StrokeLeftWidth => panel
.snapshot
.stroke_side_width_for(focus)
.map(format_panel_number)
.unwrap_or_else(|| "0".to_string()),
PropertyFocus::GradientAngle => {
let a = panel.snapshot.gradient_angle.unwrap_or(0.0);
if a.fract() == 0.0 {
format!("{}", a as i32)
} else {
format!("{a}")
}
}
PropertyFocus::GradientStopHex(i) => panel
.snapshot
.gradient_stops
.get(i)
.map(|s| op_editor_ui::widgets::property_panel_fill::stop_hex_rgb_only(&s.hex))
.unwrap_or_else(|| "#000000".to_string()),
PropertyFocus::GradientStopOffset(i) => panel
.snapshot
.gradient_stops
.get(i)
.map(|s| ((s.offset * 100.0).round() as i32).to_string())
.unwrap_or_else(|| "0".to_string()),
// Widget-section fields — seed the input draft from the selected
// widget's current value so editing starts from it (mirrors the
// native `property_focus_initial`).
PropertyFocus::WidgetPlaceholder => panel
.snapshot
.widget
.as_ref()
.map(|w| w.placeholder.clone())
.unwrap_or_default(),
PropertyFocus::WidgetValue => panel
.snapshot
.widget
.as_ref()
.map(|w| w.value.clone())
.unwrap_or_default(),
PropertyFocus::WidgetLabel => panel
.snapshot
.widget
.as_ref()
.map(|w| w.label.clone())
.unwrap_or_default(),
PropertyFocus::WidgetLeadingIcon => panel
.snapshot
.widget
.as_ref()
.map(|w| w.leading_icon.clone())
.unwrap_or_default(),
PropertyFocus::WidgetTrailingIcon => panel
.snapshot
.widget
.as_ref()
.map(|w| w.trailing_icon.clone())
.unwrap_or_default(),
PropertyFocus::WidgetBindKey => panel
.snapshot
.widget
.as_ref()
.map(|w| w.bind_key.clone())
.unwrap_or_default(),
PropertyFocus::WidgetMin => panel
.snapshot
.widget
.as_ref()
.map(|w| w.min.clone())
.unwrap_or_default(),
PropertyFocus::WidgetMax => panel
.snapshot
.widget
.as_ref()
.map(|w| w.max.clone())
.unwrap_or_default(),
PropertyFocus::WidgetStep => panel
.snapshot
.widget
.as_ref()
.map(|w| w.step.clone())
.unwrap_or_default(),
}
}

View file

@ -1,5 +1,6 @@
use super::WidgetHost;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::PenFill;
use jian_ops_schema::variable::{VariableKind, VariableScalar};
use op_editor_core::editor_ui_state::EffectParamFocus;
use op_editor_core::ui_draft::PropertyFocus;
@ -18,6 +19,42 @@ fn seed(host: &mut WidgetHost, json: &str) {
host.editor_state_dirty = true;
}
#[test]
fn web_move_fill_action_dispatches_as_one_undoable_edit() {
let mut host = WidgetHost::new();
seed(
&mut host,
r##"{"version":"0.8.0","children":[{
"type":"rectangle","id":"rect","name":"Rect",
"x":0,"y":0,"width":10,"height":10,
"fill":[
{"type":"solid","color":"#111111"},
{"type":"solid","color":"#222222"},
{"type":"solid","color":"#333333"}
]
}]}"##,
);
host.editor_state.set_single_selection(NodeId::new("rect"));
host.apply_property_action(PropertyPanelAction::MoveFill { from: 2, to: 0 });
let node = op_editor_core::walkers::find_node(
host.editor_state.active_children(),
&NodeId::new("rect"),
)
.expect("rect exists");
let colors: Vec<_> = op_editor_core::fills::node_fills(node)
.expect("fills exist")
.iter()
.map(|fill| match fill {
PenFill::Solid(body) => body.color.as_str(),
other => panic!("expected solid, got {other:?}"),
})
.collect();
assert_eq!(colors, ["#333333", "#111111", "#222222"]);
assert_eq!(host.editor_state.history.past.len(), 1);
}
fn point_for_property_focus(host: &WidgetHost, want: PropertyFocus) -> (f32, f32) {
let panel = PropertyPanel::for_selection(&host.editor_state)
.expect("fixture selection shows property panel");