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`.
This commit is contained in:
Kayshen-X 2026-05-16 20:48:55 +08:00
parent 3e9f2bb26e
commit 7a4f958ff5
11 changed files with 252 additions and 249 deletions

View file

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

View file

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

View file

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

View file

@ -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<BTreeMap<String, VariableDefinition>>`)
//! - persisted theme axes — `doc.themes`
//! (`Option<BTreeMap<String, Vec<String>>>`, axis → value list)
//! - transient active-theme selection —
//! `ui.variables.active_theme` (`BTreeMap<String, String>`)
//!
//! 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<AxisChip>,
/// 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<String>,
/// Row index currently in inline-edit focus (Number / String
/// variable). `None` = no row editing.
editing_row: Option<usize>,
/// 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<VariableScalar>,
}
#[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<VarRow>,
/// Axis chips painted in the header row.
chips: Vec<AxisChip>,
/// Axis → ordered value list, sourced from `doc.themes`.
themes: Vec<(String, Vec<String>)>,
/// If `Some(axis_name)` AND the axis matches one of `chips`,
/// paint a dropdown overlay anchored to that chip.
dropdown_open: Option<String>,
/// Row index currently in inline-edit focus (Number / String
/// variable). `None` = no row editing.
editing_row: Option<usize>,
/// Draft buffer for the row in edit focus.
editing_draft: String,
}
pub fn for_table(table: &'a VariableTable) -> Self {
let chips: Vec<AxisChip> = table
impl VariablesPanel {
pub fn for_editor(state: &EditorState) -> Self {
// Variable rows — keyed by BTreeMap order so paint is stable.
let rows: Vec<VarRow> = 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<AxisChip> = 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<String>)> = 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<Color> {
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<ThemeAxis> = None;
let _v: Option<VariableValue> = None;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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