From cb0842a5c917c129a6d0d0944ab23d74cfcef9c4 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 12 Jul 2026 09:10:49 +0800 Subject: [PATCH] feat(panels): reorder multiple fill layers --- crates/op-editor-core/src/fill_order.rs | 49 +++ crates/op-editor-core/src/fills_tests.rs | 144 +++++++- crates/op-editor-core/src/history.rs | 104 +++++- crates/op-editor-core/src/lib.rs | 2 + crates/op-editor-core/src/mutators.rs | 114 ------ crates/op-editor-core/src/state.rs | 2 + .../src/widgets/property_panel_action.rs | 5 + .../src/widgets/property_panel_fill.rs | 109 +++++- .../src/widgets/property_panel_fill_tests.rs | 73 ++++ .../widgets/property_panel_input_layout.rs | 42 ++- .../src/widget_host/instance_panel_tests.rs | 85 +++++ .../src/widget_host/property_dispatch.rs | 306 +--------------- .../widget_host/property_input_dispatch.rs | 306 ++++++++++++++++ .../src/widget_host/property_dispatch.rs | 333 +----------------- .../widget_host/property_input_dispatch.rs | 330 +++++++++++++++++ .../src/widget_host/property_input_tests.rs | 37 ++ 16 files changed, 1264 insertions(+), 777 deletions(-) create mode 100644 crates/op-editor-core/src/fill_order.rs create mode 100644 crates/op-host-native/src/widget_host/property_input_dispatch.rs create mode 100644 crates/op-host-web/src/widget_host/property_input_dispatch.rs diff --git a/crates/op-editor-core/src/fill_order.rs b/crates/op-editor-core/src/fill_order.rs new file mode 100644 index 000000000..66c455417 --- /dev/null +++ b/crates/op-editor-core/src/fill_order.rs @@ -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 + } +} diff --git a/crates/op-editor-core/src/fills_tests.rs b/crates/op-editor-core/src/fills_tests.rs index a2f1d8456..2cd9bba4b 100644 --- a/crates/op-editor-core/src/fills_tests.rs +++ b/crates/op-editor-core/src/fills_tests.rs @@ -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 { + 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 diff --git a/crates/op-editor-core/src/history.rs b/crates/op-editor-core/src/history.rs index d347e1d00..6b214db1b 100644 --- a/crates/op-editor-core/src/history.rs +++ b/crates/op-editor-core/src/history.rs @@ -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, + /// 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, + /// 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, /// 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::*; diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index f99491b3c..cd4dd03b2 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -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, diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index 5bcfb8b02..d1978340b 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -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. diff --git a/crates/op-editor-core/src/state.rs b/crates/op-editor-core/src/state.rs index e3fe992dc..996f25149 100644 --- a/crates/op-editor-core/src/state.rs +++ b/crates/op-editor-core/src/state.rs @@ -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")); diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index d0be6aa5f..28582b930 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -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. diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill.rs b/crates/op-editor-ui/src/widgets/property_panel_fill.rs index ec4071761..6666860d7 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill.rs @@ -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, diff --git a/crates/op-editor-ui/src/widgets/property_panel_fill_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_fill_tests.rs index 22ed03329..2a8e1e5e1 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_fill_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_fill_tests.rs @@ -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(); diff --git a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs index 1feee1a78..e75072e2f 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs @@ -4,7 +4,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); diff --git a/crates/op-host-native/src/widget_host/instance_panel_tests.rs b/crates/op-host-native/src/widget_host/instance_panel_tests.rs index 41aa56cb0..9b0a2f1d1 100644 --- a/crates/op-host-native/src/widget_host/instance_panel_tests.rs +++ b/crates/op-host-native/src/widget_host/instance_panel_tests.rs @@ -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(), diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index 342334a04..22d15f81b 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -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::() { - 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::() { - 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 diff --git a/crates/op-host-native/src/widget_host/property_input_dispatch.rs b/crates/op-host-native/src/widget_host/property_input_dispatch.rs new file mode 100644 index 000000000..ec04056dc --- /dev/null +++ b/crates/op-host-native/src/widget_host/property_input_dispatch.rs @@ -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::() { + 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::() { + 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(); + } +} diff --git a/crates/op-host-web/src/widget_host/property_dispatch.rs b/crates/op-host-web/src/widget_host/property_dispatch.rs index 69ef63605..33cc82274 100644 --- a/crates/op-host-web/src/widget_host/property_dispatch.rs +++ b/crates/op-host-web/src/widget_host/property_dispatch.rs @@ -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::() { - 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::() { - 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 { use jian_ops_schema::style::PenFill; let first = op_editor_core::fills::node_fills(node).and_then(|f| f.first())?; diff --git a/crates/op-host-web/src/widget_host/property_input_dispatch.rs b/crates/op-host-web/src/widget_host/property_input_dispatch.rs new file mode 100644 index 000000000..9a0e4d878 --- /dev/null +++ b/crates/op-host-web/src/widget_host/property_input_dispatch.rs @@ -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::() { + 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::() { + 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(), + } +} diff --git a/crates/op-host-web/src/widget_host/property_input_tests.rs b/crates/op-host-web/src/widget_host/property_input_tests.rs index e34b42914..dbd4ae980 100644 --- a/crates/op-host-web/src/widget_host/property_input_tests.rs +++ b/crates/op-host-web/src/widget_host/property_input_tests.rs @@ -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");