From 7a4f958ff5c78291a8da346f99e0803f9d9d2240 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sat, 16 May 2026 20:48:55 +0800 Subject: [PATCH] refactor(editor): migrate four modal widgets onto op-editor-core state ColorPicker, FigmaImport, LayerContextMenu and VariablesPanel now read op-editor-core types directly instead of shell-core's `Document`: - ColorPicker / LayerContextMenu take `&EditorState`, swapping `ColorPickerState` / `ColorPickerDrag` / `LayerContextMenuState` / `LayerContextTarget` to their op-editor-core equivalents. `drag_for_hit` returns op-editor-core's `ColorPickerDrag`, dropping the host-side shell-core->op-editor-core translation shim. - FigmaImport stores the active `Locale` directly off `EditorUiState`. - VariablesPanel reads `doc.variables` / `doc.themes` + `ui.variables.active_theme`, snapshotting owned row / chip / theme data at construction. Construction + hit-test call sites updated in both hosts' widget_host modules to feed `&EditorState` instead of the derived `paint_doc`. --- .../src/widgets/color_picker.rs | 23 +- .../src/widgets/figma_import.rs | 31 +- .../src/widgets/layer_context_menu.rs | 14 +- .../src/widgets/variables_panel.rs | 338 ++++++++++-------- .../src/widget_host/input.rs | 12 +- .../src/widget_host/paint.rs | 21 +- .../src/widget_host/press.rs | 33 +- .../src/widget_host/property_dispatch.rs | 15 +- .../openpencil-shell-web/src/widget_host.rs | 5 +- .../src/widget_host/paint.rs | 4 +- .../src/widget_host/press.rs | 5 +- 11 files changed, 252 insertions(+), 249 deletions(-) diff --git a/crates/openpencil-shell-core/src/widgets/color_picker.rs b/crates/openpencil-shell-core/src/widgets/color_picker.rs index faac7a83b..6b030cf93 100644 --- a/crates/openpencil-shell-core/src/widgets/color_picker.rs +++ b/crates/openpencil-shell-core/src/widgets/color_picker.rs @@ -2,11 +2,13 @@ //! click in the PropertyPanel. SV box + hue strip + R/G/B readout //! + hex; mirrors the layout of common design-tool pickers. -use crate::document::{ColorPickerDrag, ColorPickerState, ColorTarget, Document}; use crate::theme::Theme; +use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; +use op_editor_core::ui_draft::{ColorPickerDrag, ColorPickerState, ColorTarget}; +use op_editor_core::EditorState; pub const PICKER_WIDTH: f32 = 240.0; pub const PICKER_HEIGHT: f32 = 336.0; @@ -36,20 +38,21 @@ pub enum ColorPickerHit { Inside, } -pub struct ColorPicker<'a> { +pub struct ColorPicker { pub id: WidgetId, pub state: ColorPickerState, pub theme: Theme, - document: &'a Document, + /// Right-rail width — drives the picker's horizontal anchor. + property_panel_width: f32, } -impl<'a> ColorPicker<'a> { - pub fn for_state(doc: &'a Document, state: ColorPickerState) -> Self { +impl ColorPicker { + pub fn for_state(state: &EditorState, picker: ColorPickerState) -> Self { Self { id: WidgetId::new(5100), - state, - theme: doc.theme(), - document: doc, + state: picker, + theme: theme_for(&state.editor_ui), + property_panel_width: state.editor_ui.property_panel_width, } } @@ -57,7 +60,7 @@ impl<'a> ColorPicker<'a> { /// pinned to the right rail near the Fill section. Hosts use /// this for paint AND hit-test so they stay in sync. pub fn rect(&self, viewport_w: f32, viewport_h: f32) -> Rect { - let right_rail_left = viewport_w - self.document.ui.property_panel_width; + let right_rail_left = viewport_w - self.property_panel_width; let x = (right_rail_left - PICKER_WIDTH - 8.0).max(8.0); // Center vertically around the swatch the user clicked, then // clamp so the panel stays fully on-screen. @@ -120,7 +123,7 @@ impl<'a> ColorPicker<'a> { } } -impl<'a> Widget for ColorPicker<'a> { +impl Widget for ColorPicker { fn id(&self) -> WidgetId { self.id } diff --git a/crates/openpencil-shell-core/src/widgets/figma_import.rs b/crates/openpencil-shell-core/src/widgets/figma_import.rs index 5eed633b2..f656638f0 100644 --- a/crates/openpencil-shell-core/src/widgets/figma_import.rs +++ b/crates/openpencil-shell-core/src/widgets/figma_import.rs @@ -5,11 +5,13 @@ //! yet; clicking inside the drop zone routes through a file dialog //! to pick a .fig path. -use crate::document::{Document, Locale}; use crate::theme::Theme; +use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; +use op_editor_core::editor_ui_state::Locale; +use op_editor_core::EditorState; pub const MODAL_WIDTH: f32 = 460.0; pub const MODAL_HEIGHT: f32 = 260.0; @@ -23,18 +25,19 @@ pub enum FigmaImportHit { Inside, } -pub struct FigmaImportModal<'a> { +pub struct FigmaImportModal { pub id: WidgetId, pub theme: Theme, - doc: &'a Document, + /// Active UI locale — drives the modal's `t` copy lookup. + locale: Locale, } -impl<'a> FigmaImportModal<'a> { - pub fn for_document(doc: &'a Document) -> Self { +impl FigmaImportModal { + pub fn for_editor(state: &EditorState) -> Self { Self { id: WidgetId::new(5400), - theme: doc.theme(), - doc, + theme: theme_for(&state.editor_ui), + locale: state.editor_ui.locale, } } @@ -85,8 +88,8 @@ fn rect_contains(r: Rect, p: Point2D) -> bool { && p.y <= r.origin.y + r.size.y } -fn t(doc: &Document, key: &str) -> &'static str { - let zh = matches!(doc.ui.locale, Locale::ZhCn | Locale::ZhTw); +fn t(locale: Locale, key: &str) -> &'static str { + let zh = matches!(locale, Locale::ZhCn | Locale::ZhTw); match (key, zh) { ("title", true) => "从 Figma 导入", ("title", _) => "Import from Figma", @@ -104,7 +107,7 @@ fn t(doc: &Document, key: &str) -> &'static str { } } -impl<'a> Widget for FigmaImportModal<'a> { +impl Widget for FigmaImportModal { fn id(&self) -> WidgetId { self.id } @@ -123,7 +126,7 @@ impl<'a> Widget for FigmaImportModal<'a> { cx.backend.stroke_round_rect(rect, 12.0, self.theme.border, 1.0); let title = TextLayout::single_run( - t(self.doc, "title"), + t(self.locale, "title"), "system-ui", 14.0, to_jian(self.theme.foreground), @@ -160,7 +163,7 @@ impl<'a> Widget for FigmaImportModal<'a> { self.theme.muted_foreground, ); - let headline = t(self.doc, "drop"); + let headline = t(self.locale, "drop"); let head_w = cx.backend.measure_text(headline, 13.0); let head_layout = TextLayout::single_run( headline, @@ -177,7 +180,7 @@ impl<'a> Widget for FigmaImportModal<'a> { ), ); - let sub = t(self.doc, "browse"); + let sub = t(self.locale, "browse"); let sub_w = cx.backend.measure_text(sub, 11.0); let sub_layout = TextLayout::single_run( sub, @@ -195,7 +198,7 @@ impl<'a> Widget for FigmaImportModal<'a> { ); let footer = TextLayout::single_run( - t(self.doc, "footer"), + t(self.locale, "footer"), "system-ui", 11.0, to_jian(self.theme.muted_foreground), diff --git a/crates/openpencil-shell-core/src/widgets/layer_context_menu.rs b/crates/openpencil-shell-core/src/widgets/layer_context_menu.rs index 19b285f51..f5c81355b 100644 --- a/crates/openpencil-shell-core/src/widgets/layer_context_menu.rs +++ b/crates/openpencil-shell-core/src/widgets/layer_context_menu.rs @@ -2,11 +2,13 @@ //! layer rows (复制 / 删除 / 创建组件 / 切换锁定 / 切换可见性) and //! page rows (重命名 / 复制 / 上移 / 下移 / 删除). -use crate::document::{Document, LayerContextMenuState, LayerContextTarget}; use crate::theme::Theme; +use crate::widgets::editor_state_ext::theme_for; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; +use op_editor_core::editor_ui_state::{LayerContextMenuState, LayerContextTarget}; +use op_editor_core::EditorState; pub const LAYER_CONTEXT_MENU_WIDTH: f32 = 168.0; const ROW_HEIGHT: f32 = 32.0; @@ -128,16 +130,16 @@ pub struct LayerContextMenu { } impl LayerContextMenu { - pub fn for_state(doc: &Document, state: LayerContextMenuState) -> Self { - let rows = match &state.target { + pub fn for_state(state: &EditorState, menu: LayerContextMenuState) -> Self { + let rows = match &menu.target { LayerContextTarget::Layer(_) => LAYER_ROWS, LayerContextTarget::Page(_) => PAGE_ROWS, }; - let hovered_row = state.hovered_row.map(|i| i as usize); + let hovered_row = menu.hovered_row.map(|i| i as usize); Self { id: WidgetId::new(3000), - theme: doc.theme(), - state, + theme: theme_for(&state.editor_ui), + state: menu, rows, hovered_row, } diff --git a/crates/openpencil-shell-core/src/widgets/variables_panel.rs b/crates/openpencil-shell-core/src/widgets/variables_panel.rs index 5426287d0..14d177cbc 100644 --- a/crates/openpencil-shell-core/src/widgets/variables_panel.rs +++ b/crates/openpencil-shell-core/src/widgets/variables_panel.rs @@ -1,27 +1,36 @@ -//! Variables panel — lists every variable in `Document.var_table` -//! plus the currently active theme axes. Mirrors the TS app's -//! Variables panel in the right rail (under the Themes header). +//! Variables panel — lists every variable in the document plus the +//! currently active theme axes. Mirrors the TS app's Variables panel +//! in the right rail (under the Themes header). //! -//! v1 scope (this commit): +//! v1 scope: //! - Header row showing each active theme axis as a small chip //! (`mode: dark`, `density: compact`, etc). //! - One row per variable: name on the left, a small preview on //! the right (resolved color swatch for `VariableKind::Color`; //! stringified scalar for other kinds). -//! - Click hit-test stub returning `VariablesPanelHit::Row(idx)` -//! so the host can wire row clicks to the color picker / edit -//! modal in a follow-up. +//! - Click hit-test returning `VariablesPanelHit::Row(idx)` / +//! `AxisChip(idx)` / `AxisDropdownItem` so the host can wire +//! row clicks to the color picker / theme switch. //! -//! Editing inputs (add / rename / delete a variable, toggle a theme -//! axis value) are deferred to a second commit once the active-theme -//! flow is exercised via a real `.op` file with themes. +//! ## State source +//! +//! The panel reads the canonical document model on `EditorState`: +//! - persisted variables — `doc.variables` +//! (`Option>`) +//! - persisted theme axes — `doc.themes` +//! (`Option>>`, axis → value list) +//! - transient active-theme selection — +//! `ui.variables.active_theme` (`BTreeMap`) +//! +//! Construction snapshots that state into owned value rows / chips so +//! paint + hit-test never re-walk the document. -use crate::document::{ - Document, ThemeAxis, Variable, VariableKind, VariableScalar, VariableTable, VariableValue, -}; use crate::theme::Theme; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect}; +use op_editor_core::editor_ui_state::VariableRowFocus; +use op_editor_core::EditorState; +use jian_ops_schema::variable::{VariableKind, VariableScalar}; const ROW_HEIGHT: f32 = 32.0; const HEADER_HEIGHT: f32 = 28.0; @@ -37,40 +46,31 @@ const DROPDOWN_ROW_HEIGHT: f32 = 24.0; pub const VARIABLES_PANEL_WIDTH: f32 = 240.0; /// Hit kinds for `VariablesPanel::hit_test`. Row index is into the -/// `variables` slice the panel was built from, so callers can map -/// straight back to the source [`Variable`]. +/// `rows` slice the panel was built from, so callers can map straight +/// back to the source variable name. #[derive(Debug, Clone, PartialEq, Eq)] pub enum VariablesPanelHit { /// Click on a variable row. Row(usize), /// Click on a theme-axis chip in the header. Host toggles - /// `Document.ui.axis_dropdown_open` for that axis name. + /// `EditorUiState.axis_dropdown_open` for that axis name. AxisChip(usize), /// Click on a value row inside an open axis dropdown. - /// Carries the axis name + the picked value so the host - /// can pin `var_table.active_theme[axis] = value`. The host - /// is also responsible for clearing `axis_dropdown_open`. + /// Carries the axis name + the picked value so the host can pin + /// `ui.variables.active_theme[axis] = value`. The host is also + /// responsible for clearing `axis_dropdown_open`. AxisDropdownItem { axis: String, value: String }, } -/// View model for the Variables panel. Borrows from the document -/// (cheap; immutable across paint). -pub struct VariablesPanel<'a> { - pub table: &'a VariableTable, - /// Cached snapshot of axis chips painted in the header row. - /// Stored so `hit_test` doesn't need to re-walk the table. - chips: Vec, - /// If `Some(axis_name)` AND the axis matches one of `chips`, - /// paint a dropdown overlay anchored to that chip. The list - /// is sourced from `VariableTable::themes`. - dropdown_open: Option, - /// Row index currently in inline-edit focus (Number / String - /// variable). `None` = no row editing. - editing_row: Option, - /// Draft buffer for the row in edit focus. Shared with - /// `Document.ui.property_input_draft` (reuses the caret + - /// blink machinery). - editing_draft: String, +/// One variable row snapshot — owned so paint + hit-test never touch +/// the document after construction. +#[derive(Debug, Clone)] +struct VarRow { + name: String, + kind: VariableKind, + /// Resolved scalar under the active theme; `None` when the + /// variable has an empty themed list. + resolved: Option, } #[derive(Debug, Clone)] @@ -79,20 +79,45 @@ struct AxisChip { value: String, } -impl<'a> VariablesPanel<'a> { - pub fn for_document(doc: &'a Document) -> Self { - let mut panel = Self::for_table(&doc.var_table); - panel.dropdown_open = doc.ui.axis_dropdown_open.clone(); - panel.editing_row = doc.ui.variable_row_focus.map(|f| match f { - crate::document::VariableRowFocus::Number(i) => i, - crate::document::VariableRowFocus::String(i) => i, - }); - panel.editing_draft = doc.ui.property_input_draft.clone(); - panel - } +/// View model for the Variables panel. Holds owned snapshots derived +/// from `EditorState` at construction time. +pub struct VariablesPanel { + rows: Vec, + /// Axis chips painted in the header row. + chips: Vec, + /// Axis → ordered value list, sourced from `doc.themes`. + themes: Vec<(String, Vec)>, + /// If `Some(axis_name)` AND the axis matches one of `chips`, + /// paint a dropdown overlay anchored to that chip. + dropdown_open: Option, + /// Row index currently in inline-edit focus (Number / String + /// variable). `None` = no row editing. + editing_row: Option, + /// Draft buffer for the row in edit focus. + editing_draft: String, +} - pub fn for_table(table: &'a VariableTable) -> Self { - let chips: Vec = table +impl VariablesPanel { + pub fn for_editor(state: &EditorState) -> Self { + // Variable rows — keyed by BTreeMap order so paint is stable. + let rows: Vec = state + .doc + .variables + .as_ref() + .map(|vars| { + vars.iter() + .map(|(name, def)| VarRow { + name: name.clone(), + kind: def.kind.clone(), + resolved: state.resolve_variable(name).cloned(), + }) + .collect() + }) + .unwrap_or_default(); + // Active-theme chips. + let chips: Vec = state + .ui + .variables .active_theme .iter() .map(|(axis, value)| AxisChip { @@ -100,18 +125,29 @@ impl<'a> VariablesPanel<'a> { value: value.clone(), }) .collect(); + // Theme axes + their value lists. + let themes: Vec<(String, Vec)> = state + .doc + .themes + .as_ref() + .map(|t| t.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default(); Self { - table, + rows, chips, - dropdown_open: None, - editing_row: None, - editing_draft: String::new(), + themes, + dropdown_open: state.editor_ui.axis_dropdown_open.clone(), + editing_row: state.editor_ui.variable_row_focus.map(|f| match f { + VariableRowFocus::Number(i) => i, + VariableRowFocus::String(i) => i, + }), + editing_draft: state.ui.property_input_draft.clone(), } } /// Number of variable rows the panel paints. pub fn row_count(&self) -> usize { - self.table.variables.len() + self.rows.len() } /// Number of axis chips in the header. May be zero — a document @@ -131,6 +167,14 @@ impl<'a> VariablesPanel<'a> { HEADER_HEIGHT + chip_row + (self.row_count() as f32) * ROW_HEIGHT } + /// Value list for an axis name, sourced from `doc.themes`. + fn axis_values(&self, axis: &str) -> Option<&[String]> { + self.themes + .iter() + .find(|(name, _)| name == axis) + .map(|(_, values)| values.as_slice()) + } + /// Anchor rect of the chip at index `i` within `rect`. Mirrors /// the paint walk in `paint` so hit-test + dropdown anchoring /// stay aligned without re-measuring. @@ -170,13 +214,7 @@ impl<'a> VariablesPanel<'a> { .enumerate() .find(|(_, c)| c.axis == open_axis) { - if let Some(values) = self - .table - .themes - .iter() - .find(|t| t.name == open_axis) - .map(|t| t.values.as_slice()) - { + if let Some(values) = self.axis_values(open_axis) { let chip_rect = self.chip_rect(rect, chip_idx); let menu_y_start = chip_rect.origin.y + CHIP_HEIGHT + 4.0; let menu_rect = Rect { @@ -243,7 +281,7 @@ fn rect_contains(r: Rect, p: Point2D) -> bool { && p.y < r.origin.y + r.size.y } -impl<'a> Widget for VariablesPanel<'a> { +impl Widget for VariablesPanel { fn id(&self) -> WidgetId { // Stable id reserved at the host level; the table itself // has no id so we pick a constant in the chrome-widget @@ -311,7 +349,7 @@ impl<'a> Widget for VariablesPanel<'a> { y += CHIP_HEIGHT + 8.0; } // Variable rows. - for (idx, var) in self.table.variables.iter().enumerate() { + for (idx, var) in self.rows.iter().enumerate() { let row = Rect { origin: Point2D::new(rect.origin.x, y), size: Point2D::new(rect.size.x, ROW_HEIGHT), @@ -332,11 +370,7 @@ impl<'a> Widget for VariablesPanel<'a> { let preview_x = row.origin.x + row.size.x - PAD_X - SWATCH_SIZE; if self.editing_row == Some(idx) { // Inline edit mode — paint the draft + a thin - // underline to signal active focus. Aligned with - // the same baseline the preview label would have - // used. No caret blink today (the property panel's - // caret machinery isn't yet shared); the underline - // is enough affordance. + // underline to signal active focus. let draft_layout = crate::TextLayout::single_run( &self.editing_draft, "system-ui", @@ -352,7 +386,7 @@ impl<'a> Widget for VariablesPanel<'a> { }; cx.backend.fill_rect(underline, theme.foreground); } else { - paint_preview(cx, &theme, var, self.table, preview_x, row.origin.y + 7.0); + paint_preview(cx, &theme, var, preview_x, row.origin.y + 7.0); } y += ROW_HEIGHT; } @@ -366,13 +400,7 @@ impl<'a> Widget for VariablesPanel<'a> { .enumerate() .find(|(_, c)| c.axis == open_axis) { - if let Some(values) = self - .table - .themes - .iter() - .find(|t| t.name == open_axis) - .map(|t| t.values.as_slice()) - { + if let Some(values) = self.axis_values(open_axis) { let chip_rect = self.chip_rect(rect, chip_idx); let menu_y = chip_rect.origin.y + CHIP_HEIGHT + 4.0; let menu_rect = Rect { @@ -386,10 +414,10 @@ impl<'a> Widget for VariablesPanel<'a> { cx.backend .stroke_round_rect(menu_rect, 6.0, theme.border, 1.0); let active_value = self - .table - .active_theme - .get(open_axis) - .cloned() + .chips + .iter() + .find(|c| c.axis == open_axis) + .map(|c| c.value.clone()) .unwrap_or_default(); for (i, v) in values.iter().enumerate() { let row_y = menu_y + (i as f32) * DROPDOWN_ROW_HEIGHT; @@ -419,18 +447,13 @@ impl<'a> Widget for VariablesPanel<'a> { } } -fn paint_preview( - cx: &mut PaintCx<'_>, - theme: &Theme, - var: &Variable, - table: &VariableTable, - x: f32, - y: f32, -) { +fn paint_preview(cx: &mut PaintCx<'_>, theme: &Theme, var: &VarRow, x: f32, y: f32) { match var.kind { VariableKind::Color => { - let rgba = table - .resolve_color(&var.name) + let rgba = var + .resolved + .as_ref() + .and_then(scalar_as_color) .unwrap_or(Color::WHITE); let swatch = Rect { origin: Point2D::new(x, y), @@ -444,7 +467,7 @@ fn paint_preview( // Non-color: render the resolved scalar as a short text // label. Falls back to "—" when the variable doesn't // resolve under the active theme. - let text = match table.resolve(&var.name) { + let text = match var.resolved.as_ref() { Some(s) => scalar_to_label(s), None => "—".into(), }; @@ -463,6 +486,16 @@ fn paint_preview( } } +/// Parse a `Str` scalar as an `#rrggbb` colour swatch. +fn scalar_as_color(s: &VariableScalar) -> Option { + let hex = match s { + VariableScalar::Str(hex) => hex, + _ => return None, + }; + let (r, g, b) = op_editor_core::color_picker::parse_hex_rgb(hex)?; + Some(Color { r, g, b, a: 1.0 }) +} + fn scalar_to_label(s: &VariableScalar) -> String { match s { VariableScalar::Str(s) => s.clone(), @@ -490,78 +523,73 @@ fn to_jian_color(c: Color) -> jian_core::scene::Color { #[cfg(test)] mod tests { use super::*; - use crate::document::{VariableKind, VariableScalar, VariableValue}; - use std::collections::BTreeMap; + use jian_ops_schema::variable::VariableScalar; - fn table_with_three_vars() -> VariableTable { - let mut t = VariableTable::default(); - t.variables.push(Variable { - name: "color-1".into(), - kind: VariableKind::Color, - value: VariableValue::Scalar(VariableScalar::Str("#ff8800".into())), - }); - t.variables.push(Variable { - name: "spacing-md".into(), - kind: VariableKind::Number, - value: VariableValue::Scalar(VariableScalar::Num(16.0)), - }); - t.variables.push(Variable { - name: "is-dark".into(), - kind: VariableKind::Boolean, - value: VariableValue::Scalar(VariableScalar::Bool(true)), - }); - t.active_theme.insert("mode".into(), "dark".into()); - t + fn state_with_three_vars() -> EditorState { + let mut s = EditorState::new(); + s.create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#ff8800".into()), + ); + s.create_variable( + "spacing-md", + VariableKind::Number, + VariableScalar::Num(16.0), + ); + s.create_variable( + "is-dark", + VariableKind::Boolean, + VariableScalar::Bool(true), + ); + s.ui + .variables + .active_theme + .insert("mode".into(), "dark".into()); + s } #[test] fn row_count_matches_variable_count() { - let t = table_with_three_vars(); - let p = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); assert_eq!(p.row_count(), 3); } #[test] fn axis_count_reflects_active_theme() { - let t = table_with_three_vars(); - let p = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); assert_eq!(p.axis_count(), 1); } #[test] fn intrinsic_height_grows_with_rows_and_chips() { - let mut t = VariableTable::default(); - let p = VariablesPanel::for_table(&t); + let s_empty = EditorState::new(); + let p = VariablesPanel::for_editor(&s_empty); let empty_h = p.intrinsic_height(); assert!((empty_h - HEADER_HEIGHT).abs() < f32::EPSILON); - t.variables.push(Variable { - name: "x".into(), - kind: VariableKind::Number, - value: VariableValue::Scalar(VariableScalar::Num(1.0)), - }); - let p2 = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p2 = VariablesPanel::for_editor(&s); assert!(p2.intrinsic_height() > empty_h); } #[test] fn axis_dropdown_hit_routes_to_named_value() { - let mut t = table_with_three_vars(); - t.themes.push(crate::document::ThemeAxis { - name: "mode".into(), - values: vec!["light".into(), "dark".into(), "system".into()], - }); - let mut p = VariablesPanel::for_table(&t); + let mut s = state_with_three_vars(); + s.doc.themes.get_or_insert_with(Default::default).insert( + "mode".into(), + vec!["light".into(), "dark".into(), "system".into()], + ); + let mut p = VariablesPanel::for_editor(&s); p.dropdown_open = Some("mode".into()); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(VARIABLES_PANEL_WIDTH, p.intrinsic_height()), }; - // Hit row 0 of the dropdown (== "light"). Chip anchor is - // (PAD_X, HEADER_HEIGHT); dropdown starts CHIP_HEIGHT+4 - // below it. let menu_y = HEADER_HEIGHT + CHIP_HEIGHT + 4.0; let click_y = menu_y + DROPDOWN_ROW_HEIGHT * 0.5; - let click_x = PAD_X + 10.0; // inside DROPDOWN_WIDTH + let click_x = PAD_X + 10.0; match p.hit_test(rect, Point2D::new(click_x, click_y)) { Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => { assert_eq!(axis, "mode"); @@ -569,7 +597,6 @@ mod tests { } other => panic!("expected AxisDropdownItem for row 0, got {other:?}"), } - // Row 2 = "system". let click_y_sys = menu_y + DROPDOWN_ROW_HEIGHT * 2.5; match p.hit_test(rect, Point2D::new(click_x, click_y_sys)) { Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => { @@ -582,13 +609,12 @@ mod tests { #[test] fn hit_test_returns_row_index_for_in_row_click() { - let t = table_with_three_vars(); - let p = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(VARIABLES_PANEL_WIDTH, p.intrinsic_height()), }; - // y for row 1 = HEADER + chip row + row_height * 1 + half. let chip_block = CHIP_HEIGHT + 8.0; let y = HEADER_HEIGHT + chip_block + ROW_HEIGHT * 1.0 + ROW_HEIGHT / 2.0; match p.hit_test(rect, Point2D::new(100.0, y)) { @@ -599,13 +625,12 @@ mod tests { #[test] fn hit_test_returns_axis_chip_for_chip_click() { - let t = table_with_three_vars(); - let p = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(VARIABLES_PANEL_WIDTH, p.intrinsic_height()), }; - // First chip starts at (PAD_X, HEADER_HEIGHT). let y = HEADER_HEIGHT + CHIP_HEIGHT / 2.0; match p.hit_test(rect, Point2D::new(PAD_X + 4.0, y)) { Some(VariablesPanelHit::AxisChip(0)) => {} @@ -615,8 +640,8 @@ mod tests { #[test] fn hit_test_returns_none_outside_rect() { - let t = table_with_three_vars(); - let p = VariablesPanel::for_table(&t); + let s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); let rect = Rect { origin: Point2D::new(0.0, 0.0), size: Point2D::new(VARIABLES_PANEL_WIDTH, 200.0), @@ -627,24 +652,19 @@ mod tests { #[test] fn axis_chip_table_mirrors_active_theme_btree_order() { - let mut t = VariableTable::default(); - t.active_theme.insert("z-axis".into(), "alpha".into()); - t.active_theme.insert("a-axis".into(), "omega".into()); - let p = VariablesPanel::for_table(&t); + let mut s = EditorState::new(); + s.ui + .variables + .active_theme + .insert("z-axis".into(), "alpha".into()); + s.ui + .variables + .active_theme + .insert("a-axis".into(), "omega".into()); + let p = VariablesPanel::for_editor(&s); // BTreeMap iterates in key order — a-axis first. assert_eq!(p.chips.len(), 2); assert_eq!(p.chips[0].axis, "a-axis"); assert_eq!(p.chips[1].axis, "z-axis"); } } - -// Suppress unused-import warning until tests are split into a -// sibling file. Test mod uses `BTreeMap` indirectly via the -// VariableTable default; keeping the import keeps that obvious. -#[allow(dead_code)] -fn _ensure_themed_value_path_compiles() { - // Reference these types so `cargo check --lib` warns when they - // move (the panel will need them once edit inputs land). - let _t: Option = None; - let _v: Option = None; -} diff --git a/crates/openpencil-shell-native/src/widget_host/input.rs b/crates/openpencil-shell-native/src/widget_host/input.rs index 1ed2e7552..0f152be02 100644 --- a/crates/openpencil-shell-native/src/widget_host/input.rs +++ b/crates/openpencil-shell-native/src/widget_host/input.rs @@ -126,11 +126,7 @@ impl WidgetHostNative { if let Some(kind) = state.drag { use op_editor_core::ui_draft::ColorPickerDrag; use openpencil_shell_core::widgets::color_picker::ColorPicker; - self.refresh_paint_doc(); - let picker = ColorPicker::for_state( - &self.paint_doc, - self.paint_doc.ui.color_picker.clone().unwrap(), - ); + let picker = ColorPicker::for_state(&self.editor_state, state.clone()); let panel = picker.rect(self.last_viewport_w, self.last_viewport_h); let point = Point2D::new(x, y); match kind { @@ -160,11 +156,7 @@ impl WidgetHostNative { } if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - self.refresh_paint_doc(); - let menu = LayerContextMenu::for_state( - &self.paint_doc, - self.paint_doc.ui.layer_context_menu.clone().unwrap(), - ); + let menu = LayerContextMenu::for_state(&self.editor_state, state.clone()); let new_hover = menu.hovered_row_at(Point2D::new(x, y)).map(|i| i as u8); if new_hover != state.hovered_row { self.editor_state.editor_ui.layer_context_menu = diff --git a/crates/openpencil-shell-native/src/widget_host/paint.rs b/crates/openpencil-shell-native/src/widget_host/paint.rs index 34dff95cb..70fd20207 100644 --- a/crates/openpencil-shell-native/src/widget_host/paint.rs +++ b/crates/openpencil-shell-native/src/widget_host/paint.rs @@ -120,8 +120,15 @@ impl WidgetHostNative { // selection is active, PropertyPanel owns the rail and // VariablesPanel paints below it; when no selection, the // Variables panel anchors at the top so it's not hidden. - if !doc.var_table.variables.is_empty() { - let vars = VariablesPanel::for_document(doc); + let has_variables = self + .editor_state + .doc + .variables + .as_ref() + .map(|v| !v.is_empty()) + .unwrap_or(false); + if has_variables { + let vars = VariablesPanel::for_editor(&self.editor_state); let intrinsic = vars.intrinsic_height(); let top_y = if has_property { // Below PropertyPanel — naive offset uses the @@ -311,7 +318,7 @@ impl WidgetHostNative { }, openpencil_shell_core::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.45 }, ); - let modal = FigmaImportModal::for_document(doc); + let modal = FigmaImportModal::for_editor(&self.editor_state); let modal_rect = modal.rect(viewport_width, viewport_height); let mut cx = PaintCx { backend: &mut *frame }; modal.paint(&mut cx, modal_rect); @@ -357,9 +364,9 @@ impl WidgetHostNative { } // 10b. Color picker — floating overlay near the right rail. - if let Some(state) = doc.ui.color_picker.clone() { + if let Some(state) = self.editor_state.ui.color_picker.clone() { use openpencil_shell_core::widgets::color_picker::ColorPicker; - let picker = ColorPicker::for_state(doc, state); + let picker = ColorPicker::for_state(&self.editor_state, state); let picker_rect = picker.rect(viewport_width, viewport_height); let mut cx = PaintCx { backend: &mut *frame, @@ -369,9 +376,9 @@ impl WidgetHostNative { // 11. Layer context menu — right-click overlay above // everything else. - if let Some(state) = doc.ui.layer_context_menu.clone() { + if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - let menu = LayerContextMenu::for_state(doc, state); + let menu = LayerContextMenu::for_state(&self.editor_state, state); let menu_rect = menu.rect(); let mut cx = PaintCx { backend: &mut *frame, diff --git a/crates/openpencil-shell-native/src/widget_host/press.rs b/crates/openpencil-shell-native/src/widget_host/press.rs index d921c619a..f1e62b505 100644 --- a/crates/openpencil-shell-native/src/widget_host/press.rs +++ b/crates/openpencil-shell-native/src/widget_host/press.rs @@ -161,11 +161,7 @@ impl WidgetHostNative { use openpencil_shell_core::widgets::color_picker::{ drag_for_hit, ColorPicker, ColorPickerHit, }; - self.refresh_paint_doc(); - let picker = ColorPicker::for_state( - &self.paint_doc, - self.paint_doc.ui.color_picker.clone().unwrap(), - ); + let picker = ColorPicker::for_state(&self.editor_state, state.clone()); let panel = picker.rect(viewport_width, viewport_height); let point = Point2D::new(x, y); match picker.hit_test(panel, point) { @@ -195,10 +191,9 @@ impl WidgetHostNative { } _ => {} } - // `drag_for_hit` returns a shell-core - // `ColorPickerDrag`; translate to op-editor-core. - self.editor_state - .color_picker_set_drag(Some(color_picker_drag(kind))); + // `drag_for_hit` returns op-editor-core's + // `ColorPickerDrag` — no translation needed. + self.editor_state.color_picker_set_drag(Some(kind)); } self.mark_dirty(); return true; @@ -214,11 +209,7 @@ impl WidgetHostNative { if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - self.refresh_paint_doc(); - let menu = LayerContextMenu::for_state( - &self.paint_doc, - self.paint_doc.ui.layer_context_menu.clone().unwrap(), - ); + let menu = LayerContextMenu::for_state(&self.editor_state, state.clone()); if let Some(action) = menu.hit_test(Point2D::new(x, y)) { self.dispatch_layer_context_action(action, state.target.clone()); self.editor_state.editor_ui.layer_context_menu = None; @@ -758,17 +749,3 @@ impl WidgetHostNative { } } -/// Translate a shell-core `ColorPickerDrag` into op-editor-core's. -fn color_picker_drag( - d: openpencil_shell_core::document::ColorPickerDrag, -) -> op_editor_core::ui_draft::ColorPickerDrag { - match d { - openpencil_shell_core::document::ColorPickerDrag::SvBox => { - op_editor_core::ui_draft::ColorPickerDrag::SvBox - } - openpencil_shell_core::document::ColorPickerDrag::HueSlider => { - op_editor_core::ui_draft::ColorPickerDrag::HueSlider - } - } -} - diff --git a/crates/openpencil-shell-native/src/widget_host/property_dispatch.rs b/crates/openpencil-shell-native/src/widget_host/property_dispatch.rs index 5297f8ca7..095dccdf0 100644 --- a/crates/openpencil-shell-native/src/widget_host/property_dispatch.rs +++ b/crates/openpencil-shell-native/src/widget_host/property_dispatch.rs @@ -115,8 +115,7 @@ impl WidgetHostNative { ) { use op_editor_core::editor_ui_state::FileAction; use openpencil_shell_core::widgets::figma_import::{FigmaImportHit, FigmaImportModal}; - self.refresh_paint_doc(); - let modal = FigmaImportModal::for_document(&self.paint_doc); + let modal = FigmaImportModal::for_editor(&self.editor_state); let panel_rect = modal.rect(viewport_w, viewport_h); match modal.hit_test(panel_rect, openpencil_shell_core::Point2D::new(x, y)) { FigmaImportHit::Close | FigmaImportHit::Outside => { @@ -299,8 +298,14 @@ impl WidgetHostNative { viewport_width: f32, viewport_height: f32, ) -> bool { - self.refresh_paint_doc(); - if self.paint_doc.var_table.variables.is_empty() { + let has_variables = self + .editor_state + .doc + .variables + .as_ref() + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !has_variables { return false; } use openpencil_shell_core::widgets::variables_panel::{ @@ -308,7 +313,7 @@ impl WidgetHostNative { }; use openpencil_shell_core::widgets::{STATUS_BAR_HEIGHT, TOP_BAR_HEIGHT}; use openpencil_shell_core::{Point2D, Rect}; - let vars = VariablesPanel::for_document(&self.paint_doc); + let vars = VariablesPanel::for_editor(&self.editor_state); let intrinsic = vars.intrinsic_height(); let top_y = if self.editor_state.property_panel_visible() { let bottom_pad = STATUS_BAR_HEIGHT + 16.0; diff --git a/crates/openpencil-shell-web/src/widget_host.rs b/crates/openpencil-shell-web/src/widget_host.rs index 2e0333216..959dae130 100644 --- a/crates/openpencil-shell-web/src/widget_host.rs +++ b/crates/openpencil-shell-web/src/widget_host.rs @@ -355,10 +355,7 @@ impl WidgetHost { self.refresh_paint_doc(); if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - let menu = LayerContextMenu::for_state( - &self.paint_doc, - self.paint_doc.ui.layer_context_menu.clone().unwrap(), - ); + let menu = LayerContextMenu::for_state(&self.editor_state, state.clone()); let new_hover = menu.hovered_row_at(Point2D::new(x, y)).map(|i| i as u8); if new_hover != state.hovered_row { self.editor_state.editor_ui.layer_context_menu = diff --git a/crates/openpencil-shell-web/src/widget_host/paint.rs b/crates/openpencil-shell-web/src/widget_host/paint.rs index 728b33e0a..4783d9e57 100644 --- a/crates/openpencil-shell-web/src/widget_host/paint.rs +++ b/crates/openpencil-shell-web/src/widget_host/paint.rs @@ -235,9 +235,9 @@ impl WidgetHost { } // Layer context menu — right-click overlay, top of stack. - if let Some(state) = self.paint_doc.ui.layer_context_menu.clone() { + if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - let menu = LayerContextMenu::for_state(&self.paint_doc, state); + let menu = LayerContextMenu::for_state(&self.editor_state, state); let menu_rect = menu.rect(); let mut cx = PaintCx { backend: &mut *backend, diff --git a/crates/openpencil-shell-web/src/widget_host/press.rs b/crates/openpencil-shell-web/src/widget_host/press.rs index c99bcefed..422695fbe 100644 --- a/crates/openpencil-shell-web/src/widget_host/press.rs +++ b/crates/openpencil-shell-web/src/widget_host/press.rs @@ -201,10 +201,7 @@ impl WidgetHost { // 0. Layer context menu — top-most overlay when open. if let Some(state) = self.editor_state.editor_ui.layer_context_menu.clone() { use openpencil_shell_core::widgets::layer_context_menu::LayerContextMenu; - let menu = LayerContextMenu::for_state( - &self.paint_doc, - self.paint_doc.ui.layer_context_menu.clone().unwrap(), - ); + let menu = LayerContextMenu::for_state(&self.editor_state, state.clone()); if let Some(action) = menu.hit_test(Point2D::new(x, y)) { self.dispatch_layer_context_action(action, state.target); self.editor_state.editor_ui.layer_context_menu = None;