diff --git a/crates/op-editor-core/src/align.rs b/crates/op-editor-core/src/align.rs new file mode 100644 index 000000000..355065f46 --- /dev/null +++ b/crates/op-editor-core/src/align.rs @@ -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 = 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 = 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 { + 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 = 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 = 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); + } +} diff --git a/crates/op-editor-core/src/color_picker.rs b/crates/op-editor-core/src/color_picker.rs new file mode 100644 index 000000000..218d51c19 --- /dev/null +++ b/crates/op-editor-core/src/color_picker.rs @@ -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` — a single +//! literal colour. The canonical `PenNode` carries +//! `fill: Option>` (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) { + 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}"); + } + } +} diff --git a/crates/op-editor-core/src/fills.rs b/crates/op-editor-core/src/fills.rs new file mode 100644 index 000000000..ce01eb320 --- /dev/null +++ b/crates/op-editor-core/src/fills.rs @@ -0,0 +1,209 @@ +//! Fill / stroke / effect read-write helpers for `PenNode`. +//! +//! shell-core's flat `Node` carried `fill: Option` and a +//! `stroke: Option` — a single literal colour per channel. +//! The canonical `PenNode` is richer: every paintable variant carries +//! `fill: Option>` (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`. +//! +//! 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> { + 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> { + 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> { + 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> { + 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 +} diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 06e590601..415ddc094 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -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; diff --git a/crates/op-editor-core/src/rename.rs b/crates/op-editor-core/src/rename.rs new file mode 100644 index 000000000..34e212316 --- /dev/null +++ b/crates/op-editor-core/src/rename.rs @@ -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"), + } + } +} diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index 07733924d..1782b4be4 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -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`; 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, + /// 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, + /// Active HSV colour picker overlay; `None` when closed. + pub color_picker: Option, + /// Pre-edit snapshot captured when the colour picker opens; + /// pushed onto undo on close only when the colour changed. + pub pending_color_history: Option, + /// 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, + /// 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, } diff --git a/crates/op-editor-core/src/variables.rs b/crates/op-editor-core/src/variables.rs new file mode 100644 index 000000000..0c16943e1 --- /dev/null +++ b/crates/op-editor-core/src/variables.rs @@ -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>`) + +//! `EditorState.doc.themes` (`Option>>`, 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 { + 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) -> 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, +) -> 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, +) { + 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:?}"), + } + } +}