feat(panels): reorder multiple fill layers
This commit is contained in:
parent
df5a9aa8b2
commit
cb0842a5c9
49
crates/op-editor-core/src/fill_order.rs
Normal file
49
crates/op-editor-core/src/fill_order.rs
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
306
crates/op-host-native/src/widget_host/property_input_dispatch.rs
Normal file
306
crates/op-host-native/src/widget_host/property_input_dispatch.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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())?;
|
||||
|
|
|
|||
330
crates/op-host-web/src/widget_host/property_input_dispatch.rs
Normal file
330
crates/op-host-web/src/widget_host/property_input_dispatch.rs
Normal 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(),
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue