feat(op-editor-core): port color/align/variables/rename mutators

This commit is contained in:
Kayshen-X 2026-05-16 16:21:12 +08:00
parent 9d9862a099
commit 3e04f9a261
7 changed files with 1844 additions and 1 deletions

View 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(&center(&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);
}
}

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

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

View file

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

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

View file

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

View 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:?}"),
}
}
}