feat(op-editor-core): port color/align/variables/rename mutators
This commit is contained in:
parent
9d9862a099
commit
3e04f9a261
372
crates/op-editor-core/src/align.rs
Normal file
372
crates/op-editor-core/src/align.rs
Normal file
|
|
@ -0,0 +1,372 @@
|
|||
//! Align / distribute mutators — ported from shell-core's
|
||||
//! `document/align.rs`, retargeted onto `EditorState` +
|
||||
//! `jian_ops_schema::PenNode`.
|
||||
//!
|
||||
//! Reference frame:
|
||||
//! - 2+ selected → union of the selection's aggregate bounds.
|
||||
//! - 1 selected → parent container's aggregate bounds (a top-level
|
||||
//! node has no useful reference and silently no-ops).
|
||||
//! Distribute requires 3+ nodes; fewer silently no-ops.
|
||||
//!
|
||||
//! All deltas go through [`crate::walkers::translate_subtree`], so
|
||||
//! containers cascade to their descendants exactly like drag-move.
|
||||
//! An ancestor-already-in-set dedup stops a descendant moving twice.
|
||||
|
||||
use crate::geometry::{aggregate_bounds, union_aggregate_bounds, DocRect};
|
||||
use crate::node_id::NodeId;
|
||||
use crate::state::EditorState;
|
||||
use crate::walkers::{find_node, is_ancestor_in_set, translate_subtree};
|
||||
use jian_ops_schema::node::PenNode;
|
||||
|
||||
/// The six align edges + two distribute axes the PropertyPanel's
|
||||
/// Align section exposes. Ported verbatim from shell-core.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AlignAction {
|
||||
Left,
|
||||
CenterH,
|
||||
Right,
|
||||
Top,
|
||||
CenterV,
|
||||
Bottom,
|
||||
DistributeH,
|
||||
DistributeV,
|
||||
}
|
||||
|
||||
impl AlignAction {
|
||||
fn is_distribute(self) -> bool {
|
||||
matches!(self, Self::DistributeH | Self::DistributeV)
|
||||
}
|
||||
fn is_horizontal(self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Left | Self::CenterH | Self::Right | Self::DistributeH
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorState {
|
||||
/// Apply alignment or distribution to the active selection.
|
||||
/// Returns true when at least one node moved; pushes history
|
||||
/// only on real motion.
|
||||
pub fn align_selected(&mut self, action: AlignAction) -> bool {
|
||||
let editable: Vec<NodeId> = self
|
||||
.selection
|
||||
.set
|
||||
.iter()
|
||||
.filter(|id| self.is_editable(id))
|
||||
.cloned()
|
||||
.collect();
|
||||
if editable.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if action.is_distribute() && editable.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
// Resolve the reference rect against an immutable borrow
|
||||
// before taking the mutable one.
|
||||
let reference = {
|
||||
let children = self.active_children();
|
||||
if editable.len() >= 2 {
|
||||
match union_aggregate_bounds(children, &editable) {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
}
|
||||
} else {
|
||||
match parent_aggregate_bounds(children, &editable[0]) {
|
||||
Some(r) => r,
|
||||
None => return false,
|
||||
}
|
||||
}
|
||||
};
|
||||
let pre = self.snapshot_for_history();
|
||||
let children = self.active_children_mut();
|
||||
let moved = if action.is_distribute() {
|
||||
apply_distribute(children, &editable, action)
|
||||
} else {
|
||||
apply_align(children, &editable, reference, action)
|
||||
};
|
||||
if moved {
|
||||
self.history_push_past(pre);
|
||||
}
|
||||
moved
|
||||
}
|
||||
}
|
||||
|
||||
/// Move each editable node so its edge / center matches `reference`.
|
||||
///
|
||||
/// Ancestor-in-set dedup: when both an ancestor and a descendant are
|
||||
/// selected, only the ancestor moves — the descendant cascades via
|
||||
/// `translate_subtree`.
|
||||
fn apply_align(
|
||||
children: &mut [PenNode],
|
||||
editable: &[NodeId],
|
||||
reference: DocRect,
|
||||
action: AlignAction,
|
||||
) -> bool {
|
||||
let ref_min_x = reference.x;
|
||||
let ref_max_x = reference.x + reference.w;
|
||||
let ref_mid_x = reference.x + reference.w / 2.0;
|
||||
let ref_min_y = reference.y;
|
||||
let ref_max_y = reference.y + reference.h;
|
||||
let ref_mid_y = reference.y + reference.h / 2.0;
|
||||
let mut moved = false;
|
||||
for id in editable {
|
||||
if is_ancestor_in_set(children, id, editable) {
|
||||
continue;
|
||||
}
|
||||
let Some(cur) = find_node(children, id).map(aggregate_bounds) else {
|
||||
continue;
|
||||
};
|
||||
let (cx, cy, cw, ch) = (cur.x, cur.y, cur.w, cur.h);
|
||||
let (dx, dy) = match action {
|
||||
AlignAction::Left => (ref_min_x - cx, 0.0),
|
||||
AlignAction::Right => (ref_max_x - (cx + cw), 0.0),
|
||||
AlignAction::CenterH => (ref_mid_x - (cx + cw / 2.0), 0.0),
|
||||
AlignAction::Top => (0.0, ref_min_y - cy),
|
||||
AlignAction::Bottom => (0.0, ref_max_y - (cy + ch)),
|
||||
AlignAction::CenterV => (0.0, ref_mid_y - (cy + ch / 2.0)),
|
||||
AlignAction::DistributeH | AlignAction::DistributeV => unreachable!(),
|
||||
};
|
||||
if dx == 0.0 && dy == 0.0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(node) = crate::walkers::find_node_mut(children, id) {
|
||||
translate_subtree(node, dx, dy);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// Sort by center along the distribution axis, then evenly space the
|
||||
/// inner nodes between the outermost two. Endpoints stay put.
|
||||
fn apply_distribute(children: &mut [PenNode], editable: &[NodeId], action: AlignAction) -> bool {
|
||||
let horizontal = action.is_horizontal();
|
||||
// Ancestor-in-set dedup before sorting — a selected ancestor +
|
||||
// descendant must not contribute two anchors.
|
||||
let filtered: Vec<NodeId> = editable
|
||||
.iter()
|
||||
.cloned()
|
||||
.filter(|id| !is_ancestor_in_set(children, id, editable))
|
||||
.collect();
|
||||
let mut sorted: Vec<(NodeId, DocRect)> = filtered
|
||||
.iter()
|
||||
.filter_map(|id| find_node(children, id).map(|n| (id.clone(), aggregate_bounds(n))))
|
||||
.collect();
|
||||
if sorted.len() < 3 {
|
||||
return false;
|
||||
}
|
||||
let center = |r: &DocRect| -> f64 {
|
||||
if horizontal {
|
||||
r.x + r.w / 2.0
|
||||
} else {
|
||||
r.y + r.h / 2.0
|
||||
}
|
||||
};
|
||||
sorted.sort_by(|a, b| {
|
||||
center(&a.1)
|
||||
.partial_cmp(¢er(&b.1))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
let n = sorted.len();
|
||||
let first_c = center(&sorted[0].1);
|
||||
let last_c = center(&sorted[n - 1].1);
|
||||
let step = (last_c - first_c) / (n - 1) as f64;
|
||||
let mut moved = false;
|
||||
for i in 1..n - 1 {
|
||||
let (id, cur) = (sorted[i].0.clone(), sorted[i].1);
|
||||
let cur_c = center(&cur);
|
||||
let target_c = first_c + step * i as f64;
|
||||
let delta = target_c - cur_c;
|
||||
if delta == 0.0 {
|
||||
continue;
|
||||
}
|
||||
let (dx, dy) = if horizontal { (delta, 0.0) } else { (0.0, delta) };
|
||||
if let Some(node) = crate::walkers::find_node_mut(children, &id) {
|
||||
translate_subtree(node, dx, dy);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// Walk `children` for the node whose own `children` vec holds
|
||||
/// `target`, returning that parent's aggregate bounds. `None` when
|
||||
/// `target` is top-level or absent.
|
||||
fn parent_aggregate_bounds(children: &[PenNode], target: &NodeId) -> Option<DocRect> {
|
||||
use crate::pen_node_ext::PenNodeExt;
|
||||
for child in children {
|
||||
if let Some(grand) = child.children() {
|
||||
if grand.iter().any(|c| c.id_str() == target.as_str()) {
|
||||
return Some(aggregate_bounds(child));
|
||||
}
|
||||
if let Some(rect) = parent_aggregate_bounds(grand, target) {
|
||||
return Some(rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::geometry::aggregate_bounds;
|
||||
use crate::test_support::{frame, rect, state_with};
|
||||
use crate::walkers::find_node;
|
||||
|
||||
/// A state with `n` rectangles at known offsets, all selected.
|
||||
fn rects(positions: &[(f64, f64, f64, f64)]) -> EditorState {
|
||||
let roots: Vec<PenNode> = positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &(x, y, w, h))| rect(&format!("n{}", 10 + i), "r", x, y, w, h))
|
||||
.collect();
|
||||
let mut s = state_with(roots);
|
||||
let ids: Vec<NodeId> = positions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, _)| NodeId::new(format!("n{}", 10 + i)))
|
||||
.collect();
|
||||
s.selection.anchor = ids.last().cloned().unwrap();
|
||||
s.selection.set = ids;
|
||||
s
|
||||
}
|
||||
|
||||
fn bx(s: &EditorState, id: &str) -> DocRect {
|
||||
aggregate_bounds(find_node(s.active_children(), &NodeId::new(id)).unwrap())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_left_snaps_to_union_min_x() {
|
||||
let mut s = rects(&[(10.0, 0.0, 40.0, 20.0), (50.0, 100.0, 30.0, 20.0)]);
|
||||
assert!(s.align_selected(AlignAction::Left));
|
||||
assert_eq!(bx(&s, "n10").x, 10.0);
|
||||
assert_eq!(bx(&s, "n11").x, 10.0);
|
||||
assert_eq!(s.history.past.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_right_snaps_to_union_max_x() {
|
||||
let mut s = rects(&[(0.0, 0.0, 40.0, 20.0), (50.0, 100.0, 30.0, 20.0)]);
|
||||
assert!(s.align_selected(AlignAction::Right));
|
||||
// Union max-x = 80; first node right=80 → x=40.
|
||||
assert_eq!(bx(&s, "n10").x, 40.0);
|
||||
assert_eq!(bx(&s, "n11").x, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_center_h_snaps_to_union_mid_x() {
|
||||
let mut s = rects(&[(0.0, 0.0, 40.0, 20.0), (60.0, 100.0, 20.0, 20.0)]);
|
||||
assert!(s.align_selected(AlignAction::CenterH));
|
||||
assert_eq!(bx(&s, "n10").x, 20.0);
|
||||
assert_eq!(bx(&s, "n11").x, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_top_snaps_to_union_min_y() {
|
||||
let mut s = rects(&[(0.0, 10.0, 20.0, 20.0), (50.0, 80.0, 20.0, 20.0)]);
|
||||
assert!(s.align_selected(AlignAction::Top));
|
||||
assert_eq!(bx(&s, "n10").y, 10.0);
|
||||
assert_eq!(bx(&s, "n11").y, 10.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_bottom_snaps_to_union_max_y() {
|
||||
let mut s = rects(&[(0.0, 0.0, 20.0, 20.0), (50.0, 50.0, 20.0, 30.0)]);
|
||||
assert!(s.align_selected(AlignAction::Bottom));
|
||||
assert_eq!(bx(&s, "n10").y, 60.0);
|
||||
assert_eq!(bx(&s, "n11").y, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn align_center_v_snaps_to_union_mid_y() {
|
||||
let mut s = rects(&[(0.0, 0.0, 20.0, 40.0), (50.0, 60.0, 20.0, 20.0)]);
|
||||
assert!(s.align_selected(AlignAction::CenterV));
|
||||
assert_eq!(bx(&s, "n10").y, 20.0);
|
||||
assert_eq!(bx(&s, "n11").y, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_h_equal_spacing_between_centers() {
|
||||
let mut s = rects(&[
|
||||
(0.0, 0.0, 20.0, 20.0),
|
||||
(20.0, 0.0, 10.0, 20.0),
|
||||
(80.0, 0.0, 20.0, 20.0),
|
||||
]);
|
||||
assert!(s.align_selected(AlignAction::DistributeH));
|
||||
// Middle node center → 50; w=10 → x=45.
|
||||
assert_eq!(bx(&s, "n11").x, 45.0);
|
||||
assert_eq!(bx(&s, "n10").x, 0.0);
|
||||
assert_eq!(bx(&s, "n12").x, 80.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_v_equal_spacing_between_centers() {
|
||||
let mut s = rects(&[
|
||||
(0.0, 0.0, 20.0, 20.0),
|
||||
(0.0, 30.0, 20.0, 10.0),
|
||||
(0.0, 80.0, 20.0, 20.0),
|
||||
]);
|
||||
assert!(s.align_selected(AlignAction::DistributeV));
|
||||
assert_eq!(bx(&s, "n11").y, 45.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distribute_under_three_is_no_op() {
|
||||
let mut s = rects(&[(0.0, 0.0, 20.0, 20.0), (40.0, 0.0, 20.0, 20.0)]);
|
||||
assert!(!s.align_selected(AlignAction::DistributeH));
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_selection_no_ops() {
|
||||
let mut s = rects(&[(0.0, 0.0, 20.0, 20.0)]);
|
||||
s.clear_selection();
|
||||
assert!(!s.align_selected(AlignAction::Left));
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_aligned_skips_history() {
|
||||
let mut s = rects(&[(10.0, 0.0, 20.0, 20.0), (10.0, 50.0, 30.0, 20.0)]);
|
||||
assert!(!s.align_selected(AlignAction::Left));
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_select_aligns_to_parent_frame() {
|
||||
let child = rect("n20", "c", 50.0, 50.0, 20.0, 20.0);
|
||||
let f = frame("n10", "f", 0.0, 0.0, 200.0, 100.0, vec![child]);
|
||||
let mut s = state_with(vec![f]);
|
||||
s.set_single_selection(NodeId::new("n20"));
|
||||
assert!(s.align_selected(AlignAction::Left));
|
||||
assert_eq!(bx(&s, "n20").x, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_select_top_level_no_ops() {
|
||||
let mut s = rects(&[(50.0, 50.0, 20.0, 20.0)]);
|
||||
assert!(!s.align_selected(AlignAction::Left));
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ancestor_in_set_skips_descendant_align() {
|
||||
let child = rect("n20", "c", 150.0, 50.0, 20.0, 20.0);
|
||||
let f = frame("n10", "f", 0.0, 0.0, 200.0, 200.0, vec![child]);
|
||||
let sibling = rect("n30", "s", 400.0, 0.0, 100.0, 100.0);
|
||||
let mut s = state_with(vec![f, sibling]);
|
||||
s.selection.set = vec![
|
||||
NodeId::new("n10"),
|
||||
NodeId::new("n20"),
|
||||
NodeId::new("n30"),
|
||||
];
|
||||
s.selection.anchor = NodeId::new("n30");
|
||||
assert!(s.align_selected(AlignAction::Left));
|
||||
// Frame already at x=0; child cascades, so it keeps x=150.
|
||||
assert_eq!(bx(&s, "n10").x, 0.0);
|
||||
assert_eq!(bx(&s, "n20").x, 150.0);
|
||||
assert_eq!(bx(&s, "n30").x, 0.0);
|
||||
}
|
||||
}
|
||||
345
crates/op-editor-core/src/color_picker.rs
Normal file
345
crates/op-editor-core/src/color_picker.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! Colour / fill mutators + the HSV colour-picker state machine —
|
||||
//! ported from shell-core's `document/color_picker.rs` plus the
|
||||
//! fill-related parts of `document/mutators.rs`
|
||||
//! (`set_selected_color` / `set_selected_fill_type` /
|
||||
//! `add_drop_shadow_to_selected`).
|
||||
//!
|
||||
//! ## Fill model
|
||||
//!
|
||||
//! shell-core's flat `Node` had `fill: Option<Color>` — a single
|
||||
//! literal colour. The canonical `PenNode` carries
|
||||
//! `fill: Option<Vec<PenFill>>` (Solid / gradient / Image variants,
|
||||
//! hex `String` colours). These mutators only ever touch "the first
|
||||
//! solid fill's hex" — the [`crate::fills`] helpers do that read /
|
||||
//! write while preserving any gradient / image fills verbatim.
|
||||
//!
|
||||
//! ## Colour-picker history
|
||||
//!
|
||||
//! shell-core stored the pre-edit snapshot inside `ColorPickerState`.
|
||||
//! Here the snapshot lives in `ui.pending_color_history` (parallel to
|
||||
//! the pen tool's `pending_pen_history`) so `ColorPickerState` stays
|
||||
//! a plain value type. `close_color_picker` pushes that snapshot onto
|
||||
//! undo only when the colour actually changed.
|
||||
|
||||
use crate::fills::{
|
||||
first_solid_fill_hex, first_solid_stroke_hex, push_drop_shadow, set_primary_fill_hex,
|
||||
set_primary_stroke_hex,
|
||||
};
|
||||
use crate::state::EditorState;
|
||||
use crate::ui_draft::{ColorPickerDrag, ColorPickerState, ColorTarget};
|
||||
use crate::walkers::find_node_mut;
|
||||
|
||||
impl EditorState {
|
||||
// --- Fill / stroke colour ---------------------------------------
|
||||
|
||||
/// Write a `#rrggbb` hex to the anchor node's fill (`is_fill`) or
|
||||
/// stroke colour. Editable-gated. Returns true when the write
|
||||
/// landed. Mirrors shell-core's `set_selected_color`.
|
||||
pub fn set_selected_color(&mut self, is_fill: bool, hex: &str) -> bool {
|
||||
let sel = self.selection.anchor.clone();
|
||||
if !sel.is_real() || !self.is_editable(&sel) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
|
||||
return false;
|
||||
};
|
||||
if is_fill {
|
||||
set_primary_fill_hex(node, hex)
|
||||
} else {
|
||||
set_primary_stroke_hex(node, hex)
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a default drop-shadow effect to the anchor node.
|
||||
/// Editable-gated. Mirrors shell-core's
|
||||
/// `add_drop_shadow_to_selected`.
|
||||
pub fn add_drop_shadow_to_selected(&mut self) -> bool {
|
||||
let sel = self.selection.anchor.clone();
|
||||
if !sel.is_real() || !self.is_editable(&sel) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = find_node_mut(self.active_children_mut(), &sel) else {
|
||||
return false;
|
||||
};
|
||||
push_drop_shadow(node)
|
||||
}
|
||||
|
||||
// --- HSV colour picker ------------------------------------------
|
||||
|
||||
/// Open the floating colour picker on the given target. Seeds HSV
|
||||
/// from the anchor node's current fill / stroke colour. Captures
|
||||
/// a pre-edit history snapshot. Returns false when there is no
|
||||
/// editable selection to edit.
|
||||
pub fn open_color_picker(&mut self, target: ColorTarget, anchor_y: f32) -> bool {
|
||||
let sel = self.selection.anchor.clone();
|
||||
if !sel.is_real() || !self.is_editable(&sel) {
|
||||
return false;
|
||||
}
|
||||
let Some(node) = self.selected_node() else {
|
||||
return false;
|
||||
};
|
||||
let current_hex = match target {
|
||||
ColorTarget::Fill => first_solid_fill_hex(node),
|
||||
ColorTarget::Stroke => first_solid_stroke_hex(node),
|
||||
}
|
||||
.unwrap_or("#000000");
|
||||
let (h, s, v) = rgb_to_hsv(parse_hex_rgb(current_hex).unwrap_or((0.0, 0.0, 0.0)));
|
||||
self.ui.pending_color_history = Some(self.snapshot_for_history());
|
||||
self.ui.color_picker = Some(ColorPickerState {
|
||||
target,
|
||||
hue: h,
|
||||
sat: s,
|
||||
val: v,
|
||||
drag: None,
|
||||
anchor_y,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Update the picker HSV and live-apply the resulting RGB to the
|
||||
/// anchor node's target colour. Tolerates `color_picker = None`
|
||||
/// so hosts can pipe move events unconditionally.
|
||||
pub fn color_picker_set_hsv(&mut self, hue: f32, sat: f32, val: f32) -> bool {
|
||||
let Some(state) = self.ui.color_picker.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
state.hue = hue.rem_euclid(360.0);
|
||||
state.sat = sat.clamp(0.0, 1.0);
|
||||
state.val = val.clamp(0.0, 1.0);
|
||||
let target = state.target;
|
||||
let (r, g, b) = hsv_to_rgb(state.hue, state.sat, state.val);
|
||||
let hex = rgb_to_hex(r, g, b);
|
||||
self.set_selected_color(matches!(target, ColorTarget::Fill), &hex);
|
||||
true
|
||||
}
|
||||
|
||||
/// Set the active drag kind so `apply_cursor_move` can route a
|
||||
/// move event to the right control.
|
||||
pub fn color_picker_set_drag(&mut self, drag: Option<ColorPickerDrag>) {
|
||||
if let Some(state) = self.ui.color_picker.as_mut() {
|
||||
state.drag = drag;
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the picker. Pushes the pre-edit snapshot onto the undo
|
||||
/// stack when the colour actually changed; drops it otherwise.
|
||||
/// Returns true when a picker was open.
|
||||
pub fn close_color_picker(&mut self) -> bool {
|
||||
let Some(state) = self.ui.color_picker.take() else {
|
||||
return false;
|
||||
};
|
||||
let snap = self.ui.pending_color_history.take();
|
||||
let Some(snap) = snap else {
|
||||
return true;
|
||||
};
|
||||
let sel = self.selection.anchor.clone();
|
||||
let snap_children = snapshot_active_children(&snap);
|
||||
let before =
|
||||
crate::walkers::find_node(snap_children, &sel).and_then(|n| match state.target {
|
||||
ColorTarget::Fill => first_solid_fill_hex(n).map(str::to_string),
|
||||
ColorTarget::Stroke => first_solid_stroke_hex(n).map(str::to_string),
|
||||
});
|
||||
let after = self.selected_node().and_then(|n| match state.target {
|
||||
ColorTarget::Fill => first_solid_fill_hex(n).map(str::to_string),
|
||||
ColorTarget::Stroke => first_solid_stroke_hex(n).map(str::to_string),
|
||||
});
|
||||
if before != after {
|
||||
self.history_push_past(snap);
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// The active page's children inside a history snapshot — mirrors
|
||||
/// [`EditorState::active_children`] but reads from a snapshot.
|
||||
fn snapshot_active_children(snap: &crate::history::EditorSnapshot) -> &[jian_ops_schema::node::PenNode] {
|
||||
match snap.doc.pages.as_ref() {
|
||||
Some(pages) => match pages.get(snap.active_page_index) {
|
||||
Some(page) => &page.children,
|
||||
None => &[],
|
||||
},
|
||||
None => &snap.doc.children,
|
||||
}
|
||||
}
|
||||
|
||||
// --- HSV / hex helpers -----------------------------------------------
|
||||
|
||||
/// HSV → RGB, h 0..360, s/v 0..1. Each channel 0..1.
|
||||
/// Ported verbatim from shell-core's `hsv_to_rgb`.
|
||||
pub fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) {
|
||||
let h = h.rem_euclid(360.0);
|
||||
let c = v * s;
|
||||
let hh = h / 60.0;
|
||||
let x = c * (1.0 - (hh.rem_euclid(2.0) - 1.0).abs());
|
||||
let (r1, g1, b1) = match hh as u32 {
|
||||
0 => (c, x, 0.0),
|
||||
1 => (x, c, 0.0),
|
||||
2 => (0.0, c, x),
|
||||
3 => (0.0, x, c),
|
||||
4 => (x, 0.0, c),
|
||||
_ => (c, 0.0, x),
|
||||
};
|
||||
let m = v - c;
|
||||
(r1 + m, g1 + m, b1 + m)
|
||||
}
|
||||
|
||||
/// RGB (0..1) → HSV (h 0..360, s 0..1, v 0..1).
|
||||
/// Ported verbatim from shell-core's `rgb_to_hsv`.
|
||||
pub fn rgb_to_hsv(rgb: (f32, f32, f32)) -> (f32, f32, f32) {
|
||||
let (r, g, b) = rgb;
|
||||
let max = r.max(g).max(b);
|
||||
let min = r.min(g).min(b);
|
||||
let v = max;
|
||||
let delta = max - min;
|
||||
let s = if max <= 0.0 { 0.0 } else { delta / max };
|
||||
let h = if delta == 0.0 {
|
||||
0.0
|
||||
} else if max == r {
|
||||
60.0 * (((g - b) / delta) % 6.0)
|
||||
} else if max == g {
|
||||
60.0 * (((b - r) / delta) + 2.0)
|
||||
} else {
|
||||
60.0 * (((r - g) / delta) + 4.0)
|
||||
};
|
||||
let h = if h < 0.0 { h + 360.0 } else { h };
|
||||
(h, s, v)
|
||||
}
|
||||
|
||||
/// Parse `#rgb` / `#rrggbb` / `#rrggbbaa` into RGB floats (0..1).
|
||||
/// Lenient on case; requires the leading `#`.
|
||||
pub fn parse_hex_rgb(s: &str) -> Option<(f32, f32, f32)> {
|
||||
let s = s.trim().strip_prefix('#')?;
|
||||
let (r, g, b) = match s.len() {
|
||||
3 => (
|
||||
u8::from_str_radix(&s[0..1].repeat(2), 16).ok()?,
|
||||
u8::from_str_radix(&s[1..2].repeat(2), 16).ok()?,
|
||||
u8::from_str_radix(&s[2..3].repeat(2), 16).ok()?,
|
||||
),
|
||||
6 | 8 => (
|
||||
u8::from_str_radix(&s[0..2], 16).ok()?,
|
||||
u8::from_str_radix(&s[2..4], 16).ok()?,
|
||||
u8::from_str_radix(&s[4..6], 16).ok()?,
|
||||
),
|
||||
_ => return None,
|
||||
};
|
||||
Some((r as f32 / 255.0, g as f32 / 255.0, b as f32 / 255.0))
|
||||
}
|
||||
|
||||
/// Format RGB floats (0..1) as a `#rrggbb` hex string.
|
||||
pub fn rgb_to_hex(r: f32, g: f32, b: f32) -> String {
|
||||
fn ch(v: f32) -> u8 {
|
||||
(v.clamp(0.0, 1.0) * 255.0).round() as u8
|
||||
}
|
||||
format!("#{:02x}{:02x}{:02x}", ch(r), ch(g), ch(b))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::node_id::NodeId;
|
||||
use crate::test_support::{rect, state_with};
|
||||
use crate::ui_draft::ColorTarget;
|
||||
|
||||
fn doc_with_rect() -> EditorState {
|
||||
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 40.0, 30.0)]);
|
||||
s.set_single_selection(NodeId::new("n1"));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selected_color_writes_first_solid_fill() {
|
||||
let mut s = doc_with_rect();
|
||||
assert!(s.set_selected_color(true, "#ff0000"));
|
||||
let node = s.selected_node().unwrap();
|
||||
assert_eq!(crate::fills::first_solid_fill_hex(node), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selected_color_writes_stroke() {
|
||||
let mut s = doc_with_rect();
|
||||
assert!(s.set_selected_color(false, "#00ff00"));
|
||||
let node = s.selected_node().unwrap();
|
||||
assert_eq!(crate::fills::first_solid_stroke_hex(node), Some("#00ff00"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_selected_color_no_op_without_selection() {
|
||||
let mut s = state_with(vec![rect("n1", "r", 0.0, 0.0, 10.0, 10.0)]);
|
||||
assert!(!s.set_selected_color(true, "#ffffff"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_drop_shadow_appends_effect() {
|
||||
let mut s = doc_with_rect();
|
||||
assert!(s.add_drop_shadow_to_selected());
|
||||
// A second call appends a second shadow.
|
||||
assert!(s.add_drop_shadow_to_selected());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_picker_seeds_hsv_from_fill() {
|
||||
let mut s = doc_with_rect();
|
||||
s.set_selected_color(true, "#ff8800");
|
||||
assert!(s.open_color_picker(ColorTarget::Fill, 120.0));
|
||||
let state = s.ui.color_picker.as_ref().unwrap();
|
||||
// Orange #ff8800 → hue near 32°.
|
||||
assert!(state.hue > 20.0 && state.hue < 45.0, "hue {}", state.hue);
|
||||
assert!(state.sat > 0.95);
|
||||
assert!(state.val > 0.95);
|
||||
assert!(s.ui.pending_color_history.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_set_hsv_writes_through_to_node() {
|
||||
let mut s = doc_with_rect();
|
||||
assert!(s.open_color_picker(ColorTarget::Fill, 0.0));
|
||||
// Pure red: H=0 S=1 V=1.
|
||||
assert!(s.color_picker_set_hsv(0.0, 1.0, 1.0));
|
||||
let node = s.selected_node().unwrap();
|
||||
assert_eq!(crate::fills::first_solid_fill_hex(node), Some("#ff0000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_picker_pushes_history_only_on_change() {
|
||||
let mut s = doc_with_rect();
|
||||
let depth = s.history.past.len();
|
||||
assert!(s.open_color_picker(ColorTarget::Fill, 0.0));
|
||||
// No HSV change → close does not push history.
|
||||
assert!(s.close_color_picker());
|
||||
assert_eq!(s.history.past.len(), depth);
|
||||
|
||||
// Re-open + drag + close → history grows by one.
|
||||
assert!(s.open_color_picker(ColorTarget::Fill, 0.0));
|
||||
assert!(s.color_picker_set_hsv(180.0, 1.0, 1.0));
|
||||
assert!(s.close_color_picker());
|
||||
assert_eq!(s.history.past.len(), depth + 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undo_after_picker_edit_restores_color() {
|
||||
let mut s = doc_with_rect();
|
||||
s.set_selected_color(true, "#ff8800");
|
||||
assert!(s.open_color_picker(ColorTarget::Fill, 0.0));
|
||||
assert!(s.color_picker_set_hsv(0.0, 1.0, 1.0));
|
||||
assert!(s.close_color_picker());
|
||||
assert_eq!(
|
||||
crate::fills::first_solid_fill_hex(s.selected_node().unwrap()),
|
||||
Some("#ff0000")
|
||||
);
|
||||
assert!(s.undo());
|
||||
assert_eq!(
|
||||
crate::fills::first_solid_fill_hex(s.selected_node().unwrap()),
|
||||
Some("#ff8800")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hsv_roundtrip_is_stable() {
|
||||
for &hex in &["#ff0000", "#00ff00", "#0000ff", "#808080", "#ff8800"] {
|
||||
let rgb = parse_hex_rgb(hex).unwrap();
|
||||
let (h, s, v) = rgb_to_hsv(rgb);
|
||||
let (r, g, b) = hsv_to_rgb(h, s, v);
|
||||
assert_eq!(rgb_to_hex(r, g, b), hex, "roundtrip {hex}");
|
||||
}
|
||||
}
|
||||
}
|
||||
209
crates/op-editor-core/src/fills.rs
Normal file
209
crates/op-editor-core/src/fills.rs
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
//! Fill / stroke / effect read-write helpers for `PenNode`.
|
||||
//!
|
||||
//! shell-core's flat `Node` carried `fill: Option<Color>` and a
|
||||
//! `stroke: Option<Stroke>` — a single literal colour per channel.
|
||||
//! The canonical `PenNode` is richer: every paintable variant carries
|
||||
//! `fill: Option<Vec<PenFill>>` (where each `PenFill` is a tagged
|
||||
//! `Solid` / gradient / `Image` body with hex `String` colours), and
|
||||
//! the stroke colour lives inside `PenStroke::fill` as its own
|
||||
//! `Vec<PenFill>`.
|
||||
//!
|
||||
//! The colour-picker + property-panel mutators only ever care about
|
||||
//! "the node's primary solid colour" — a single hex. This module is
|
||||
//! the shim that reads / writes exactly that:
|
||||
//!
|
||||
//! - [`first_solid_fill_hex`] — read the first `Solid` fill's hex.
|
||||
//! - [`set_primary_fill_hex`] — replace the first `Solid` fill (or
|
||||
//! prepend one) with a new hex, keeping any non-solid fills.
|
||||
//! - the stroke parallel ([`first_solid_stroke_hex`] /
|
||||
//! [`set_primary_stroke_hex`]).
|
||||
//! - [`push_drop_shadow`] — append a default drop-shadow effect.
|
||||
//!
|
||||
//! Gradient / image fills are preserved verbatim — a hex write only
|
||||
//! ever touches the *first solid* entry, mirroring shell-core's
|
||||
//! single-colour behaviour without flattening the canonical model.
|
||||
|
||||
use jian_ops_schema::node::PenNode;
|
||||
use jian_ops_schema::style::{
|
||||
PenEffect, PenFill, PenStroke, ShadowBody, SolidFillBody, StrokeThickness,
|
||||
};
|
||||
|
||||
/// Borrow a node's `fill` list, if the variant carries one. Frame /
|
||||
/// Group fills live on `container.fill`; the leaf paintable variants
|
||||
/// (Rectangle / Ellipse / Polygon / Path / Text / TextInput /
|
||||
/// IconFont) carry their own `fill`. Line / Image / Ref have none.
|
||||
pub fn node_fills(node: &PenNode) -> Option<&Vec<PenFill>> {
|
||||
match node {
|
||||
PenNode::Frame(n) => n.container.fill.as_ref(),
|
||||
PenNode::Group(n) => n.container.fill.as_ref(),
|
||||
PenNode::Rectangle(n) => n.container.fill.as_ref(),
|
||||
PenNode::Ellipse(n) => n.fill.as_ref(),
|
||||
PenNode::Polygon(n) => n.fill.as_ref(),
|
||||
PenNode::Path(n) => n.fill.as_ref(),
|
||||
PenNode::Text(n) => n.fill.as_ref(),
|
||||
PenNode::TextInput(n) => n.fill.as_ref(),
|
||||
PenNode::IconFont(n) => n.fill.as_ref(),
|
||||
PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutably borrow a node's `fill` list, creating an empty one when
|
||||
/// the variant supports fills but has none yet. `None` for the
|
||||
/// variants that have no `fill` field at all.
|
||||
pub fn node_fills_mut(node: &mut PenNode) -> Option<&mut Vec<PenFill>> {
|
||||
match node {
|
||||
PenNode::Frame(n) => Some(n.container.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Group(n) => Some(n.container.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Rectangle(n) => Some(n.container.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Ellipse(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Polygon(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Path(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Text(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::TextInput(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::IconFont(n) => Some(n.fill.get_or_insert_with(Vec::new)),
|
||||
PenNode::Line(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Borrow a node's `stroke`, if the variant carries one.
|
||||
fn node_stroke_mut(node: &mut PenNode) -> Option<&mut Option<PenStroke>> {
|
||||
match node {
|
||||
PenNode::Frame(n) => Some(&mut n.container.stroke),
|
||||
PenNode::Group(n) => Some(&mut n.container.stroke),
|
||||
PenNode::Rectangle(n) => Some(&mut n.container.stroke),
|
||||
PenNode::Ellipse(n) => Some(&mut n.stroke),
|
||||
PenNode::Polygon(n) => Some(&mut n.stroke),
|
||||
PenNode::Path(n) => Some(&mut n.stroke),
|
||||
PenNode::Line(n) => Some(&mut n.stroke),
|
||||
PenNode::TextInput(n) => Some(&mut n.stroke),
|
||||
PenNode::IconFont(n) => Some(&mut n.stroke),
|
||||
PenNode::Text(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared stroke accessor for reads.
|
||||
fn node_stroke(node: &PenNode) -> Option<&PenStroke> {
|
||||
match node {
|
||||
PenNode::Frame(n) => n.container.stroke.as_ref(),
|
||||
PenNode::Group(n) => n.container.stroke.as_ref(),
|
||||
PenNode::Rectangle(n) => n.container.stroke.as_ref(),
|
||||
PenNode::Ellipse(n) => n.stroke.as_ref(),
|
||||
PenNode::Polygon(n) => n.stroke.as_ref(),
|
||||
PenNode::Path(n) => n.stroke.as_ref(),
|
||||
PenNode::Line(n) => n.stroke.as_ref(),
|
||||
PenNode::TextInput(n) => n.stroke.as_ref(),
|
||||
PenNode::IconFont(n) => n.stroke.as_ref(),
|
||||
PenNode::Text(_) | PenNode::Image(_) | PenNode::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutably borrow a node's `effects` list, creating an empty one
|
||||
/// when the variant supports effects but has none yet.
|
||||
fn node_effects_mut(node: &mut PenNode) -> Option<&mut Vec<PenEffect>> {
|
||||
match node {
|
||||
PenNode::Frame(n) => Some(n.container.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Group(n) => Some(n.container.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Rectangle(n) => Some(n.container.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Ellipse(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Polygon(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Path(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Line(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Text(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::TextInput(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::Image(n) => Some(n.effects.get_or_insert_with(Vec::new)),
|
||||
PenNode::IconFont(_) | PenNode::Ref(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// First `Solid` fill's hex string, when the node has one.
|
||||
pub fn first_solid_fill_hex(node: &PenNode) -> Option<&str> {
|
||||
let fills = node_fills(node)?;
|
||||
fills.iter().find_map(|f| match f {
|
||||
PenFill::Solid(body) => Some(body.color.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// First `Solid` fill's hex string on the node's stroke.
|
||||
pub fn first_solid_stroke_hex(node: &PenNode) -> Option<&str> {
|
||||
let stroke = node_stroke(node)?;
|
||||
stroke.fill.as_ref()?.iter().find_map(|f| match f {
|
||||
PenFill::Solid(body) => Some(body.color.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a bare `Solid` fill from a hex string.
|
||||
fn solid_fill(hex: String) -> PenFill {
|
||||
PenFill::Solid(SolidFillBody {
|
||||
color: hex,
|
||||
explain: None,
|
||||
opacity: None,
|
||||
blend_mode: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the first `Solid` fill's colour with `hex`, leaving any
|
||||
/// gradient / image fills untouched. When the node has no solid fill,
|
||||
/// a fresh one is prepended so it paints on top. `false` when the
|
||||
/// variant carries no `fill` field at all.
|
||||
pub fn set_primary_fill_hex(node: &mut PenNode, hex: &str) -> bool {
|
||||
let Some(fills) = node_fills_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(slot) = fills.iter_mut().find_map(|f| match f {
|
||||
PenFill::Solid(body) => Some(body),
|
||||
_ => None,
|
||||
}) {
|
||||
slot.color = hex.to_string();
|
||||
} else {
|
||||
fills.insert(0, solid_fill(hex.to_string()));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Stroke parallel to [`set_primary_fill_hex`]. Creates a default
|
||||
/// 1-px stroke when the node has none, so a colour write always
|
||||
/// lands a visible stroke. `false` for variants without a stroke.
|
||||
pub fn set_primary_stroke_hex(node: &mut PenNode, hex: &str) -> bool {
|
||||
let Some(slot) = node_stroke_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
let stroke = slot.get_or_insert_with(|| PenStroke {
|
||||
thickness: StrokeThickness::Uniform(1.0),
|
||||
align: None,
|
||||
join: None,
|
||||
cap: None,
|
||||
dash_pattern: None,
|
||||
dash_offset: None,
|
||||
fill: None,
|
||||
});
|
||||
let fills = stroke.fill.get_or_insert_with(Vec::new);
|
||||
if let Some(body) = fills.iter_mut().find_map(|f| match f {
|
||||
PenFill::Solid(body) => Some(body),
|
||||
_ => None,
|
||||
}) {
|
||||
body.color = hex.to_string();
|
||||
} else {
|
||||
fills.insert(0, solid_fill(hex.to_string()));
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Append a default drop-shadow effect — mirrors a common CSS card
|
||||
/// shadow (`0 4px 8px rgba(0,0,0,0.25)`). `false` for variants that
|
||||
/// carry no `effects` field.
|
||||
pub fn push_drop_shadow(node: &mut PenNode) -> bool {
|
||||
let Some(effects) = node_effects_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
effects.push(PenEffect::Shadow(ShadowBody {
|
||||
inner: None,
|
||||
offset_x: 0.0,
|
||||
offset_y: 4.0,
|
||||
blur: 8.0,
|
||||
spread: 0.0,
|
||||
color: "#00000040".to_string(),
|
||||
}));
|
||||
true
|
||||
}
|
||||
|
|
@ -7,7 +7,10 @@
|
|||
//! editor-only state (selection, tool, viewport, history, transient
|
||||
//! UI drafts).
|
||||
|
||||
pub mod align;
|
||||
pub mod clipboard;
|
||||
pub mod color_picker;
|
||||
pub mod fills;
|
||||
pub mod geometry;
|
||||
pub mod grouping;
|
||||
pub mod history;
|
||||
|
|
@ -17,10 +20,12 @@ pub mod page_mutators;
|
|||
pub mod pen;
|
||||
pub mod pen_node_ext;
|
||||
pub mod render_backend;
|
||||
pub mod rename;
|
||||
pub mod selection;
|
||||
pub mod state;
|
||||
pub mod tool;
|
||||
pub mod ui_draft;
|
||||
pub mod variables;
|
||||
pub mod viewport;
|
||||
pub mod walkers;
|
||||
|
||||
|
|
@ -33,6 +38,9 @@ mod tests_mutators;
|
|||
#[cfg(test)]
|
||||
mod tests_pages;
|
||||
|
||||
pub use align::AlignAction;
|
||||
pub use color_picker::{hsv_to_rgb, parse_hex_rgb, rgb_to_hex, rgb_to_hsv};
|
||||
pub use fills::{first_solid_fill_hex, first_solid_stroke_hex};
|
||||
pub use geometry::{aggregate_bounds, own_bounds, union_aggregate_bounds, DocRect};
|
||||
pub use history::{EditorSnapshot, History, HISTORY_CAP};
|
||||
pub use node_id::NodeId;
|
||||
|
|
@ -42,7 +50,8 @@ pub use selection::SelectionState;
|
|||
pub use state::EditorState;
|
||||
pub use tool::Tool;
|
||||
pub use ui_draft::{
|
||||
ColorTarget, LayerContextTarget, LayerRenameState, PropertyFocus, UiDraftState, VariableUiState,
|
||||
ColorPickerDrag, ColorPickerState, ColorTarget, LayerContextTarget, LayerRenameState,
|
||||
PropertyFocus, UiDraftState, VariableUiState,
|
||||
};
|
||||
pub use viewport::Viewport;
|
||||
pub use walkers::ReorderDirection;
|
||||
|
|
|
|||
381
crates/op-editor-core/src/rename.rs
Normal file
381
crates/op-editor-core/src/rename.rs
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
//! Inline rename + inline text-edit session state machines —
|
||||
//! ported from shell-core's `document/page_mutators.rs`
|
||||
//! (`start_rename_*` / `rename_append` / `rename_backspace` /
|
||||
//! `rename_commit` / `rename_cancel`) and the text-edit session
|
||||
//! (`start_text_edit` / `text_edit_append` / `text_edit_backspace`
|
||||
//! / `text_edit_commit`).
|
||||
//!
|
||||
//! ## Rename
|
||||
//!
|
||||
//! An inline rename collects keystrokes into
|
||||
//! `ui.layer_rename.draft`. `rename_commit` writes the draft into
|
||||
//! the node's `name` (or the page's `name`) and pushes a single
|
||||
//! history entry — but only when the name actually changed, so a
|
||||
//! no-op commit doesn't pollute the undo stack.
|
||||
//!
|
||||
//! ## Text edit
|
||||
//!
|
||||
//! An inline text-edit session mutates a `Text` node's content in
|
||||
//! place. To keep a typing burst as one undoable step, history opens
|
||||
//! lazily: the first keystroke (or any keystroke after a > 500 ms
|
||||
//! pause) snapshots the pre-edit state and pushes it. The snapshot +
|
||||
//! the last-keystroke timestamp live on `ui.pending_text_edit_history`
|
||||
//! / `ui.text_edit_last_ms`.
|
||||
|
||||
use crate::node_id::NodeId;
|
||||
use crate::pen_node_ext::PenNodeExt;
|
||||
use crate::state::EditorState;
|
||||
use crate::ui_draft::{LayerContextTarget, LayerRenameState};
|
||||
use crate::walkers::find_node_mut;
|
||||
use jian_ops_schema::node::{PenNode, TextContent};
|
||||
|
||||
/// Coalesce window for text-edit history bursts (ms). A pause longer
|
||||
/// than this opens a fresh undo entry.
|
||||
const TEXT_COALESCE_MS: u64 = 500;
|
||||
|
||||
impl EditorState {
|
||||
// --- Inline rename ----------------------------------------------
|
||||
|
||||
/// Start an inline rename on a layer row. Seeds the draft with the
|
||||
/// node's current name. `false` when the node doesn't exist.
|
||||
pub fn start_rename_layer(&mut self, id: NodeId) -> bool {
|
||||
let Some(node) = crate::walkers::find_node(self.active_children(), &id) else {
|
||||
return false;
|
||||
};
|
||||
let draft = node.base().name.clone().unwrap_or_default();
|
||||
self.ui.layer_rename = Some(LayerRenameState {
|
||||
target: LayerContextTarget::Layer(id),
|
||||
draft,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Start an inline rename on a page row. Seeds the draft with the
|
||||
/// page's current name. `false` for an out-of-range index or a
|
||||
/// single-page document (the implicit page has no name field).
|
||||
pub fn start_rename_page(&mut self, idx: usize) -> bool {
|
||||
let Some(pages) = self.doc.pages.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(page) = pages.get(idx) else {
|
||||
return false;
|
||||
};
|
||||
self.ui.layer_rename = Some(LayerRenameState {
|
||||
target: LayerContextTarget::Page(idx),
|
||||
draft: page.name.clone(),
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// Append text to the in-flight rename draft. `false` when no
|
||||
/// rename is active.
|
||||
pub fn rename_append(&mut self, text: &str) -> bool {
|
||||
match self.ui.layer_rename.as_mut() {
|
||||
Some(state) => {
|
||||
state.draft.push_str(text);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pop the last char of the rename draft. `false` when no rename
|
||||
/// is active.
|
||||
pub fn rename_backspace(&mut self) -> bool {
|
||||
match self.ui.layer_rename.as_mut() {
|
||||
Some(state) => {
|
||||
state.draft.pop();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Commit the active rename. Returns true when a rename was in
|
||||
/// flight (UI changed → repaint). Pushes a history snapshot only
|
||||
/// when the name actually changed — an empty draft or a no-op
|
||||
/// commit leaves the undo stack untouched.
|
||||
pub fn rename_commit(&mut self) -> bool {
|
||||
let Some(state) = self.ui.layer_rename.take() else {
|
||||
return false;
|
||||
};
|
||||
if state.draft.trim().is_empty() {
|
||||
return true;
|
||||
}
|
||||
let snap = self.snapshot_for_history();
|
||||
let mut changed = false;
|
||||
match state.target {
|
||||
LayerContextTarget::Page(idx) => {
|
||||
if let Some(pages) = self.doc.pages.as_mut() {
|
||||
if let Some(page) = pages.get_mut(idx) {
|
||||
if page.name != state.draft {
|
||||
page.name = state.draft;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
LayerContextTarget::Layer(id) => {
|
||||
if let Some(node) = find_node_mut(self.active_children_mut(), &id) {
|
||||
let base = node.base_mut();
|
||||
if base.name.as_deref() != Some(state.draft.as_str()) {
|
||||
base.name = Some(state.draft);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
self.history_push_past(snap);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Cancel an in-flight rename. `true` when one was active.
|
||||
pub fn rename_cancel(&mut self) -> bool {
|
||||
self.ui.layer_rename.take().is_some()
|
||||
}
|
||||
|
||||
// --- Inline text edit -------------------------------------------
|
||||
|
||||
/// Enter inline text-edit mode on `id`. `false` when the node
|
||||
/// isn't a `Text` node or doesn't exist. History opens lazily on
|
||||
/// the first keystroke.
|
||||
pub fn start_text_edit(&mut self, id: NodeId) -> bool {
|
||||
let Some(node) = crate::walkers::find_node(self.active_children(), &id) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(node, PenNode::Text(_)) {
|
||||
return false;
|
||||
}
|
||||
self.ui.text_editing = Some(id);
|
||||
self.ui.text_edit_last_ms = 0;
|
||||
self.ui.pending_text_edit_history = None;
|
||||
true
|
||||
}
|
||||
|
||||
/// Append `text` to the actively-edited Text node's content.
|
||||
/// `now_ms` lets a typing burst coalesce into one undo step: a
|
||||
/// pause > 500 ms opens a fresh history entry. `false` when no
|
||||
/// text-edit session is active or the node has vanished.
|
||||
pub fn text_edit_append(&mut self, text: &str, now_ms: u64) -> bool {
|
||||
let Some(id) = self.ui.text_editing.clone() else {
|
||||
return false;
|
||||
};
|
||||
self.maybe_open_text_edit_history(now_ms);
|
||||
let Some(node) = find_node_mut(self.active_children_mut(), &id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(content) = text_content_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
content.push_str(text);
|
||||
self.ui.text_edit_last_ms = now_ms;
|
||||
true
|
||||
}
|
||||
|
||||
/// Pop the last char from the actively-edited Text node. `false`
|
||||
/// when no session is active, the node has vanished, or the
|
||||
/// content was already empty.
|
||||
pub fn text_edit_backspace(&mut self, now_ms: u64) -> bool {
|
||||
let Some(id) = self.ui.text_editing.clone() else {
|
||||
return false;
|
||||
};
|
||||
self.maybe_open_text_edit_history(now_ms);
|
||||
let Some(node) = find_node_mut(self.active_children_mut(), &id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(content) = text_content_mut(node) else {
|
||||
return false;
|
||||
};
|
||||
if content.pop().is_some() {
|
||||
self.ui.text_edit_last_ms = now_ms;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Exit text-edit mode. History snapshots were pushed lazily by
|
||||
/// `text_edit_append` / `text_edit_backspace`, so commit only
|
||||
/// clears the session state. `true` when a session was active.
|
||||
pub fn text_edit_commit(&mut self) -> bool {
|
||||
self.ui.pending_text_edit_history = None;
|
||||
self.ui.text_edit_last_ms = 0;
|
||||
self.ui.text_editing.take().is_some()
|
||||
}
|
||||
|
||||
/// Open a fresh history burst when the first keystroke since the
|
||||
/// session started, or > 500 ms since the last one. Snapshots the
|
||||
/// pre-mutation state and pushes it onto the undo stack.
|
||||
fn maybe_open_text_edit_history(&mut self, now_ms: u64) {
|
||||
let last = self.ui.text_edit_last_ms;
|
||||
let elapsed = now_ms.saturating_sub(last);
|
||||
if last == 0 || elapsed > TEXT_COALESCE_MS {
|
||||
let snap = self.snapshot_for_history();
|
||||
self.ui.pending_text_edit_history = None;
|
||||
self.history_push_past(snap);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Free helpers ----------------------------------------------------
|
||||
|
||||
/// Mutable handle to a `Text` node's plain content string. Returns
|
||||
/// `None` for a non-Text node or a `Styled` content variant (the
|
||||
/// inline editor only handles plain text).
|
||||
fn text_content_mut(node: &mut PenNode) -> Option<&mut String> {
|
||||
match node {
|
||||
PenNode::Text(t) => match &mut t.content {
|
||||
TextContent::Plain(s) => Some(s),
|
||||
TextContent::Styled(_) => None,
|
||||
},
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{rect, state_with, text};
|
||||
|
||||
fn doc() -> EditorState {
|
||||
state_with(vec![
|
||||
rect("n1", "Rectangle", 0.0, 0.0, 40.0, 30.0),
|
||||
text("n2", "Label", 0.0, 50.0, 100.0, 20.0, "Hi"),
|
||||
])
|
||||
}
|
||||
|
||||
// --- Rename -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn rename_layer_round_trip() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_rename_layer(NodeId::new("n1")));
|
||||
assert_eq!(s.ui.layer_rename.as_ref().unwrap().draft, "Rectangle");
|
||||
assert!(s.rename_backspace());
|
||||
assert!(s.rename_append("X"));
|
||||
assert!(s.rename_commit());
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n1")).unwrap();
|
||||
assert_eq!(node.base().name.as_deref(), Some("RectanglX"));
|
||||
// The name changed → exactly one history entry.
|
||||
assert_eq!(s.history.past.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_unknown_layer_is_no_op() {
|
||||
let mut s = doc();
|
||||
assert!(!s.start_rename_layer(NodeId::new("missing")));
|
||||
assert!(!s.rename_append("x"));
|
||||
assert!(!s.rename_backspace());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_empty_draft_pushes_no_history() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_rename_layer(NodeId::new("n1")));
|
||||
// Clear the whole draft.
|
||||
for _ in 0..20 {
|
||||
s.rename_backspace();
|
||||
}
|
||||
assert!(s.rename_commit());
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
// Name unchanged.
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n1")).unwrap();
|
||||
assert_eq!(node.base().name.as_deref(), Some("Rectangle"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_unchanged_name_pushes_no_history() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_rename_layer(NodeId::new("n1")));
|
||||
// Commit with the seeded draft untouched.
|
||||
assert!(s.rename_commit());
|
||||
assert_eq!(s.history.past.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_cancel_discards_draft() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_rename_layer(NodeId::new("n1")));
|
||||
s.rename_append("zzz");
|
||||
assert!(s.rename_cancel());
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n1")).unwrap();
|
||||
assert_eq!(node.base().name.as_deref(), Some("Rectangle"));
|
||||
assert!(!s.rename_cancel());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_page_round_trip() {
|
||||
let mut s = doc();
|
||||
s.add_page();
|
||||
assert!(s.start_rename_page(0));
|
||||
assert!(s.rename_append(" Renamed"));
|
||||
assert!(s.rename_commit());
|
||||
assert_eq!(s.doc.pages.as_ref().unwrap()[0].name, "Page 1 Renamed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_page_single_page_doc_rejected() {
|
||||
let mut s = doc();
|
||||
// Single-page document has no `pages` list.
|
||||
assert!(!s.start_rename_page(0));
|
||||
}
|
||||
|
||||
// --- Text edit --------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn text_edit_appends_and_backspaces() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_text_edit(NodeId::new("n2")));
|
||||
assert!(s.text_edit_append("!", 100));
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n2")).unwrap();
|
||||
match node {
|
||||
PenNode::Text(t) => assert_eq!(t.content, TextContent::Plain("Hi!".to_string())),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
assert!(s.text_edit_backspace(150));
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n2")).unwrap();
|
||||
match node {
|
||||
PenNode::Text(t) => assert_eq!(t.content, TextContent::Plain("Hi".to_string())),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
assert!(s.text_edit_commit());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_rejects_non_text_node() {
|
||||
let mut s = doc();
|
||||
assert!(!s.start_text_edit(NodeId::new("n1")));
|
||||
assert!(!s.text_edit_append("x", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_history_coalesces_within_window() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_text_edit(NodeId::new("n2")));
|
||||
// First keystroke opens one history entry.
|
||||
s.text_edit_append("a", 100);
|
||||
assert_eq!(s.history.past.len(), 1);
|
||||
// Within 500 ms — no new entry.
|
||||
s.text_edit_append("b", 300);
|
||||
assert_eq!(s.history.past.len(), 1);
|
||||
// After a > 500 ms pause — a fresh entry.
|
||||
s.text_edit_append("c", 1000);
|
||||
assert_eq!(s.history.past.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_edit_undo_restores_pre_session_text() {
|
||||
let mut s = doc();
|
||||
assert!(s.start_text_edit(NodeId::new("n2")));
|
||||
s.text_edit_append("!!!", 100);
|
||||
assert!(s.text_edit_commit());
|
||||
assert!(s.undo());
|
||||
let node = crate::walkers::find_node(s.active_children(), &NodeId::new("n2")).unwrap();
|
||||
match node {
|
||||
PenNode::Text(t) => assert_eq!(t.content, TextContent::Plain("Hi".to_string())),
|
||||
_ => panic!("not text"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +61,43 @@ pub struct LayerRenameState {
|
|||
pub draft: String,
|
||||
}
|
||||
|
||||
/// Which control of the HSV colour picker a drag is currently
|
||||
/// driving. Ported from shell-core's `ColorPickerDrag`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ColorPickerDrag {
|
||||
/// The 2-D saturation / value box.
|
||||
SvBox,
|
||||
/// The 1-D hue strip.
|
||||
HueSlider,
|
||||
}
|
||||
|
||||
/// Floating HSV colour-picker state. The picker keeps `(hue, sat,
|
||||
/// val)` as the source of truth so dragging a control doesn't
|
||||
/// visibly snap when round-trip RGB rounding pulls a slightly
|
||||
/// different hex out of the same HSV.
|
||||
///
|
||||
/// shell-core's `ColorPickerState` carried a `pre_snap:
|
||||
/// Option<DocumentSnapshot>`; here the pre-edit `EditorSnapshot` is
|
||||
/// held in [`UiDraftState::pending_color_history`] instead — parallel
|
||||
/// to how the pen tool stashes `pending_pen_history` — so the picker
|
||||
/// state stays a plain value type.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ColorPickerState {
|
||||
/// Whether the picker edits the node's fill or stroke colour.
|
||||
pub target: ColorTarget,
|
||||
/// Hue in degrees, 0..360.
|
||||
pub hue: f32,
|
||||
/// Saturation, 0..1.
|
||||
pub sat: f32,
|
||||
/// Value (brightness), 0..1.
|
||||
pub val: f32,
|
||||
/// The control currently being dragged; `None` while idle.
|
||||
pub drag: Option<ColorPickerDrag>,
|
||||
/// Viewport-y of the click that opened the picker — anchors the
|
||||
/// floating panel.
|
||||
pub anchor_y: f32,
|
||||
}
|
||||
|
||||
/// Transient variable/theme editor state (spec §5.2).
|
||||
///
|
||||
/// shell-core's `VariableTable` mixed *persisted* data (`variables`,
|
||||
|
|
@ -112,6 +149,18 @@ pub struct UiDraftState {
|
|||
/// mutates the tree, pushed onto the undo stack only when the
|
||||
/// finished path has ≥ 2 anchors. Dropped on a 1-anchor cancel.
|
||||
pub pending_pen_history: Option<crate::history::EditorSnapshot>,
|
||||
/// Active HSV colour picker overlay; `None` when closed.
|
||||
pub color_picker: Option<ColorPickerState>,
|
||||
/// Pre-edit snapshot captured when the colour picker opens;
|
||||
/// pushed onto undo on close only when the colour changed.
|
||||
pub pending_color_history: Option<crate::history::EditorSnapshot>,
|
||||
/// Pre-edit snapshot for the inline text-edit session; opened
|
||||
/// lazily on the first keystroke, dropped if the text is
|
||||
/// unchanged at commit. See `rename.rs`.
|
||||
pub pending_text_edit_history: Option<crate::history::EditorSnapshot>,
|
||||
/// Timestamp (ms) of the last text-edit keystroke — drives the
|
||||
/// 500 ms history-coalescing window.
|
||||
pub text_edit_last_ms: u64,
|
||||
/// Transient variable/theme state (active theme + ref caches).
|
||||
pub variables: VariableUiState,
|
||||
}
|
||||
|
|
|
|||
478
crates/op-editor-core/src/variables.rs
Normal file
478
crates/op-editor-core/src/variables.rs
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
//! Variables / themes mutators — ported from shell-core's
|
||||
//! `document/variables.rs` (`VariableTable`), retargeted onto the
|
||||
//! canonical document model.
|
||||
//!
|
||||
//! ## Model split (spec §5.2)
|
||||
//!
|
||||
//! shell-core's `VariableTable` mixed *persisted* data with
|
||||
//! *transient* editor state. Here that split is explicit:
|
||||
//!
|
||||
//! - **Persisted** — `EditorState.doc.variables`
|
||||
//! (`Option<BTreeMap<String, VariableDefinition>>`) +
|
||||
//! `EditorState.doc.themes` (`Option<BTreeMap<String,
|
||||
//! Vec<String>>>`, axis-name → ordered value list). They
|
||||
//! serialize with the `.op` file.
|
||||
//! - **Transient** — the active-theme selection lives on
|
||||
//! `EditorState.ui.variables.active_theme`. Rebuilt on load,
|
||||
//! never serialized.
|
||||
//!
|
||||
//! Theme-routing discipline for themed values is ported verbatim:
|
||||
//! a write targets the subset-matching entry, else the `theme: None`
|
||||
//! default when no theme axis is active, else a fresh entry keyed to
|
||||
//! the active theme appended at the END of the vec (front insertion
|
||||
//! would shadow pre-existing entries on other axes).
|
||||
|
||||
use crate::state::EditorState;
|
||||
use jian_ops_schema::variable::{
|
||||
ThemedValue, VariableDefinition, VariableKind, VariableScalar, VariableValue,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
impl EditorState {
|
||||
// --- Read helpers -----------------------------------------------
|
||||
|
||||
/// Mutable handle to `doc.variables`, creating the map on first
|
||||
/// use so the mutators never have to special-case `None`.
|
||||
fn variables_mut(&mut self) -> &mut BTreeMap<String, VariableDefinition> {
|
||||
self.doc.variables.get_or_insert_with(BTreeMap::new)
|
||||
}
|
||||
|
||||
/// Look up a variable definition by name.
|
||||
pub fn find_variable(&self, name: &str) -> Option<&VariableDefinition> {
|
||||
self.doc.variables.as_ref()?.get(name)
|
||||
}
|
||||
|
||||
/// Resolve a variable's current scalar under the active theme.
|
||||
/// `None` for an unknown name or an empty themed list.
|
||||
pub fn resolve_variable(&self, name: &str) -> Option<&VariableScalar> {
|
||||
let def = self.find_variable(name)?;
|
||||
resolve_value(&def.value, &self.ui.variables.active_theme)
|
||||
}
|
||||
|
||||
// --- Scalar writes ----------------------------------------------
|
||||
|
||||
/// Write a `#rgb` / `#rrggbb` / `#rrggbbaa` hex into a `Color`
|
||||
/// variable. `false` when the variable is unknown, not
|
||||
/// Color-kind, or the hex doesn't parse. Themed variables route
|
||||
/// per the active-theme discipline.
|
||||
pub fn set_variable_color(&mut self, name: &str, hex: &str) -> bool {
|
||||
if crate::color_picker::parse_hex_rgb(hex).is_none() {
|
||||
return false;
|
||||
}
|
||||
let active = self.ui.variables.active_theme.clone();
|
||||
let Some(def) = self.variables_mut().get_mut(name) else {
|
||||
return false;
|
||||
};
|
||||
if !matches!(def.kind, VariableKind::Color) {
|
||||
return false;
|
||||
}
|
||||
write_scalar(&mut def.value, VariableScalar::Str(hex.trim().to_string()), &active);
|
||||
true
|
||||
}
|
||||
|
||||
/// Write a number into a `Number` variable. Kind-mismatch → false.
|
||||
pub fn set_variable_number(&mut self, name: &str, value: f64) -> bool {
|
||||
self.set_variable_scalar(name, VariableKind::Number, VariableScalar::Num(value))
|
||||
}
|
||||
|
||||
/// Write a string into a `String` variable. Kind-mismatch → false.
|
||||
pub fn set_variable_string(&mut self, name: &str, value: impl Into<String>) -> bool {
|
||||
self.set_variable_scalar(
|
||||
name,
|
||||
VariableKind::String,
|
||||
VariableScalar::Str(value.into()),
|
||||
)
|
||||
}
|
||||
|
||||
/// Write a boolean into a `Boolean` variable. Kind-mismatch → false.
|
||||
pub fn set_variable_boolean(&mut self, name: &str, value: bool) -> bool {
|
||||
self.set_variable_scalar(name, VariableKind::Boolean, VariableScalar::Bool(value))
|
||||
}
|
||||
|
||||
/// Shared kind-checked scalar writer for number / string /
|
||||
/// boolean. Color variables are FORBIDDEN here — they need hex
|
||||
/// validation, which only `set_variable_color` provides.
|
||||
fn set_variable_scalar(
|
||||
&mut self,
|
||||
name: &str,
|
||||
expect: VariableKind,
|
||||
scalar: VariableScalar,
|
||||
) -> bool {
|
||||
let active = self.ui.variables.active_theme.clone();
|
||||
let Some(def) = self.variables_mut().get_mut(name) else {
|
||||
return false;
|
||||
};
|
||||
if def.kind != expect {
|
||||
return false;
|
||||
}
|
||||
write_scalar(&mut def.value, scalar, &active);
|
||||
true
|
||||
}
|
||||
|
||||
// --- Variable lifecycle -----------------------------------------
|
||||
|
||||
/// Create a new theme-agnostic scalar variable. Rejects an empty
|
||||
/// (post-trim) name, a name that collides with an existing
|
||||
/// variable, or a `default` that doesn't match `kind`. Color
|
||||
/// defaults must be a parseable hex string.
|
||||
pub fn create_variable(
|
||||
&mut self,
|
||||
name: &str,
|
||||
kind: VariableKind,
|
||||
default: VariableScalar,
|
||||
) -> bool {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let kind_ok = match (&kind, &default) {
|
||||
(VariableKind::Color, VariableScalar::Str(s)) => {
|
||||
crate::color_picker::parse_hex_rgb(s).is_some()
|
||||
}
|
||||
(VariableKind::Number, VariableScalar::Num(_)) => true,
|
||||
(VariableKind::String, VariableScalar::Str(_)) => true,
|
||||
(VariableKind::Boolean, VariableScalar::Bool(_)) => true,
|
||||
_ => false,
|
||||
};
|
||||
if !kind_ok {
|
||||
return false;
|
||||
}
|
||||
let vars = self.variables_mut();
|
||||
if vars.contains_key(trimmed) {
|
||||
return false;
|
||||
}
|
||||
vars.insert(
|
||||
trimmed.to_string(),
|
||||
VariableDefinition {
|
||||
kind,
|
||||
value: VariableValue::Scalar(default),
|
||||
},
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Delete a variable by name. `false` when the name is unknown.
|
||||
pub fn delete_variable(&mut self, name: &str) -> bool {
|
||||
let Some(vars) = self.doc.variables.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if vars.remove(name).is_none() {
|
||||
return false;
|
||||
}
|
||||
// Drop any ref caches pointing at the now-deleted variable.
|
||||
self.ui.variables.fill_refs.retain(|_, v| v != name);
|
||||
self.ui.variables.stroke_refs.retain(|_, v| v != name);
|
||||
true
|
||||
}
|
||||
|
||||
/// Rename a variable. Rejects an unknown `old`, an empty (post-
|
||||
/// trim) `new`, or a `new` that collides with a different
|
||||
/// existing variable. `old == new` (after trim) is a no-op
|
||||
/// success. Rewrites every `fill_refs` / `stroke_refs` entry.
|
||||
pub fn rename_variable(&mut self, old: &str, new: &str) -> bool {
|
||||
let new_trimmed = new.trim();
|
||||
if new_trimmed.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let Some(vars) = self.doc.variables.as_mut() else {
|
||||
return false;
|
||||
};
|
||||
if old == new_trimmed {
|
||||
return vars.contains_key(old);
|
||||
}
|
||||
if vars.contains_key(new_trimmed) {
|
||||
return false;
|
||||
}
|
||||
let Some(def) = vars.remove(old) else {
|
||||
return false;
|
||||
};
|
||||
vars.insert(new_trimmed.to_string(), def);
|
||||
for v in self.ui.variables.fill_refs.values_mut() {
|
||||
if v == old {
|
||||
*v = new_trimmed.to_string();
|
||||
}
|
||||
}
|
||||
for v in self.ui.variables.stroke_refs.values_mut() {
|
||||
if v == old {
|
||||
*v = new_trimmed.to_string();
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// --- Theme axis -------------------------------------------------
|
||||
|
||||
/// Set the active value of a theme axis (e.g. `("mode", "dark")`).
|
||||
/// `false` when the axis isn't declared in `doc.themes` or the
|
||||
/// value isn't one of its declared entries.
|
||||
pub fn set_active_axis_value(&mut self, axis: &str, value: &str) -> bool {
|
||||
let Some(themes) = self.doc.themes.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(values) = themes.get(axis) else {
|
||||
return false;
|
||||
};
|
||||
if !values.iter().any(|v| v == value) {
|
||||
return false;
|
||||
}
|
||||
self.ui
|
||||
.variables
|
||||
.active_theme
|
||||
.insert(axis.to_string(), value.to_string());
|
||||
true
|
||||
}
|
||||
|
||||
/// Cycle the active value of `axis` to the next declared entry,
|
||||
/// wrapping at the end. Seeds the first value when the axis
|
||||
/// isn't yet in the active theme. `false` when the axis isn't
|
||||
/// declared or has no values.
|
||||
pub fn cycle_active_axis_value(&mut self, axis: &str) -> bool {
|
||||
let Some(themes) = self.doc.themes.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
let Some(values) = themes.get(axis) else {
|
||||
return false;
|
||||
};
|
||||
if values.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let next = match self.ui.variables.active_theme.get(axis) {
|
||||
None => values[0].clone(),
|
||||
Some(current) => {
|
||||
let idx = values
|
||||
.iter()
|
||||
.position(|v| v == current)
|
||||
.map(|i| (i + 1) % values.len())
|
||||
.unwrap_or(0);
|
||||
values[idx].clone()
|
||||
}
|
||||
};
|
||||
self.ui
|
||||
.variables
|
||||
.active_theme
|
||||
.insert(axis.to_string(), next);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Free helpers ----------------------------------------------------
|
||||
|
||||
/// Resolve a `VariableValue` under the active theme — the canonical
|
||||
/// equivalent of shell-core's `Variable::resolve`.
|
||||
fn resolve_value<'a>(
|
||||
value: &'a VariableValue,
|
||||
active: &BTreeMap<String, String>,
|
||||
) -> Option<&'a VariableScalar> {
|
||||
match value {
|
||||
VariableValue::Scalar(s) => Some(s),
|
||||
VariableValue::Themed(entries) => {
|
||||
for e in entries {
|
||||
if let Some(t) = &e.theme {
|
||||
if t.iter().all(|(k, v)| active.get(k) == Some(v)) {
|
||||
return Some(&e.value);
|
||||
}
|
||||
}
|
||||
}
|
||||
entries.iter().find(|e| e.theme.is_none()).map(|e| &e.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `scalar` into a `VariableValue` with the theme-routing
|
||||
/// discipline ported from shell-core's `set_color_hex` /
|
||||
/// `set_scalar`.
|
||||
fn write_scalar(
|
||||
value: &mut VariableValue,
|
||||
scalar: VariableScalar,
|
||||
active: &BTreeMap<String, String>,
|
||||
) {
|
||||
match value {
|
||||
VariableValue::Scalar(s) => *s = scalar,
|
||||
VariableValue::Themed(entries) => {
|
||||
let subset_idx = entries.iter().position(|e| match &e.theme {
|
||||
Some(t) => t.iter().all(|(k, v)| active.get(k) == Some(v)),
|
||||
None => false,
|
||||
});
|
||||
if let Some(i) = subset_idx {
|
||||
entries[i].value = scalar;
|
||||
return;
|
||||
}
|
||||
if active.is_empty() {
|
||||
if let Some(i) = entries.iter().position(|e| e.theme.is_none()) {
|
||||
entries[i].value = scalar;
|
||||
} else {
|
||||
entries.push(ThemedValue {
|
||||
value: scalar,
|
||||
theme: None,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Active theme set + no subset match — end-push a fresh
|
||||
// entry keyed to the active theme.
|
||||
entries.push(ThemedValue {
|
||||
value: scalar,
|
||||
theme: Some(active.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::state_with;
|
||||
|
||||
fn doc_with_color_var(name: &str, hex: &str) -> EditorState {
|
||||
let mut s = state_with(vec![]);
|
||||
s.create_variable(name, VariableKind::Color, VariableScalar::Str(hex.to_string()));
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_then_resolve_color_variable() {
|
||||
let s = doc_with_color_var("brand", "#ff8800");
|
||||
match s.resolve_variable("brand") {
|
||||
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#ff8800"),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_variable_rejects_duplicate_and_empty() {
|
||||
let mut s = doc_with_color_var("brand", "#ff0000");
|
||||
assert!(!s.create_variable("brand", VariableKind::Color, VariableScalar::Str("#fff".into())));
|
||||
assert!(!s.create_variable(" ", VariableKind::Number, VariableScalar::Num(1.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_variable_rejects_kind_mismatch() {
|
||||
let mut s = state_with(vec![]);
|
||||
// Number kind with a string default → rejected.
|
||||
assert!(!s.create_variable("x", VariableKind::Number, VariableScalar::Str("nope".into())));
|
||||
// Color kind with a bad hex → rejected.
|
||||
assert!(!s.create_variable("c", VariableKind::Color, VariableScalar::Str("zzz".into())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_variable_color_writes_and_validates() {
|
||||
let mut s = doc_with_color_var("brand", "#ff0000");
|
||||
assert!(s.set_variable_color("brand", "#00ff00"));
|
||||
match s.resolve_variable("brand") {
|
||||
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#00ff00"),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
// Bad hex → rejected, value unchanged.
|
||||
assert!(!s.set_variable_color("brand", "nothex"));
|
||||
// Unknown name → rejected.
|
||||
assert!(!s.set_variable_color("missing", "#000000"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_variable_scalar_kind_checks() {
|
||||
let mut s = state_with(vec![]);
|
||||
s.create_variable("n", VariableKind::Number, VariableScalar::Num(1.0));
|
||||
assert!(s.set_variable_number("n", 42.0));
|
||||
// String write into a Number variable → rejected.
|
||||
assert!(!s.set_variable_string("n", "no"));
|
||||
// Color write into a Number variable → rejected.
|
||||
s.create_variable("flag", VariableKind::Boolean, VariableScalar::Bool(false));
|
||||
assert!(s.set_variable_boolean("flag", true));
|
||||
assert!(!s.set_variable_number("flag", 3.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_variable_drops_it() {
|
||||
let mut s = doc_with_color_var("brand", "#ff0000");
|
||||
assert!(s.delete_variable("brand"));
|
||||
assert!(s.find_variable("brand").is_none());
|
||||
assert!(!s.delete_variable("brand"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_variable_moves_definition() {
|
||||
let mut s = doc_with_color_var("brand", "#ff0000");
|
||||
assert!(s.rename_variable("brand", "primary"));
|
||||
assert!(s.find_variable("brand").is_none());
|
||||
assert!(s.find_variable("primary").is_some());
|
||||
// Unknown old → rejected.
|
||||
assert!(!s.rename_variable("brand", "x"));
|
||||
// Empty new → rejected.
|
||||
assert!(!s.rename_variable("primary", " "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rename_variable_collision_rejected() {
|
||||
let mut s = doc_with_color_var("a", "#000000");
|
||||
s.create_variable("b", VariableKind::Color, VariableScalar::Str("#ffffff".into()));
|
||||
assert!(!s.rename_variable("a", "b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_axis_value_requires_declared_theme() {
|
||||
let mut s = state_with(vec![]);
|
||||
// No themes declared → false.
|
||||
assert!(!s.set_active_axis_value("mode", "dark"));
|
||||
let mut themes = BTreeMap::new();
|
||||
themes.insert("mode".to_string(), vec!["light".to_string(), "dark".to_string()]);
|
||||
s.doc.themes = Some(themes);
|
||||
assert!(s.set_active_axis_value("mode", "dark"));
|
||||
assert_eq!(s.ui.variables.active_theme.get("mode").map(|v| v.as_str()), Some("dark"));
|
||||
// Undeclared value → false.
|
||||
assert!(!s.set_active_axis_value("mode", "sepia"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_active_axis_wraps() {
|
||||
let mut s = state_with(vec![]);
|
||||
let mut themes = BTreeMap::new();
|
||||
themes.insert("mode".to_string(), vec!["light".to_string(), "dark".to_string()]);
|
||||
s.doc.themes = Some(themes);
|
||||
// First cycle seeds the first value.
|
||||
assert!(s.cycle_active_axis_value("mode"));
|
||||
assert_eq!(s.ui.variables.active_theme["mode"], "light");
|
||||
assert!(s.cycle_active_axis_value("mode"));
|
||||
assert_eq!(s.ui.variables.active_theme["mode"], "dark");
|
||||
// Wraps back to the first.
|
||||
assert!(s.cycle_active_axis_value("mode"));
|
||||
assert_eq!(s.ui.variables.active_theme["mode"], "light");
|
||||
// Unknown axis → false.
|
||||
assert!(!s.cycle_active_axis_value("density"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn themed_write_targets_active_theme_entry() {
|
||||
let mut s = state_with(vec![]);
|
||||
let mut themes = BTreeMap::new();
|
||||
themes.insert("mode".to_string(), vec!["light".to_string(), "dark".to_string()]);
|
||||
s.doc.themes = Some(themes);
|
||||
// Seed a themed color variable directly.
|
||||
let mut vars = BTreeMap::new();
|
||||
vars.insert(
|
||||
"bg".to_string(),
|
||||
VariableDefinition {
|
||||
kind: VariableKind::Color,
|
||||
value: VariableValue::Themed(vec![ThemedValue {
|
||||
value: VariableScalar::Str("#ffffff".into()),
|
||||
theme: None,
|
||||
}]),
|
||||
},
|
||||
);
|
||||
s.doc.variables = Some(vars);
|
||||
// Under no active theme, write hits the default entry.
|
||||
assert!(s.set_variable_color("bg", "#eeeeee"));
|
||||
// Switch to dark and write — a new dark-keyed entry appends.
|
||||
s.set_active_axis_value("mode", "dark");
|
||||
assert!(s.set_variable_color("bg", "#111111"));
|
||||
match s.resolve_variable("bg") {
|
||||
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#111111"),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
// Switch back to light — the default entry still resolves.
|
||||
s.set_active_axis_value("mode", "light");
|
||||
match s.resolve_variable("bg") {
|
||||
Some(VariableScalar::Str(hex)) => assert_eq!(hex, "#eeeeee"),
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue