diff --git a/crates/op-editor-core/src/color_picker.rs b/crates/op-editor-core/src/color_picker.rs index 4d0a4968..0abb9b98 100644 --- a/crates/op-editor-core/src/color_picker.rs +++ b/crates/op-editor-core/src/color_picker.rs @@ -84,6 +84,27 @@ impl EditorState { /// 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 { + self.open_color_picker_with_anchor(target, None, anchor_y) + } + + /// Same as [`Self::open_color_picker`], but horizontally anchors + /// the floating picker near the click point instead of the right + /// property rail. + pub fn open_color_picker_at( + &mut self, + target: ColorTarget, + anchor_x: f32, + anchor_y: f32, + ) -> bool { + self.open_color_picker_with_anchor(target, Some(anchor_x), anchor_y) + } + + fn open_color_picker_with_anchor( + &mut self, + target: ColorTarget, + anchor_x: Option, + anchor_y: f32, + ) -> bool { let sel = self.selection.anchor.clone(); if !sel.is_real() || !self.is_editable(&sel) { return false; @@ -117,6 +138,7 @@ impl EditorState { val: v, drag: None, anchor_y, + anchor_x, variable: None, alpha, }); @@ -135,6 +157,26 @@ impl EditorState { &mut self, variable: impl Into, anchor_y: f32, + ) -> bool { + self.open_color_picker_for_variable_with_anchor(variable, None, anchor_y) + } + + /// Same as [`Self::open_color_picker_for_variable`], but anchors + /// the floating picker near the clicked variable swatch. + pub fn open_color_picker_for_variable_at( + &mut self, + variable: impl Into, + anchor_x: f32, + anchor_y: f32, + ) -> bool { + self.open_color_picker_for_variable_with_anchor(variable, Some(anchor_x), anchor_y) + } + + fn open_color_picker_for_variable_with_anchor( + &mut self, + variable: impl Into, + anchor_x: Option, + anchor_y: f32, ) -> bool { let name = variable.into(); // Resolve the variable's current colour to seed HSV. Reject @@ -165,6 +207,7 @@ impl EditorState { val: v, drag: None, anchor_y, + anchor_x, variable: Some(name), alpha: 1.0, }); diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index fd82b4ab..639c6276 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -388,11 +388,14 @@ pub struct PageRenameState { pub draft: String, } -/// Editor focus for a non-color variable row in the VariablesPanel. +/// Editor focus for a variable row cell in the VariablesPanel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum VariableRowFocus { + Name(usize), Number(usize), String(usize), + NumberCell { row: usize, variant: usize }, + StringCell { row: usize, variant: usize }, } /// Keyboard focus on an effect-parameter value (the Effects @@ -500,6 +503,10 @@ pub struct EditorUiState { pub icon_picker_panel_pos: Option<(f32, f32)>, /// Live text filter for the native Lucide icon picker. pub icon_picker_search: String, + /// Blink anchor for the icon picker's focused search caret. + pub icon_picker_caret_anchor_ms: u64, + /// Vertical scroll offset for the native icon picker list. + pub icon_picker_scroll: f32, /// Remote Iconify search results appended by the desktop host. pub icon_picker_remote: crate::icon_picker_state::IconPickerRemoteState, /// Queued "load more" request drained asynchronously by desktop. @@ -533,6 +540,23 @@ pub struct EditorUiState { // --- Property panel: tabs + layout toggles --------------------- /// Active PropertyPanel tab — toggled by `Cmd+Shift+C`. pub property_tab: PropertyTab, + /// Floating Variables panel open, matching the TS toolbar's + /// `{}` button. + pub variables_panel_open: bool, + pub variables_preset_menu_open: bool, + pub variables_add_menu_open: bool, + /// Theme axis currently shown in the floating variables panel. + /// Separate from `ui.variables.active_theme`, which stores the + /// concrete value selected for each axis. + pub variables_current_axis: Option, + /// Theme-axis tab whose rename/delete menu is open. + pub variables_theme_menu_axis: Option, + /// Variant column whose rename/delete menu is open. + pub variables_variant_menu_value: Option, + /// Theme-axis name currently being edited in the VariablesPanel. + pub variables_theme_rename_axis: Option, + /// Variant column value currently being edited in the VariablesPanel. + pub variables_variant_rename_value: Option, /// Active flex-layout mode for the property panel's row. pub flex_layout: FlexLayout, pub size_fill_width: bool, @@ -546,6 +570,10 @@ pub struct EditorUiState { pub image_fill_popover_open: bool, /// Whether the text font-family picker is open. pub font_family_picker_open: bool, + /// Vertical scroll offset for the text font-family picker. + pub font_family_picker_scroll: f32, + /// Cached system font family names provided by the native host. + pub system_font_families: Vec, /// Active-theme axis whose value picker is open; `None` = closed. pub axis_dropdown_open: Option, /// Editor focus for a non-color variable row (Number / String). @@ -579,6 +607,9 @@ pub struct EditorUiState { /// Last canvas left-click target + ms; 400 ms same-node re-press /// on a Text node promotes to inline text edit. pub last_canvas_click: Option<(NodeId, u64)>, + /// Last VariablesPanel name-cell click + ms; 400 ms same-row + /// re-press promotes to variable rename. + pub last_variable_name_click: Option<(usize, u64)>, /// Smart-guide lines to paint during the current node drag — /// computed each `apply_cursor_move` by `align_guides`, cleared on /// drag release. View-only transient state: never serialized, @@ -676,6 +707,8 @@ impl Default for EditorUiState { icon_picker_replace_selection: false, icon_picker_panel_pos: None, icon_picker_search: String::new(), + icon_picker_caret_anchor_ms: 0, + icon_picker_scroll: 0.0, icon_picker_remote: crate::icon_picker_state::IconPickerRemoteState::default(), icon_picker_load_more_request: None, chat_model_picker_open: false, @@ -686,6 +719,14 @@ impl Default for EditorUiState { window_fullscreen: false, align_toolbar_hover: None, property_tab: PropertyTab::Design, + variables_panel_open: false, + variables_preset_menu_open: false, + variables_add_menu_open: false, + variables_current_axis: None, + variables_theme_menu_axis: None, + variables_variant_menu_value: None, + variables_theme_rename_axis: None, + variables_variant_rename_value: None, flex_layout: FlexLayout::Free, size_fill_width: false, size_fill_height: false, @@ -695,6 +736,8 @@ impl Default for EditorUiState { fill_type_picker_open: false, image_fill_popover_open: false, font_family_picker_open: false, + font_family_picker_scroll: 0.0, + system_font_families: Vec::new(), axis_dropdown_open: None, variable_row_focus: None, effect_param_focus: None, @@ -705,6 +748,7 @@ impl Default for EditorUiState { rename_caret_anchor_ms: 0, last_layer_click: None, last_canvas_click: None, + last_variable_name_click: None, active_guides: Vec::new(), update_status: UpdateStatus::Idle, git_panel: GitPanelState::default(), diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index e7aa9866..5837aba5 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -157,14 +157,10 @@ impl EditorState { .any(|id| find_node(children, id).is_some()) } - /// True when ANY widget occupies the right rail — PropertyPanel - /// (gated on selection) OR VariablesPanel (gated on a non-empty - /// persisted variable table). Faithful port of shell-core's - /// `Document::right_rail_visible`; the persisted variables that - /// shell-core read from `var_table.variables` live on - /// `EditorState.doc.variables` in the canonical model. + /// True when a widget occupies the right rail. The Variables panel is a + /// floating canvas overlay in the TS app, so it must not reserve rail width. pub fn right_rail_visible(&self) -> bool { - self.property_panel_visible() || self.doc.variables.as_ref().is_some_and(|v| !v.is_empty()) + self.property_panel_visible() } /// Union of `aggregate_bounds` across the selected nodes. diff --git a/crates/op-editor-core/src/toolbar_state.rs b/crates/op-editor-core/src/toolbar_state.rs index 427a398c..c868af1e 100644 --- a/crates/op-editor-core/src/toolbar_state.rs +++ b/crates/op-editor-core/src/toolbar_state.rs @@ -16,7 +16,7 @@ use crate::tool::Tool; pub enum ToolbarAction { Undo, Redo, - ToggleCodePanel, + ToggleVariablesPanel, ToggleDesignPanel, } diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index ee6af13c..c016e3a7 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -180,6 +180,9 @@ pub struct ColorPickerState { /// Viewport-y of the click that opened the picker — anchors the /// floating panel. pub anchor_y: f32, + /// Optional viewport-x of the click that opened the picker. When + /// absent, the widget keeps the legacy right-rail anchor. + pub anchor_x: Option, /// When `Some(name)`, the picker edits the named Color **variable** /// instead of the selected node's fill / stroke. The commit path /// (`color_picker_set_hsv`) then routes through diff --git a/crates/op-editor-core/src/variables.rs b/crates/op-editor-core/src/variables.rs index 7004c14c..1787dbd8 100644 --- a/crates/op-editor-core/src/variables.rs +++ b/crates/op-editor-core/src/variables.rs @@ -79,6 +79,23 @@ impl EditorState { self.set_variable_scalar(name, VariableKind::Number, VariableScalar::Num(value)) } + /// Write a number into one concrete theme value column. + pub fn set_variable_number_for_theme( + &mut self, + name: &str, + axis: &str, + theme_value: &str, + value: f64, + ) -> bool { + self.set_variable_scalar_for_theme( + name, + VariableKind::Number, + VariableScalar::Num(value), + axis, + theme_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( @@ -88,6 +105,23 @@ impl EditorState { ) } + /// Write a string into one concrete theme value column. + pub fn set_variable_string_for_theme( + &mut self, + name: &str, + axis: &str, + theme_value: &str, + value: impl Into, + ) -> bool { + self.set_variable_scalar_for_theme( + name, + VariableKind::String, + VariableScalar::Str(value.into()), + axis, + theme_value, + ) + } + /// 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)) @@ -113,6 +147,36 @@ impl EditorState { true } + fn set_variable_scalar_for_theme( + &mut self, + name: &str, + expect: VariableKind, + scalar: VariableScalar, + axis: &str, + theme_value: &str, + ) -> bool { + let Some(theme_values) = self + .doc + .themes + .as_ref() + .and_then(|themes| themes.get(axis)) + .cloned() + else { + return self.set_variable_scalar(name, expect, scalar); + }; + if !theme_values.iter().any(|value| value == theme_value) { + return false; + } + let Some(def) = self.variables_mut().get_mut(name) else { + return false; + }; + if def.kind != expect { + return false; + } + write_scalar_for_theme(&mut def.value, scalar, axis, theme_value, &theme_values); + true + } + // --- Variable lifecycle ----------------------------------------- /// Create a new theme-agnostic scalar variable. Rejects an empty @@ -322,6 +386,54 @@ fn write_scalar( } } +fn write_scalar_for_theme( + value: &mut VariableValue, + scalar: VariableScalar, + axis: &str, + theme_value: &str, + theme_values: &[String], +) { + match value { + VariableValue::Scalar(current) => { + let fallback = current.clone(); + let themed = theme_values + .iter() + .map(|value_name| { + let mut theme = BTreeMap::new(); + theme.insert(axis.to_string(), value_name.clone()); + ThemedValue { + value: if value_name == theme_value { + scalar.clone() + } else { + fallback.clone() + }, + theme: Some(theme), + } + }) + .collect(); + *value = VariableValue::Themed(themed); + } + VariableValue::Themed(entries) => { + if let Some(entry) = entries.iter_mut().find(|entry| { + entry + .theme + .as_ref() + .and_then(|theme| theme.get(axis)) + .is_some_and(|value| value == theme_value) + }) { + entry.value = scalar; + return; + } + let mut theme = BTreeMap::new(); + theme.insert(axis.to_string(), theme_value.to_string()); + entries.push(ThemedValue { + value: scalar, + theme: Some(theme), + }); + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/op-editor-ui/src/widgets/color_picker.rs b/crates/op-editor-ui/src/widgets/color_picker.rs index 6e99e4e4..d44832c1 100644 --- a/crates/op-editor-ui/src/widgets/color_picker.rs +++ b/crates/op-editor-ui/src/widgets/color_picker.rs @@ -57,11 +57,18 @@ impl ColorPicker { } /// Resolve the picker's anchor (top-left) in viewport coords — - /// pinned to the right rail near the Fill section. Hosts use - /// this for paint AND hit-test so they stay in sync. + /// pinned near an explicit click point when available, otherwise + /// 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.property_panel_width; - let x = (right_rail_left - PICKER_WIDTH - 8.0).max(8.0); + let x = self + .state + .anchor_x + .map(|anchor_x| anchored_x(anchor_x, viewport_w)) + .unwrap_or_else(|| { + let right_rail_left = viewport_w - self.property_panel_width; + (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. let mut y = self.state.anchor_y - PICKER_HEIGHT / 2.0; @@ -123,6 +130,18 @@ impl ColorPicker { } } +fn anchored_x(anchor_x: f32, viewport_w: f32) -> f32 { + let gap = 12.0; + let right = anchor_x + gap; + let max_x = (viewport_w - PICKER_WIDTH - 8.0).max(8.0); + let preferred = if right + PICKER_WIDTH <= viewport_w - 8.0 { + right + } else { + anchor_x - PICKER_WIDTH - gap + }; + preferred.clamp(8.0, max_x) +} + impl Widget for ColorPicker { fn id(&self) -> WidgetId { self.id diff --git a/crates/op-editor-ui/src/widgets/editor_state_ext.rs b/crates/op-editor-ui/src/widgets/editor_state_ext.rs index 032e77bc..67c2f040 100644 --- a/crates/op-editor-ui/src/widgets/editor_state_ext.rs +++ b/crates/op-editor-ui/src/widgets/editor_state_ext.rs @@ -130,7 +130,7 @@ pub fn toolbar_action(a: crate::widgets::toolbar::ToolbarAction) -> op_editor_co match a { W::Undo => O::Undo, W::Redo => O::Redo, - W::ToggleCodePanel => O::ToggleCodePanel, + W::ToggleVariablesPanel => O::ToggleVariablesPanel, W::ToggleDesignPanel => O::ToggleDesignPanel, } } diff --git a/crates/op-editor-ui/src/widgets/icon_picker_panel.rs b/crates/op-editor-ui/src/widgets/icon_picker_panel.rs index 0b84e3f3..0d8a9c31 100644 --- a/crates/op-editor-ui/src/widgets/icon_picker_panel.rs +++ b/crates/op-editor-ui/src/widgets/icon_picker_panel.rs @@ -15,10 +15,14 @@ const PAD: f32 = 14.0; const HEADER_H: f32 = 40.0; const SEARCH_H: f32 = 42.0; const CLOSE_BTN: f32 = 24.0; -const ROW_H: f32 = 34.0; -const ICON_SIZE: f32 = 17.0; -const ROW_PAD_X: f32 = 10.0; -const CHAR_W: f32 = 6.0; +const GRID_COLS: usize = 6; +const GRID_CELL: f32 = 40.0; +const GRID_GAP: f32 = 8.0; +const GRID_PITCH: f32 = GRID_CELL + GRID_GAP; +const GRID_ICON: f32 = 18.0; +const LOAD_MORE_H: f32 = 32.0; +const LOAD_MORE_GAP: f32 = 8.0; +const LOAD_MORE_INSET: f32 = 10.0; const LOCAL_LIMIT: usize = 120; pub const ICONIFY_LOAD_MORE_LIMIT: usize = 48; @@ -56,10 +60,15 @@ pub struct IconPickerPanel<'a> { state: &'a EditorState, theme: Theme, locale: Locale, + now_ms: u64, } impl<'a> IconPickerPanel<'a> { pub fn for_editor(state: &'a EditorState) -> Option> { + Self::for_editor_at(state, 0) + } + + pub fn for_editor_at(state: &'a EditorState, now_ms: u64) -> Option> { if !state.editor_ui.icon_picker_open { return None; } @@ -67,6 +76,7 @@ impl<'a> IconPickerPanel<'a> { state, theme: theme_for(&state.editor_ui), locale: state.editor_ui.locale, + now_ms, }) } @@ -103,6 +113,10 @@ impl<'a> IconPickerPanel<'a> { rows } + fn all_rows(&self) -> Vec> { + self.rows(usize::MAX) + } + fn has_load_more(&self) -> bool { let query = self.query(); if query.is_empty() { @@ -115,6 +129,24 @@ impl<'a> IconPickerPanel<'a> { remote.query != query || remote.next_start < remote.total } + fn footer_visible(&self) -> bool { + self.state.editor_ui.icon_picker_search.trim().is_empty() || self.has_load_more() + } + + fn footer_can_load_more(&self) -> bool { + !self.state.editor_ui.icon_picker_search.trim().is_empty() + && self.has_load_more() + && !self.state.editor_ui.icon_picker_remote.loading + } + + fn search_caret_visible(&self) -> bool { + jian_core::anim::blink_visible( + self.now_ms, + self.state.editor_ui.icon_picker_caret_anchor_ms, + 500, + ) + } + fn close_rect(panel: Rect) -> Rect { let y = panel.origin.y + (HEADER_H - CLOSE_BTN) / 2.0; Rect { @@ -134,10 +166,101 @@ impl<'a> IconPickerPanel<'a> { panel.origin.y + HEADER_H + SEARCH_H } - fn visible_row_capacity(panel: Rect) -> usize { - ((panel.size.y - HEADER_H - SEARCH_H - PAD) / ROW_H) - .floor() - .max(0.0) as usize + fn list_rect(panel: Rect) -> Rect { + Self::list_rect_for(panel, false) + } + + fn list_rect_for(panel: Rect, footer_visible: bool) -> Rect { + let top = Self::list_top(panel); + let footer_h = if footer_visible { + LOAD_MORE_H + LOAD_MORE_GAP + } else { + 0.0 + }; + Rect { + origin: Point2D::new(panel.origin.x + 6.0, top), + size: Point2D::new( + panel.size.x - 12.0, + (panel.origin.y + panel.size.y - PAD - footer_h - top).max(0.0), + ), + } + } + + fn grid_origin(panel: Rect) -> Point2D { + let list = Self::list_rect(panel); + let grid_w = GRID_COLS as f32 * GRID_CELL + (GRID_COLS.saturating_sub(1)) as f32 * GRID_GAP; + Point2D::new( + list.origin.x + (list.size.x - grid_w).max(0.0) / 2.0, + list.origin.y, + ) + } + + fn grid_rows(item_count: usize) -> usize { + item_count.div_ceil(GRID_COLS) + } + + fn cell_rect(panel: Rect, index: usize) -> Rect { + let origin = Self::grid_origin(panel); + let col = index % GRID_COLS; + let row = index / GRID_COLS; + Rect { + origin: Point2D::new( + origin.x + col as f32 * GRID_PITCH, + origin.y + row as f32 * GRID_PITCH, + ), + size: Point2D::new(GRID_CELL, GRID_CELL), + } + } + + fn load_more_rect(panel: Rect) -> Rect { + Rect { + origin: Point2D::new( + panel.origin.x + PAD + LOAD_MORE_INSET, + panel.origin.y + panel.size.y - PAD - LOAD_MORE_H, + ), + size: Point2D::new(panel.size.x - (PAD + LOAD_MORE_INSET) * 2.0, LOAD_MORE_H), + } + } + + fn content_height(item_count: usize) -> f32 { + Self::grid_rows(item_count) as f32 * GRID_PITCH + } + + fn max_scroll_for(panel: Rect, item_count: usize, footer_visible: bool) -> f32 { + (Self::content_height(item_count) - Self::list_rect_for(panel, footer_visible).size.y) + .max(0.0) + } + + #[cfg(test)] + fn visible_index_range(panel: Rect, item_count: usize, scroll: f32) -> std::ops::Range { + Self::visible_index_range_for_list(Self::list_rect(panel), item_count, scroll) + } + + fn visible_index_range_for_list( + list: Rect, + item_count: usize, + scroll: f32, + ) -> std::ops::Range { + if item_count == 0 { + return 0..0; + } + let start_row = (scroll / GRID_PITCH).floor().max(0.0) as usize; + let row_count = (list.size.y / GRID_PITCH).ceil() as usize + 2; + let start = (start_row * GRID_COLS).min(item_count); + let end = ((start_row + row_count) * GRID_COLS).min(item_count); + start..end + } + + pub fn max_scroll(&self, panel: Rect) -> f32 { + let items = self.all_rows().len(); + Self::max_scroll_for(panel, items, self.footer_visible()) + } + + fn scroll_for(&self, panel: Rect, item_count: usize, footer_visible: bool) -> f32 { + self.state + .editor_ui + .icon_picker_scroll + .clamp(0.0, Self::max_scroll_for(panel, item_count, footer_visible)) } pub fn hit_test(&self, panel: Rect, point: Point2D) -> Option { @@ -150,27 +273,51 @@ impl<'a> IconPickerPanel<'a> { if point.y <= panel.origin.y + HEADER_H { return Some(IconPickerHit::DragHeader); } - let list_top = Self::list_top(panel); - if point.y >= list_top { - let row = ((point.y - list_top) / ROW_H) as usize; - let capacity = Self::visible_row_capacity(panel); - let has_more = self.has_load_more(); - let item_cap = capacity.saturating_sub(usize::from(has_more)); - let items = self.rows(item_cap); - if row < items.len() { - let item = &items[row]; + let footer_visible = self.footer_visible(); + if footer_visible && rect_contains(Self::load_more_rect(panel), point) { + return if self.footer_can_load_more() { + Some(IconPickerHit::LoadMore) + } else { + Some(IconPickerHit::Inside) + }; + } + let list = Self::list_rect_for(panel, footer_visible); + if rect_contains(list, point) { + let items = self.all_rows(); + let scrolled = Point2D::new( + point.x, + point.y + self.scroll_for(panel, items.len(), footer_visible), + ); + if let Some(index) = Self::cell_index_at(panel, scrolled, items.len()) { + let item = &items[index]; return Some(IconPickerHit::SelectIcon { collection: item.collection().to_string(), name: item.name().to_string(), }); } - if has_more && row == items.len() && !self.state.editor_ui.icon_picker_remote.loading { - return Some(IconPickerHit::LoadMore); - } } Some(IconPickerHit::Inside) } + fn cell_index_at(panel: Rect, point: Point2D, item_count: usize) -> Option { + let origin = Self::grid_origin(panel); + let local_x = point.x - origin.x; + let local_y = point.y - origin.y; + if local_x < 0.0 || local_y < 0.0 { + return None; + } + let col = (local_x / GRID_PITCH).floor() as usize; + let row = (local_y / GRID_PITCH).floor() as usize; + if col >= GRID_COLS { + return None; + } + let index = row * GRID_COLS + col; + if index >= item_count || !rect_contains(Self::cell_rect(panel, index), point) { + return None; + } + Some(index) + } + pub fn paint(&self, cx: &mut PaintCx<'_>, panel: Rect) { cx.backend.fill_round_rect(panel, 8.0, self.theme.popover); cx.backend @@ -178,20 +325,25 @@ impl<'a> IconPickerPanel<'a> { self.paint_header(cx, panel); self.paint_search(cx, panel); - let rows = Self::visible_row_capacity(panel); - let has_more = self.has_load_more(); - let item_cap = rows.saturating_sub(usize::from(has_more)); - let filtered = self.rows(item_cap); - if filtered.is_empty() && !has_more { + let footer_visible = self.footer_visible(); + let filtered = self.all_rows(); + if filtered.is_empty() && !footer_visible { self.paint_empty(cx, panel); return; } - for (idx, item) in filtered.iter().enumerate() { - self.paint_row(cx, panel, idx, item); + let list = Self::list_rect_for(panel, footer_visible); + let scroll = self.scroll_for(panel, filtered.len(), footer_visible); + cx.backend.save(); + cx.backend.clip_rect(list); + cx.backend.translate(Point2D::new(0.0, -scroll)); + for idx in Self::visible_index_range_for_list(list, filtered.len(), scroll) { + self.paint_cell(cx, panel, idx, &filtered[idx]); } - if has_more && rows > 0 { - self.paint_load_more(cx, panel, filtered.len()); + cx.backend.restore(); + if footer_visible { + self.paint_load_more(cx, panel); } + self.paint_scrollbar(cx, panel, filtered.len(), footer_visible, scroll); } fn paint_header(&self, cx: &mut PaintCx<'_>, panel: Rect) { @@ -250,6 +402,15 @@ impl<'a> IconPickerPanel<'a> { &layout, Point2D::new(search.origin.x + 30.0, search.origin.y + 18.0), ); + if self.search_caret_visible() { + let text_x = search.origin.x + 30.0; + let caret_x = text_x + cx.backend.measure_text(raw_query, 12.0); + let caret = Rect { + origin: Point2D::new(caret_x + 1.0, search.origin.y + 7.0), + size: Point2D::new(1.5, 15.0), + }; + cx.backend.fill_rect(caret, self.theme.foreground); + } } fn paint_empty(&self, cx: &mut PaintCx<'_>, panel: Rect) { @@ -266,20 +427,19 @@ impl<'a> IconPickerPanel<'a> { ); } - fn paint_row(&self, cx: &mut PaintCx<'_>, panel: Rect, idx: usize, item: &IconRow<'_>) { - let y = Self::list_top(panel) + idx as f32 * ROW_H; - let row = Rect { - origin: Point2D::new(panel.origin.x + 6.0, y), - size: Point2D::new(panel.size.x - 12.0, ROW_H), - }; - cx.backend.fill_round_rect(row, 6.0, self.theme.popover); - let icon_pos = Point2D::new(row.origin.x + ROW_PAD_X, y + (ROW_H - ICON_SIZE) / 2.0); + fn paint_cell(&self, cx: &mut PaintCx<'_>, panel: Rect, idx: usize, item: &IconRow<'_>) { + let cell = Self::cell_rect(panel, idx); + cx.backend.fill_round_rect(cell, 6.0, self.theme.popover); + let icon_pos = Point2D::new( + cell.origin.x + (cell.size.x - GRID_ICON) / 2.0, + cell.origin.y + (cell.size.y - GRID_ICON) / 2.0, + ); match item { IconRow::Local(icon) => draw_icon_catalog_entry( cx.backend, icon, icon_pos, - ICON_SIZE, + GRID_ICON, self.theme.foreground, 1.5, ), @@ -291,50 +451,111 @@ impl<'a> IconPickerPanel<'a> { viewbox: icon.width.max(icon.height), }, icon_pos, - ICON_SIZE, + GRID_ICON, self.theme.foreground, 1.5, ), } - let label = truncate( - &format!("{}:{}", item.collection(), item.name()), - ((row.size.x - 54.0) / CHAR_W) as usize, - ); - let text = TextLayout::single_run( - &label, - "system-ui", - 12.0, - to_jian(self.theme.foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend - .draw_text(&text, Point2D::new(row.origin.x + 38.0, y + 21.0)); } - fn paint_load_more(&self, cx: &mut PaintCx<'_>, panel: Rect, idx: usize) { - let y = Self::list_top(panel) + idx as f32 * ROW_H; - let row = Rect { - origin: Point2D::new(panel.origin.x + 6.0, y + 2.0), - size: Point2D::new(panel.size.x - 12.0, ROW_H - 4.0), - }; - cx.backend.fill_round_rect(row, 6.0, self.theme.muted); - let label = if self.state.editor_ui.icon_picker_remote.loading { - "..." + fn paint_load_more(&self, cx: &mut PaintCx<'_>, panel: Rect) { + let button = Self::load_more_rect(panel); + let divider_y = button.origin.y - LOAD_MORE_GAP / 2.0; + cx.backend.stroke_line( + Point2D::new(panel.origin.x + PAD, divider_y), + Point2D::new(panel.origin.x + panel.size.x - PAD, divider_y), + with_alpha(self.theme.border, 0.85), + 1.0, + ); + let raw_query = self.state.editor_ui.icon_picker_search.trim(); + let (label, color, fill, border, icon) = if raw_query.is_empty() { + ( + self.t("icon.typeToSearch"), + self.theme.muted_foreground, + with_alpha(self.theme.muted, 0.55), + with_alpha(self.theme.border, 0.65), + Icon::Search, + ) + } else if self.state.editor_ui.icon_picker_remote.loading { + ( + "...", + self.theme.primary, + self.theme.row_selected_primary, + with_alpha(self.theme.primary, 0.35), + Icon::Loader, + ) } else { - self.t("git.history.loadMore") + ( + self.t("git.history.loadMore"), + self.theme.primary, + self.theme.row_selected_primary, + with_alpha(self.theme.primary, 0.35), + Icon::Plus, + ) }; + cx.backend.fill_round_rect(button, 8.0, fill); + cx.backend.stroke_round_rect(button, 8.0, border, 1.0); + let text_size = 12.0; + let icon_size = 13.0; + let gap = 7.0; + let text_w = cx.backend.measure_text(label, text_size); + let content_w = icon_size + gap + text_w; + let content_x = button.origin.x + (button.size.x - content_w).max(0.0) / 2.0; + draw_icon( + cx.backend, + icon, + Point2D::new( + content_x, + button.origin.y + (button.size.y - icon_size) / 2.0, + ), + icon_size, + color, + 1.5, + ); let text = TextLayout::single_run( label, "system-ui", - 12.0, - to_jian(self.theme.foreground), + text_size, + to_jian(color), Point2D::new(0.0, 0.0), ); cx.backend.draw_text( &text, - Point2D::new(row.origin.x + ROW_PAD_X, row.origin.y + 20.0), + Point2D::new(content_x + icon_size + gap, button.origin.y + 20.0), ); } + + fn paint_scrollbar( + &self, + cx: &mut PaintCx<'_>, + panel: Rect, + item_count: usize, + footer_visible: bool, + scroll: f32, + ) { + let list = Self::list_rect_for(panel, footer_visible); + let content_h = Self::content_height(item_count); + if content_h <= list.size.y + 0.5 { + return; + } + let track_h = list.size.y - 8.0; + let thumb_h = (track_h * list.size.y / content_h).max(24.0); + let max_scroll = (content_h - list.size.y).max(0.0); + let t = if max_scroll > 0.0 { + (scroll / max_scroll).clamp(0.0, 1.0) + } else { + 0.0 + }; + let thumb = Rect { + origin: Point2D::new( + panel.origin.x + panel.size.x - 8.0, + list.origin.y + 4.0 + t * (track_h - thumb_h), + ), + size: Point2D::new(3.0, thumb_h), + }; + cx.backend + .fill_round_rect(thumb, 1.5, self.theme.muted_foreground); + } } fn remote_style(style: &str) -> IconRenderStyle { @@ -345,15 +566,6 @@ fn remote_style(style: &str) -> IconRenderStyle { } } -fn truncate(text: &str, max_chars: usize) -> String { - if text.chars().count() <= max_chars { - return text.to_string(); - } - let mut out: String = text.chars().take(max_chars.saturating_sub(1)).collect(); - out.push_str("..."); - out -} - fn rect_contains(r: Rect, p: Point2D) -> bool { p.x >= r.origin.x && p.x <= r.origin.x + r.size.x @@ -361,9 +573,127 @@ fn rect_contains(r: Rect, p: Point2D) -> bool { && p.y <= r.origin.y + r.size.y } +fn with_alpha(mut c: Color, alpha: f32) -> Color { + c.a *= alpha.clamp(0.0, 1.0); + c +} + fn to_jian(c: Color) -> jian_core::scene::Color { fn ch(v: f32) -> u8 { (v.clamp(0.0, 1.0) * 255.0).round() as u8 } jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) } + +#[cfg(test)] +mod tests { + use super::*; + + fn open_state(scroll: f32) -> EditorState { + let mut state = EditorState::starter(); + state.editor_ui.icon_picker_open = true; + state.editor_ui.icon_picker_scroll = scroll; + state + } + + fn selected_name(hit: IconPickerHit) -> String { + match hit { + IconPickerHit::SelectIcon { name, .. } => name, + other => panic!("expected selectable icon row, got {other:?}"), + } + } + + #[test] + fn hit_test_honors_icon_picker_scroll() { + let panel_rect = Rect { + origin: Point2D::new(40.0, 40.0), + size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H), + }; + let first_cell = IconPickerPanel::cell_rect(panel_rect, 0); + let point = Point2D::new( + first_cell.origin.x + first_cell.size.x / 2.0, + first_cell.origin.y + first_cell.size.y / 2.0, + ); + + let state = open_state(0.0); + let panel = IconPickerPanel::for_editor(&state).expect("picker open"); + assert!(panel.max_scroll(panel_rect) > GRID_PITCH * 2.0); + let first = selected_name(panel.hit_test(panel_rect, point).expect("first row")); + + let state = open_state(GRID_PITCH * 3.0); + let panel = IconPickerPanel::for_editor(&state).expect("picker open"); + let scrolled = selected_name(panel.hit_test(panel_rect, point).expect("scrolled row")); + + assert_ne!(first, scrolled); + } + + #[test] + fn visible_index_range_is_limited_to_visible_grid_rows() { + let panel_rect = Rect { + origin: Point2D::new(40.0, 40.0), + size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H), + }; + + let range = IconPickerPanel::visible_index_range(panel_rect, 120, GRID_PITCH * 3.0); + + assert!(range.start > 0); + assert!(range.end < 120); + assert!(range.end - range.start <= GRID_COLS * 9); + } + + #[test] + fn load_more_footer_is_clickable_without_scrolling_to_grid_end() { + let panel_rect = Rect { + origin: Point2D::new(40.0, 40.0), + size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H), + }; + let mut state = open_state(0.0); + state.editor_ui.icon_picker_search = "home".to_string(); + let panel = IconPickerPanel::for_editor(&state).expect("picker open"); + let row = IconPickerPanel::load_more_rect(panel_rect); + let point = Point2D::new( + row.origin.x + row.size.x / 2.0, + row.origin.y + row.size.y / 2.0, + ); + + assert_eq!( + panel.hit_test(panel_rect, point), + Some(IconPickerHit::LoadMore) + ); + } + + #[test] + fn empty_query_footer_is_visible_but_disabled() { + let panel_rect = Rect { + origin: Point2D::new(40.0, 40.0), + size: Point2D::new(ICON_PICKER_PANEL_W, ICON_PICKER_PANEL_H), + }; + let state = open_state(0.0); + let panel = IconPickerPanel::for_editor(&state).expect("picker open"); + let row = IconPickerPanel::load_more_rect(panel_rect); + let point = Point2D::new( + row.origin.x + row.size.x / 2.0, + row.origin.y + row.size.y / 2.0, + ); + + assert_eq!( + panel.hit_test(panel_rect, point), + Some(IconPickerHit::Inside) + ); + assert!( + IconPickerPanel::list_rect_for(panel_rect, true).size.y + < IconPickerPanel::list_rect(panel_rect).size.y + ); + let expected = IconPickerPanel::max_scroll_for(panel_rect, panel.all_rows().len(), true); + assert!((panel.max_scroll(panel_rect) - expected).abs() < 0.01); + } + + #[test] + fn search_caret_is_visible_at_blink_anchor() { + let mut state = open_state(0.0); + state.editor_ui.icon_picker_caret_anchor_ms = 1200; + let panel = IconPickerPanel::for_editor_at(&state, 1200).expect("picker open"); + + assert!(panel.search_caret_visible()); + } +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index d5e8947f..90574722 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -47,6 +47,7 @@ pub mod property_panel_effects; pub mod property_panel_export; pub mod property_panel_fill; pub mod property_panel_flex; +pub mod property_panel_font_picker; pub mod property_panel_icon; #[cfg(test)] mod property_panel_icon_tests; diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index cbe15bb1..50a1ae69 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -39,8 +39,8 @@ pub const PROPERTY_PANEL_WIDTH: f32 = 280.0; // `widgets::PropertyPanelAction` / `property_panel::PropertyPanelAction` // path is unchanged. pub use crate::widgets::property_panel_action::{ - FontFamilyChoice, LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, TextAlignValue, - TextGrowthValue, TextVerticalAlignValue, + LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, TextAlignValue, TextGrowthValue, + TextVerticalAlignValue, }; // `SectionCapabilities` lives in `property_panel_layout.rs` @@ -84,6 +84,8 @@ pub struct PropertyPanel { pub fill_type_picker_open: bool, pub image_fill_popover_open: bool, pub font_family_picker_open: bool, + pub font_family_picker_scroll: f32, + pub system_font_families: Vec, /// True for multi-select aggregate (inputs inert, "N items"). pub is_multi: bool, /// Active header tab — toggled by Cmd+Shift+C. @@ -212,6 +214,8 @@ impl PropertyPanel { fill_type_picker_open: ui.fill_type_picker_open, image_fill_popover_open: ui.image_fill_popover_open, font_family_picker_open: ui.font_family_picker_open, + font_family_picker_scroll: ui.font_family_picker_scroll, + system_font_families: ui.system_font_families.clone(), is_multi, tab: ui.property_tab, export_format: ui.export_format, @@ -320,17 +324,42 @@ impl PropertyPanel { return Some(action); } } + if self.font_family_picker_open { + if let Some(text) = self.snapshot.text.as_ref() { + if let Some(action) = + crate::widgets::property_panel_font_picker::font_family_picker_action_at( + self.scrolled_rect(panel_rect), + self.visible_sections(), + &text.font_family, + &self.system_font_families, + self.font_family_picker_scroll, + point, + ) + { + return Some(action); + } + } + } if !self.point_in_section_viewport(panel_rect, point) { return None; } - let rects = sections::action_button_rects_with_fill_picker( + let rects = sections::action_button_rects_with_font_picker( self.scrolled_rect(panel_rect), self.visible_sections(), &self.snapshot.effects, - self.fill_type_picker_open, - self.font_family_picker_open, - self.export_scale_picker_open, - self.export_format_picker_open, + sections::ActionButtonRectOptions { + system_fonts: &self.system_font_families, + active_font_family: self + .snapshot + .text + .as_ref() + .map(|text| text.font_family.as_str()) + .unwrap_or(""), + fill_picker_open: self.fill_type_picker_open, + font_family_picker_open: self.font_family_picker_open, + export_scale_picker_open: self.export_scale_picker_open, + export_format_picker_open: self.export_format_picker_open, + }, ); // Picker rows live in `rects` AFTER the dropdown rect, so // a row hit takes priority — `rev()` makes the picker rows @@ -356,14 +385,23 @@ impl PropertyPanel { if !self.point_in_section_viewport(panel_rect, point) { return None; } - sections::action_button_rects_with_fill_picker( + sections::action_button_rects_with_font_picker( self.scrolled_rect(panel_rect), self.visible_sections(), &self.snapshot.effects, - self.fill_type_picker_open, - self.font_family_picker_open, - self.export_scale_picker_open, - self.export_format_picker_open, + sections::ActionButtonRectOptions { + system_fonts: &self.system_font_families, + active_font_family: self + .snapshot + .text + .as_ref() + .map(|text| text.font_family.as_str()) + .unwrap_or(""), + fill_picker_open: self.fill_type_picker_open, + font_family_picker_open: self.font_family_picker_open, + export_scale_picker_open: self.export_scale_picker_open, + export_format_picker_open: self.export_format_picker_open, + }, ) .into_iter() .filter(|(a, _)| { @@ -375,6 +413,40 @@ impl PropertyPanel { .position(|(_, rect)| rect_contains(rect, point)) } + pub fn font_family_picker_bounds(&self, panel_rect: Rect) -> Option { + let text = self.snapshot.text.as_ref()?; + crate::widgets::property_panel_font_picker::font_family_picker_rect( + self.scrolled_rect(panel_rect), + self.visible_sections(), + &text.font_family, + &self.system_font_families, + ) + } + + pub fn font_family_picker_max_scroll(&self, panel_rect: Rect) -> f32 { + let Some(text) = self.snapshot.text.as_ref() else { + return 0.0; + }; + crate::widgets::property_panel_font_picker::font_family_picker_max_scroll( + self.scrolled_rect(panel_rect), + self.visible_sections(), + &text.font_family, + &self.system_font_families, + ) + } + + pub fn font_family_picker_row_rect(&self, panel_rect: Rect, family: &str) -> Option { + let text = self.snapshot.text.as_ref()?; + crate::widgets::property_panel_font_picker::font_family_picker_row_rect( + self.scrolled_rect(panel_rect), + self.visible_sections(), + &text.font_family, + &self.system_font_families, + self.font_family_picker_scroll, + family, + ) + } + pub fn image_adjustment_drag_action( &self, panel_rect: Rect, @@ -654,12 +726,14 @@ impl Widget for PropertyPanel { } if caps.text && self.font_family_picker_open { if let Some(text) = self.snapshot.text.as_ref() { - crate::widgets::property_panel_text::paint_font_family_picker( + crate::widgets::property_panel_font_picker::paint_font_family_picker( cx, &self.theme, scrolled, self.visible_sections(), &text.font_family, + &self.system_font_families, + self.font_family_picker_scroll, ); } } diff --git a/crates/op-editor-ui/src/widgets/property_panel_action.rs b/crates/op-editor-ui/src/widgets/property_panel_action.rs index f4339c64..83a45586 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_action.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_action.rs @@ -25,64 +25,23 @@ pub enum TextGrowthValue { FixedWidthHeight, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FontFamilyChoice { - Inter, - Poppins, - Roboto, - Montserrat, - OpenSans, - Lato, - Raleway, - DmSans, - PlayfairDisplay, - Nunito, - SourceSans3, - Arial, - Helvetica, - Georgia, - CourierNew, -} - -impl FontFamilyChoice { - pub const ALL: [Self; 15] = [ - Self::Inter, - Self::Poppins, - Self::Roboto, - Self::Montserrat, - Self::OpenSans, - Self::Lato, - Self::Raleway, - Self::DmSans, - Self::PlayfairDisplay, - Self::Nunito, - Self::SourceSans3, - Self::Arial, - Self::Helvetica, - Self::Georgia, - Self::CourierNew, - ]; - - pub fn family(self) -> &'static str { - match self { - Self::Inter => "Inter", - Self::Poppins => "Poppins", - Self::Roboto => "Roboto", - Self::Montserrat => "Montserrat", - Self::OpenSans => "Open Sans", - Self::Lato => "Lato", - Self::Raleway => "Raleway", - Self::DmSans => "DM Sans", - Self::PlayfairDisplay => "Playfair Display", - Self::Nunito => "Nunito", - Self::SourceSans3 => "Source Sans 3", - Self::Arial => "Arial", - Self::Helvetica => "Helvetica", - Self::Georgia => "Georgia", - Self::CourierNew => "Courier New", - } - } -} +pub const BUILT_IN_FONT_FAMILIES: [&str; 15] = [ + "Inter", + "Poppins", + "Roboto", + "Montserrat", + "Open Sans", + "Lato", + "Raleway", + "DM Sans", + "Playfair Display", + "Nunito", + "Source Sans 3", + "Arial", + "Helvetica", + "Georgia", + "Courier New", +]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LayoutAlignValue { @@ -105,7 +64,7 @@ pub enum LayoutJustifyValue { /// after the text-input hit-test misses. /// /// `PartialEq` only (not `Eq`) — `AdjustEffectParam` carries an `f32`. -#[derive(Debug, Clone, Copy, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub enum PropertyPanelAction { SetFlexLayout(op_editor_core::FlexLayout), ToggleSizeFillWidth, @@ -207,5 +166,5 @@ pub enum PropertyPanelAction { SetTextVerticalAlign(TextVerticalAlignValue), SetTextGrowth(TextGrowthValue), ToggleFontFamilyPicker, - SetFontFamily(FontFamilyChoice), + SetFontFamily(String), } diff --git a/crates/op-editor-ui/src/widgets/property_panel_font_picker.rs b/crates/op-editor-ui/src/widgets/property_panel_font_picker.rs new file mode 100644 index 00000000..67a196f7 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_font_picker.rs @@ -0,0 +1,396 @@ +//! Scrollable font-family picker for the native text property section. + +use crate::theme::Theme; +use crate::widgets::icons::{draw_icon, Icon}; +use crate::widgets::property_panel::PropertyPanelAction; +use crate::widgets::property_panel_action::BUILT_IN_FONT_FAMILIES; +use crate::widgets::property_panel_inputs::{ + to_jian_color, INPUT_HEIGHT, PAD_X, SECTION_GAP, SECTION_HEADER_HEIGHT, TAB_HEIGHT, +}; +use crate::widgets::property_panel_layout::VisibleSections; +use crate::widgets::PaintCx; +use crate::{Point2D, Rect, TextLayout}; +use std::collections::HashSet; + +const BUTTON_H: f32 = 28.0; +const TEXT_LAYOUT_BLOCK_H: f32 = SECTION_HEADER_HEIGHT + BUTTON_H + 12.0; +const FONT_ROW_H: f32 = 28.0; +const FONT_PICKER_PAD_Y: f32 = 6.0; +const FONT_PICKER_MAX_H: f32 = 320.0; +const CJK_FONT_PRIORITY: [&str; 16] = [ + "pingfang sc", + "hiragino sans gb", + "songti sc", + "heiti sc", + "kaiti sc", + "yuanti sc", + "stheiti", + "stsong", + "stkaiti", + "microsoft yahei", + "simsun", + "simhei", + "noto sans cjk", + "noto serif cjk", + "source han", + "wenquanyi", +]; +const CJK_FONT_KEYWORDS: [&str; 25] = [ + "pingfang", + "hiragino", + "songti", + "heiti", + "kaiti", + "yuanti", + "stheiti", + "stsong", + "stkaiti", + "stfangsong", + "yahei", + "simsun", + "simhei", + "fangsong", + "mingliu", + "lihei", + "lisong", + "apple li", + "noto sans cjk", + "noto serif cjk", + "noto sans sc", + "noto serif sc", + "source han", + "wenquanyi", + "hanwang", +]; + +pub fn prepare_system_font_families(system_fonts: Vec) -> Vec { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + for family in prioritized_system_fonts(&system_fonts) { + push_font_family(&mut out, &mut seen, &family); + } + out +} + +pub fn font_family_options(system_fonts: &[String], active_family: &str) -> Vec { + font_family_option_refs(system_fonts, active_family) + .into_iter() + .map(str::to_string) + .collect() +} + +fn font_family_option_refs<'a>(system_fonts: &'a [String], active_family: &'a str) -> Vec<&'a str> { + let mut out = Vec::new(); + let mut seen = HashSet::new(); + push_font_family_ref(&mut out, &mut seen, display_font_family(active_family)); + for family in BUILT_IN_FONT_FAMILIES { + push_font_family_ref(&mut out, &mut seen, family); + } + for family in system_fonts { + push_font_family_ref(&mut out, &mut seen, family); + } + out +} + +pub fn font_family_picker_rect( + panel_rect: Rect, + visible: VisibleSections, + active_family: &str, + system_fonts: &[String], +) -> Option { + let rows = font_family_option_refs(system_fonts, active_family).len(); + if rows == 0 { + return None; + } + let x0 = panel_rect.origin.x; + let usable_w = panel_rect.size.x - PAD_X * 2.0; + let text_y = text_section_top(panel_rect, visible)?; + let content_h = rows as f32 * FONT_ROW_H + FONT_PICKER_PAD_Y * 2.0; + Some(Rect { + origin: Point2D::new( + x0 + PAD_X, + text_y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT - 2.0, + ), + size: Point2D::new(usable_w, content_h.min(FONT_PICKER_MAX_H)), + }) +} + +pub fn font_family_picker_max_scroll( + panel_rect: Rect, + visible: VisibleSections, + active_family: &str, + system_fonts: &[String], +) -> f32 { + let rows = font_family_option_refs(system_fonts, active_family).len(); + let Some(picker) = font_family_picker_rect(panel_rect, visible, active_family, system_fonts) + else { + return 0.0; + }; + let content_h = rows as f32 * FONT_ROW_H + FONT_PICKER_PAD_Y * 2.0; + (content_h - picker.size.y).max(0.0) +} + +pub fn font_family_picker_row_rect( + panel_rect: Rect, + visible: VisibleSections, + active_family: &str, + system_fonts: &[String], + scroll: f32, + target_family: &str, +) -> Option { + let picker = font_family_picker_rect(panel_rect, visible, active_family, system_fonts)?; + let options = font_family_option_refs(system_fonts, active_family); + let index = options.iter().position(|family| { + *family == target_family || display_font_family(family) == target_family + })?; + let max = font_family_picker_max_scroll(panel_rect, visible, active_family, system_fonts); + let y = + picker.origin.y + FONT_PICKER_PAD_Y + index as f32 * FONT_ROW_H - scroll.clamp(0.0, max); + let row = Rect { + origin: Point2D::new(picker.origin.x, y), + size: Point2D::new(picker.size.x, FONT_ROW_H), + }; + if row.origin.y + row.size.y < picker.origin.y || row.origin.y > picker.origin.y + picker.size.y + { + return None; + } + Some(row) +} + +pub fn font_family_picker_action_at( + panel_rect: Rect, + visible: VisibleSections, + active_family: &str, + system_fonts: &[String], + scroll: f32, + point: Point2D, +) -> Option { + let picker = font_family_picker_rect(panel_rect, visible, active_family, system_fonts)?; + if !rect_contains(picker, point) { + return None; + } + let options = font_family_option_refs(system_fonts, active_family); + let max = font_family_picker_max_scroll(panel_rect, visible, active_family, system_fonts); + let local_y = point.y - picker.origin.y - FONT_PICKER_PAD_Y + scroll.clamp(0.0, max); + if local_y < 0.0 { + return None; + } + let index = (local_y / FONT_ROW_H).floor() as usize; + options + .get(index) + .map(|family| (*family).to_string()) + .map(PropertyPanelAction::SetFontFamily) +} + +pub fn paint_font_family_picker( + cx: &mut PaintCx<'_>, + theme: &Theme, + panel_rect: Rect, + visible: VisibleSections, + active_family: &str, + system_fonts: &[String], + scroll: f32, +) { + let Some(picker) = font_family_picker_rect(panel_rect, visible, active_family, system_fonts) + else { + return; + }; + let options = font_family_option_refs(system_fonts, active_family); + let max = font_family_picker_max_scroll(panel_rect, visible, active_family, system_fonts); + let scroll = scroll.clamp(0.0, max); + cx.backend.fill_round_rect(picker, 8.0, theme.popover); + cx.backend.stroke_round_rect(picker, 8.0, theme.border, 1.0); + cx.backend.save(); + cx.backend.clip_rect(picker); + cx.backend.translate(Point2D::new(0.0, -scroll)); + let active = display_font_family(active_family); + let start = (scroll / FONT_ROW_H).floor().max(0.0) as usize; + let visible_count = (picker.size.y / FONT_ROW_H).ceil() as usize + 2; + for (index, family) in options.iter().enumerate().skip(start).take(visible_count) { + let row = Rect { + origin: Point2D::new( + picker.origin.x, + picker.origin.y + FONT_PICKER_PAD_Y + index as f32 * FONT_ROW_H, + ), + size: Point2D::new(picker.size.x, FONT_ROW_H), + }; + paint_font_row( + cx, + theme, + row, + family, + display_font_family(family) == active, + ); + } + cx.backend.restore(); + paint_scrollbar(cx, theme, picker, options.len(), scroll); +} + +fn paint_font_row(cx: &mut PaintCx<'_>, theme: &Theme, row: Rect, family: &str, is_active: bool) { + if is_active { + cx.backend + .fill_round_rect(row, 6.0, theme.row_selected_primary); + } + let label = TextLayout::single_run( + family, + "system-ui", + 12.0, + to_jian_color(if is_active { + theme.primary + } else { + theme.foreground + }), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &label, + Point2D::new(row.origin.x + 10.0, row.origin.y + 19.0), + ); + if is_active { + draw_icon( + cx.backend, + Icon::Check, + Point2D::new(row.origin.x + row.size.x - 22.0, row.origin.y + 7.0), + 14.0, + theme.primary, + 1.6, + ); + } +} + +fn paint_scrollbar( + cx: &mut PaintCx<'_>, + theme: &Theme, + picker: Rect, + row_count: usize, + scroll: f32, +) { + let content_h = row_count as f32 * FONT_ROW_H + FONT_PICKER_PAD_Y * 2.0; + if content_h <= picker.size.y + 0.5 { + return; + } + let track_h = picker.size.y - 12.0; + let thumb_h = (track_h * picker.size.y / content_h).max(24.0); + let max_scroll = (content_h - picker.size.y).max(0.0); + let t = if max_scroll > 0.0 { + (scroll / max_scroll).clamp(0.0, 1.0) + } else { + 0.0 + }; + let thumb = Rect { + origin: Point2D::new( + picker.origin.x + picker.size.x - 8.0, + picker.origin.y + 6.0 + t * (track_h - thumb_h), + ), + size: Point2D::new(3.0, thumb_h), + }; + cx.backend + .fill_round_rect(thumb, 1.5, theme.muted_foreground); +} + +fn prioritized_system_fonts(system_fonts: &[String]) -> Vec { + let mut cjk = Vec::new(); + let mut rest = Vec::new(); + for family in system_fonts { + if is_cjk_family(family) { + cjk.push(family.clone()); + } else { + rest.push(family.clone()); + } + } + cjk.sort_by(|a, b| { + cjk_rank(a) + .cmp(&cjk_rank(b)) + .then_with(|| compare_family(a, b)) + }); + rest.sort_by(|a, b| compare_family(a, b)); + cjk.extend(rest); + cjk +} + +fn push_font_family(out: &mut Vec, seen: &mut HashSet, family: &str) { + let family = family.trim(); + if family.is_empty() { + return; + } + if seen.insert(family.to_lowercase()) { + out.push(family.to_string()); + } +} + +fn push_font_family_ref<'a>(out: &mut Vec<&'a str>, seen: &mut HashSet, family: &'a str) { + let family = family.trim(); + if family.is_empty() { + return; + } + if seen.insert(family.to_lowercase()) { + out.push(family); + } +} + +fn is_cjk_family(family: &str) -> bool { + if !family.is_ascii() { + return true; + } + let lower = family.to_lowercase(); + CJK_FONT_KEYWORDS + .iter() + .any(|keyword| lower.contains(keyword)) +} + +fn cjk_rank(family: &str) -> usize { + let lower = family.to_lowercase(); + CJK_FONT_PRIORITY + .iter() + .position(|keyword| lower.contains(keyword)) + .unwrap_or(CJK_FONT_PRIORITY.len()) +} + +fn compare_family(a: &str, b: &str) -> std::cmp::Ordering { + a.to_lowercase().cmp(&b.to_lowercase()) +} + +fn text_section_top(panel_rect: Rect, visible: VisibleSections) -> Option { + if !visible.text { + return None; + } + let mut y = panel_rect.origin.y; + y += TAB_HEIGHT; + y += crate::widgets::property_panel_inputs::HEADER_HEIGHT; + if visible.create_component { + y += 8.0 + 36.0 + 12.0; + } + y += SECTION_HEADER_HEIGHT; + y += INPUT_HEIGHT + 6.0; + y += INPUT_HEIGHT + 12.0; + y += SECTION_GAP; + if visible.flex_layout { + y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode); + } + if visible.size_options { + y += SECTION_HEADER_HEIGHT; + y += INPUT_HEIGHT + 10.0; + y += 22.0 * if visible.clip_content { 3.0 } else { 2.0 }; + y += 12.0 + SECTION_GAP; + } + if visible.icon { + y += crate::widgets::property_panel_icon::icon_section_height(); + } + Some(y) +} + +fn display_font_family(value: &str) -> &str { + value + .split(',') + .next() + .unwrap_or(value) + .trim() + .trim_matches(['"', '\'']) +} + +fn rect_contains(r: Rect, p: Point2D) -> bool { + p.x >= r.origin.x + && p.x <= r.origin.x + r.size.x + && p.y >= r.origin.y + && p.y <= r.origin.y + r.size.y +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index c5844ded..0f694b23 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -193,6 +193,16 @@ pub fn action_button_rects( /// Height of one row in an Export-section inline select popup. pub const EXPORT_PICKER_ROW_H: f32 = 30.0; +#[derive(Debug, Clone, Copy, Default)] +pub struct ActionButtonRectOptions<'a> { + pub system_fonts: &'a [String], + pub active_font_family: &'a str, + pub fill_picker_open: bool, + pub font_family_picker_open: bool, + pub export_scale_picker_open: bool, + pub export_format_picker_open: bool, +} + /// Total height (px) of the PropertyPanel's section content. The /// scroll clamp uses it so the inspector cannot scroll past its /// end. Computed as the furthest bottom edge across every action @@ -230,6 +240,26 @@ pub fn action_button_rects_with_fill_picker( font_family_picker_open: bool, export_scale_picker_open: bool, export_format_picker_open: bool, +) -> Vec<(PropertyPanelAction, Rect)> { + action_button_rects_with_font_picker( + panel_rect, + visible, + effects, + ActionButtonRectOptions { + fill_picker_open, + font_family_picker_open, + export_scale_picker_open, + export_format_picker_open, + ..Default::default() + }, + ) +} + +pub fn action_button_rects_with_font_picker( + panel_rect: Rect, + visible: VisibleSections, + effects: &[EffectSummary], + options: ActionButtonRectOptions<'_>, ) -> Vec<(PropertyPanelAction, Rect)> { let x0 = panel_rect.origin.x; let w = panel_rect.size.x; @@ -327,13 +357,6 @@ pub fn action_button_rects_with_fill_picker( out.extend(crate::widgets::property_panel_text::text_action_rects( x0, y, usable_w, )); - if font_family_picker_open { - out.extend( - crate::widgets::property_panel_text::font_family_picker_action_rects( - x0, y, usable_w, - ), - ); - } y += crate::widgets::property_panel_text::text_section_height(); y += SECTION_GAP; } @@ -372,7 +395,7 @@ pub fn action_button_rects_with_fill_picker( size: Point2D::new(usable_w - 22.0 - 6.0 - 50.0 - 22.0 - 12.0, INPUT_HEIGHT), }; out.push((PropertyPanelAction::ToggleFillTypePicker, dropdown_rect)); - if fill_picker_open { + if options.fill_picker_open { let row_h = 32.0; let panel_w = dropdown_rect.size.x; let panel_x = dropdown_rect.origin.x; @@ -587,7 +610,7 @@ pub fn action_button_rects_with_fill_picker( // here. `first_row_y` is placed so the background's bottom // edge lands `4 px` above the dropdown (matches the `6 px` // top/bottom padding `paint_select_popup` adds). - if export_scale_picker_open { + if options.export_scale_picker_open { let count = 3.0; let first_row_y = scale_rect.origin.y - 4.0 - 6.0 - count * EXPORT_PICKER_ROW_H; for (i, scale) in [1.0_f32, 2.0, 3.0].into_iter().enumerate() { @@ -603,7 +626,7 @@ pub fn action_button_rects_with_fill_picker( )); } } - if export_format_picker_open { + if options.export_format_picker_open { let formats = [ op_editor_core::ExportFormat::Png, op_editor_core::ExportFormat::Jpeg, diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index c98cac72..8d3d8474 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -162,8 +162,9 @@ impl<'a> EditContext<'a> { /// `VisibleSections` / `SizeFlags` state live in /// `property_panel_layout.rs` now. pub use crate::widgets::property_panel_layout::{ - action_button_rects, action_button_rects_with_fill_picker, editable_input_rects, - fill_body_height, property_panel_content_height, SizeFlags, VisibleSections, + action_button_rects, action_button_rects_with_fill_picker, + action_button_rects_with_font_picker, editable_input_rects, fill_body_height, + property_panel_content_height, ActionButtonRectOptions, SizeFlags, VisibleSections, }; // ── Tab strip ───────────────────────────────────────────────────── diff --git a/crates/op-editor-ui/src/widgets/property_panel_tests.rs b/crates/op-editor-ui/src/widgets/property_panel_tests.rs index 5c551ad8..10bb16db 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_tests.rs @@ -282,39 +282,108 @@ fn font_family_picker_rows_are_clickable() { origin: Point2D::new(0.0, 0.0), size: Point2D::new(280.0, 1200.0), }; - let rects = sections::action_button_rects_with_fill_picker( - rect, - visible_for(&panel), - &panel.snapshot.effects, - false, - true, - false, - false, - ); - let georgia = rects - .iter() - .find(|(action, _)| { - matches!( - action, - PropertyPanelAction::SetFontFamily( - super::property_panel::FontFamilyChoice::Georgia - ) - ) - }) - .map(|(_, r)| *r) - .expect("Georgia font row"); + let poppins = panel + .font_family_picker_row_rect(rect, "Poppins") + .expect("Poppins font row"); let center = Point2D::new( - georgia.origin.x + georgia.size.x / 2.0, - georgia.origin.y + georgia.size.y / 2.0, + poppins.origin.x + poppins.size.x / 2.0, + poppins.origin.y + poppins.size.y / 2.0, ); assert!(matches!( panel.hit_test_action(rect, center), - Some(PropertyPanelAction::SetFontFamily( - super::property_panel::FontFamilyChoice::Georgia - )) + Some(PropertyPanelAction::SetFontFamily(family)) if family == "Poppins" )); } +#[test] +fn font_family_options_include_promoted_system_fonts() { + let system_fonts = vec![ + "Zapfino".to_string(), + "PingFang SC".to_string(), + "Hiragino Sans GB".to_string(), + ]; + let system_fonts = + super::property_panel_font_picker::prepare_system_font_families(system_fonts); + let options = + super::property_panel_font_picker::font_family_options(&system_fonts, "My Brand Font"); + assert_eq!(options.first().map(String::as_str), Some("My Brand Font")); + let pingfang = options + .iter() + .position(|family| family == "PingFang SC") + .expect("system CJK font should be visible"); + let zapfino = options + .iter() + .position(|family| family == "Zapfino") + .expect("regular system font should be visible"); + assert!(pingfang < zapfino, "CJK fonts should be promoted"); +} + +#[test] +fn prepared_system_fonts_are_sorted_once_for_smooth_picker_scroll() { + let system_fonts = vec![ + "Zapfino".to_string(), + " ".to_string(), + "PingFang SC".to_string(), + "zapfino".to_string(), + "Bodoni 72".to_string(), + ]; + let prepared = super::property_panel_font_picker::prepare_system_font_families(system_fonts); + + assert_eq!( + prepared, + vec![ + "PingFang SC".to_string(), + "Bodoni 72".to_string(), + "Zapfino".to_string() + ] + ); +} + +#[test] +fn font_family_picker_scroll_hits_later_system_fonts() { + let mut state = EditorState::sample(); + state.set_single_selection(NodeId::new("n11")); + state.editor_ui.font_family_picker_open = true; + state.editor_ui.system_font_families = + (0..120).map(|i| format!("System Font {i:03}")).collect(); + state.editor_ui.font_family_picker_scroll = 56.0 * 20.0; + + let panel = PropertyPanel::for_selection(&state).expect("text panel"); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 900.0), + }; + + assert!(panel.font_family_picker_max_scroll(rect) > 0.0); + let target = panel + .font_family_picker_row_rect(rect, "System Font 030") + .expect("scrolled system font row"); + let center = Point2D::new( + target.origin.x + target.size.x / 2.0, + target.origin.y + target.size.y / 2.0, + ); + + assert!(matches!( + panel.hit_test_action(rect, center), + Some(PropertyPanelAction::SetFontFamily(family)) if family == "System Font 030" + )); +} + +#[test] +fn font_family_options_include_all_system_fonts_after_scroll_support() { + let mut system_fonts: Vec = (0..400).map(|i| format!("System Font {i}")).collect(); + system_fonts.push("PingFang SC".to_string()); + system_fonts.push("宋体".to_string()); + + let options = + super::property_panel_font_picker::font_family_options(&system_fonts, "Brand Font"); + + assert_eq!(options.first().map(String::as_str), Some("Brand Font")); + assert!(options.iter().any(|family| family == "PingFang SC")); + assert!(options.iter().any(|family| family == "宋体")); + assert!(options.iter().any(|family| family == "System Font 399")); +} + #[test] fn export_scale_picker_open_emits_option_rows() { let mut state = EditorState::sample(); diff --git a/crates/op-editor-ui/src/widgets/property_panel_text.rs b/crates/op-editor-ui/src/widgets/property_panel_text.rs index 72392981..ee849881 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_text.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_text.rs @@ -3,8 +3,7 @@ use crate::theme::Theme; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::property_panel::{ - FontFamilyChoice, NodeSnapshot, PropertyPanelAction, TextAlignValue, TextGrowthValue, - TextVerticalAlignValue, + NodeSnapshot, PropertyPanelAction, TextAlignValue, TextGrowthValue, TextVerticalAlignValue, }; use crate::widgets::property_panel_inputs::{ paint_input_with_prefix_focused, paint_section_divider, paint_section_label, to_jian_color, @@ -167,27 +166,6 @@ pub fn text_action_rects(x0: f32, y: f32, usable_w: f32) -> Vec<(PropertyPanelAc out } -pub fn font_family_picker_action_rects( - x0: f32, - y: f32, - usable_w: f32, -) -> Vec<(PropertyPanelAction, Rect)> { - let family_y = y + TEXT_LAYOUT_BLOCK_H + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + 4.0; - FontFamilyChoice::ALL - .into_iter() - .enumerate() - .map(|(i, choice)| { - ( - PropertyPanelAction::SetFontFamily(choice), - Rect { - origin: Point2D::new(x0 + PAD_X, family_y + i as f32 * 28.0), - size: Point2D::new(usable_w, 28.0), - }, - ) - }) - .collect() -} - #[allow(clippy::too_many_arguments)] pub fn paint_text_section( cx: &mut PaintCx<'_>, @@ -315,104 +293,6 @@ pub fn paint_text_section( y + SECTION_GAP } -pub fn paint_font_family_picker( - cx: &mut PaintCx<'_>, - theme: &Theme, - panel_rect: Rect, - visible: crate::widgets::property_panel_layout::VisibleSections, - active_family: &str, -) { - let x0 = panel_rect.origin.x; - let w = panel_rect.size.x; - let usable_w = w - PAD_X * 2.0; - let Some(text_y) = text_section_top(panel_rect, visible) else { - return; - }; - let rows = font_family_picker_action_rects(x0, text_y, usable_w); - if rows.is_empty() { - return; - } - let first = rows.first().map(|(_, r)| *r).unwrap(); - let last = rows.last().map(|(_, r)| *r).unwrap(); - let pop = Rect { - origin: Point2D::new(first.origin.x, first.origin.y - 6.0), - size: Point2D::new( - first.size.x, - last.origin.y + last.size.y - first.origin.y + 12.0, - ), - }; - cx.backend.fill_round_rect(pop, 8.0, theme.popover); - cx.backend.stroke_round_rect(pop, 8.0, theme.border, 1.0); - let active = display_font_family(active_family); - for (action, row) in rows { - let PropertyPanelAction::SetFontFamily(choice) = action else { - continue; - }; - let is_active = choice.family() == active; - if is_active { - cx.backend - .fill_round_rect(row, 6.0, theme.row_selected_primary); - } - let label = TextLayout::single_run( - choice.family(), - choice.family(), - 12.0, - to_jian_color(if is_active { - theme.primary - } else { - theme.foreground - }), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &label, - Point2D::new(row.origin.x + 10.0, row.origin.y + 19.0), - ); - if is_active { - draw_icon( - cx.backend, - Icon::Check, - Point2D::new(row.origin.x + row.size.x - 22.0, row.origin.y + 7.0), - 14.0, - theme.primary, - 1.6, - ); - } - } -} - -fn text_section_top( - panel_rect: Rect, - visible: crate::widgets::property_panel_layout::VisibleSections, -) -> Option { - if !visible.text { - return None; - } - let mut y = panel_rect.origin.y; - y += crate::widgets::property_panel_inputs::TAB_HEIGHT; - y += crate::widgets::property_panel_inputs::HEADER_HEIGHT; - if visible.create_component { - y += 8.0 + 36.0 + 12.0; - } - y += SECTION_HEADER_HEIGHT; - y += INPUT_HEIGHT + 6.0; - y += INPUT_HEIGHT + 12.0; - y += SECTION_GAP; - if visible.flex_layout { - y += crate::widgets::property_panel_flex::flex_section_height(visible.flex_layout_mode); - } - if visible.size_options { - y += SECTION_HEADER_HEIGHT; - y += INPUT_HEIGHT + 10.0; - y += 22.0 * if visible.clip_content { 3.0 } else { 2.0 }; - y += 12.0 + SECTION_GAP; - } - if visible.icon { - y += crate::widgets::property_panel_icon::icon_section_height(); - } - Some(y) -} - fn paint_text_growth_row( cx: &mut PaintCx<'_>, theme: &Theme, @@ -610,12 +490,3 @@ fn format_panel_number(value: f32) -> String { format!("{value:.2}") } } - -fn display_font_family(value: &str) -> &str { - value - .split(',') - .next() - .unwrap_or(value) - .trim() - .trim_matches(['"', '\'']) -} diff --git a/crates/op-editor-ui/src/widgets/toolbar.rs b/crates/op-editor-ui/src/widgets/toolbar.rs index 45c030ec..4062086d 100644 --- a/crates/op-editor-ui/src/widgets/toolbar.rs +++ b/crates/op-editor-ui/src/widgets/toolbar.rs @@ -4,7 +4,7 @@ //! Layout matches `apps/web/src/components/editor/toolbar.tsx`: //! tools at the top (Select / Rect / Text / Frame / Hand), a hairline //! separator, undo/redo, another separator, then panel toggles -//! (Code / Design system). +//! (Variables / Design system). //! //! Active tool gets a `theme.primary` filled rounded square + the //! white foreground icon. Inactive items render the icon in @@ -57,7 +57,7 @@ pub enum ToolbarItem { pub enum ToolbarAction { Undo, Redo, - ToggleCodePanel, + ToggleVariablesPanel, ToggleDesignPanel, } @@ -80,6 +80,8 @@ pub struct Toolbar { /// `Document.ui.shape_tool` so the icon flips after the user /// picks a shape from the dropdown. pub shape_tool: Tool, + pub variables_panel_open: bool, + pub design_md_panel_open: bool, /// Which item the cursor is over — drives the per-button hover /// wash. `None` = no hover (cursor off the bar or over an /// active item where the active fill already reads). @@ -110,12 +112,14 @@ impl Toolbar { ToolbarItem::Action(ToolbarAction::Undo, Icon::Undo), ToolbarItem::Action(ToolbarAction::Redo, Icon::Redo), ToolbarItem::Separator, - ToolbarItem::Action(ToolbarAction::ToggleCodePanel, Icon::Braces), + ToolbarItem::Action(ToolbarAction::ToggleVariablesPanel, Icon::Braces), ToolbarItem::Action(ToolbarAction::ToggleDesignPanel, Icon::BookOpen), ], active: state.tool, theme: theme_for(&state.editor_ui), shape_tool: state.editor_ui.shape_tool, + variables_panel_open: state.editor_ui.variables_panel_open, + design_md_panel_open: state.editor_ui.design_md_panel_open, hover: state.editor_ui.toolbar_hover, } } @@ -359,8 +363,17 @@ impl Widget for Toolbar { if prev_was_item { y += BUTTON_GAP; } + let active = match item { + ToolbarItem::Action(ToolbarAction::ToggleVariablesPanel, _) => { + self.variables_panel_open + } + ToolbarItem::Action(ToolbarAction::ToggleDesignPanel, _) => { + self.design_md_panel_open + } + _ => false, + }; let hovered = self.item_hovered(item); - paint_button(cx, &self.theme, button_x, y, *icon, false, hovered); + paint_button(cx, &self.theme, button_x, y, *icon, active, hovered); y += BUTTON_SIZE; prev_was_item = true; } diff --git a/crates/op-editor-ui/src/widgets/variables_panel.rs b/crates/op-editor-ui/src/widgets/variables_panel.rs index 6cba0e69..2bcda627 100644 --- a/crates/op-editor-ui/src/widgets/variables_panel.rs +++ b/crates/op-editor-ui/src/widgets/variables_panel.rs @@ -1,75 +1,60 @@ -//! 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: -//! - 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 returning `VariablesPanelHit::Row(idx)` / -//! `AxisChip(idx)` / `AxisDropdownItem` so the host can wire -//! row clicks to the color picker / theme switch. -//! -//! ## 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::theme::Theme; +use crate::widgets::editor_state_ext::theme_for; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; -use crate::{Color, Point2D, Rect}; -use jian_ops_schema::variable::{VariableKind, VariableScalar}; +use crate::{Point2D, Rect}; +use jian_ops_schema::variable::{VariableKind, VariableScalar, VariableValue}; use op_editor_core::editor_ui_state::VariableRowFocus; -use op_editor_core::EditorState; +use op_editor_core::{EditorState, Locale}; -const ROW_HEIGHT: f32 = 32.0; -const HEADER_HEIGHT: f32 = 28.0; +mod header; +mod paint; + +const ROW_HEIGHT: f32 = 44.0; +const HEADER_HEIGHT: f32 = 44.0; +const COLUMN_HEADER_HEIGHT: f32 = 36.0; +const FOOTER_HEIGHT: f32 = 40.0; const CHIP_HEIGHT: f32 = 20.0; -const PAD_X: f32 = 12.0; +const PAD_X: f32 = 16.0; const SWATCH_SIZE: f32 = 18.0; -const DROPDOWN_WIDTH: f32 = 140.0; -const DROPDOWN_ROW_HEIGHT: f32 = 24.0; -/// Width of the Variables rail when it docks alongside the layer -/// panel. Matches the LAYER_PANEL_WIDTH default so the chrome reads -/// symmetrically; the host can resize via the existing panel-resize -/// gutter logic. -pub const VARIABLES_PANEL_WIDTH: f32 = 240.0; +const NAME_COLUMN_WIDTH: f32 = 220.0; +const ACTION_COLUMN_WIDTH: f32 = 44.0; +const DROPDOWN_WIDTH: f32 = 176.0; +const DROPDOWN_ROW_HEIGHT: f32 = 36.0; +const PANEL_RADIUS: f32 = 16.0; +const ADD_VARIABLE_MENU_WIDTH: f32 = 176.0; +const ADD_VARIABLE_MENU_ROW_HEIGHT: f32 = 30.0; +const ADD_VARIABLE_MENU_ROWS: f32 = 3.0; +pub const VARIABLES_PANEL_WIDTH: f32 = 820.0; -/// Hit kinds for `VariablesPanel::hit_test`. Row index is into the -/// `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. + Close, + ThemeTab(String), + ToggleThemeMenu(String), + ThemeMenuRename(String), + ThemeMenuDelete(String), + AddTheme, + TogglePresetMenu, + AddVariant, + ToggleVariantMenu(String), + VariantMenuRename(String), + VariantMenuDelete(String), + ToggleAddVariableMenu, + AddVariableColor, + AddVariableNumber, + AddVariableString, + NameCell(usize), + ValueCell { row: usize, variant: usize }, Row(usize), - /// Click on a theme-axis chip in the header. Host toggles - /// `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 - /// `ui.variables.active_theme[axis] = value`. The host is also - /// responsible for clearing `axis_dropdown_open`. AxisDropdownItem { axis: String, value: 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. + value: VariableValue, resolved: Option, } @@ -79,26 +64,34 @@ struct AxisChip { value: String, } -/// 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. + theme: Theme, + locale: Locale, 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. + current_axis: Option, 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. + theme_menu_open: Option, + variant_menu_open: Option, + renaming_theme: Option, + renaming_variant: Option, + preset_menu_open: bool, + add_menu_open: bool, + editing_name_row: Option, + editing_value_cell: Option<(usize, usize)>, editing_draft: String, + caret_pos: usize, + caret_anchor_ms: u64, + now_ms: u64, } impl VariablesPanel { pub fn for_editor(state: &EditorState) -> Self { + Self::for_editor_at(state, 0) + } + + pub fn for_editor_at(state: &EditorState, now_ms: u64) -> Self { // Variable rows — keyed by BTreeMap order so paint is stable. let rows: Vec = state .doc @@ -109,12 +102,12 @@ impl VariablesPanel { .map(|(name, def)| VarRow { name: name.clone(), kind: def.kind.clone(), + value: def.value.clone(), resolved: state.resolve_variable(name).cloned(), }) .collect() }) .unwrap_or_default(); - // Active-theme chips. let chips: Vec = state .ui .variables @@ -126,22 +119,62 @@ impl VariablesPanel { }) .collect(); // Theme axes + their value lists. - let themes: Vec<(String, Vec)> = state + let mut themes: Vec<(String, Vec)> = state .doc .themes .as_ref() .map(|t| t.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) .unwrap_or_default(); + if themes.is_empty() && !rows.is_empty() { + themes.push(("Theme-1".to_string(), vec!["Default".to_string()])); + } + let current_axis = state + .editor_ui + .variables_current_axis + .as_ref() + .filter(|axis| themes.iter().any(|(name, _)| name == *axis)) + .cloned() + .or_else(|| { + state + .ui + .variables + .active_theme + .keys() + .find(|axis| themes.iter().any(|(name, _)| name == *axis)) + .cloned() + }) + .or_else(|| themes.first().map(|(axis, _)| axis.clone())); Self { rows, + theme: theme_for(&state.editor_ui), + locale: state.editor_ui.locale, chips, themes, + current_axis, 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, + theme_menu_open: state.editor_ui.variables_theme_menu_axis.clone(), + variant_menu_open: state.editor_ui.variables_variant_menu_value.clone(), + renaming_theme: state.editor_ui.variables_theme_rename_axis.clone(), + renaming_variant: state.editor_ui.variables_variant_rename_value.clone(), + preset_menu_open: state.editor_ui.variables_preset_menu_open, + add_menu_open: state.editor_ui.variables_add_menu_open, + editing_name_row: state.editor_ui.variable_row_focus.and_then(|f| match f { + VariableRowFocus::Name(i) => Some(i), + VariableRowFocus::Number(_) + | VariableRowFocus::String(_) + | VariableRowFocus::NumberCell { .. } + | VariableRowFocus::StringCell { .. } => None, + }), + editing_value_cell: state.editor_ui.variable_row_focus.and_then(|f| match f { + VariableRowFocus::Number(i) | VariableRowFocus::String(i) => Some((i, 0)), + VariableRowFocus::NumberCell { row, variant } + | VariableRowFocus::StringCell { row, variant } => Some((row, variant)), + VariableRowFocus::Name(_) => None, }), editing_draft: state.ui.property_input_draft.clone(), + caret_pos: state.ui.property_caret_pos, + caret_anchor_ms: state.ui.property_caret_anchor_ms, + now_ms, } } @@ -156,15 +189,68 @@ impl VariablesPanel { self.chips.len() } + fn theme_tab_labels(&self) -> Vec<&str> { + self.themes.iter().map(|(axis, _)| axis.as_str()).collect() + } + + fn active_axis_label(&self) -> &str { + self.current_axis + .as_deref() + .or_else(|| self.chips.first().map(|chip| chip.axis.as_str())) + .or_else(|| self.themes.first().map(|(axis, _)| axis.as_str())) + .unwrap_or("Theme-1") + } + + fn variant_column_labels(&self) -> Vec<&str> { + self.variant_column_labels_for_axis(self.active_axis_label()) + } + + fn variant_column_labels_for_axis(&self, axis: &str) -> Vec<&str> { + self.axis_values(axis) + .filter(|values| !values.is_empty()) + .map(|values| values.iter().map(String::as_str).collect()) + .unwrap_or_else(|| vec!["Default"]) + } + + pub fn variant_column_count(&self) -> usize { + self.variant_column_labels().len() + } + + fn variant_scalar_for<'a>( + &self, + var: &'a VarRow, + axis: &str, + value: &str, + ) -> Option<&'a VariableScalar> { + match &var.value { + VariableValue::Scalar(s) => Some(s), + VariableValue::Themed(entries) => entries + .iter() + .find(|entry| { + entry + .theme + .as_ref() + .and_then(|theme| theme.get(axis)) + .is_some_and(|v| v == value) + }) + .map(|entry| &entry.value) + .or_else(|| { + entries + .iter() + .find(|entry| entry.theme.is_none()) + .map(|entry| &entry.value) + }) + .or_else(|| entries.first().map(|entry| &entry.value)), + } + } + /// Total height (header + chips row + variable rows). Used by /// the right-rail host when computing layout. pub fn intrinsic_height(&self) -> f32 { - let chip_row = if self.chips.is_empty() { - 0.0 - } else { - CHIP_HEIGHT + 8.0 - }; - HEADER_HEIGHT + chip_row + (self.row_count() as f32) * ROW_HEIGHT + HEADER_HEIGHT + + COLUMN_HEADER_HEIGHT + + FOOTER_HEIGHT + + (self.row_count() as f32) * ROW_HEIGHT } /// Value list for an axis name, sourced from `doc.themes`. @@ -179,20 +265,170 @@ impl VariablesPanel { /// the paint walk in `paint` so hit-test + dropdown anchoring /// stay aligned without re-measuring. fn chip_rect(&self, rect: Rect, idx: usize) -> Rect { + let Some(chip) = self.chips.get(idx) else { + return Rect { + origin: Point2D::new(value_column_x(rect), rect.origin.y + HEADER_HEIGHT + 5.0), + size: Point2D::new(0.0, CHIP_HEIGHT), + }; + }; + let col_x = value_column_x(rect); + let labels = self.variant_column_labels_for_axis(&chip.axis); + let value_idx = labels + .iter() + .position(|label| *label == chip.value.as_str()) + .unwrap_or(0); + let col_w = variant_column_width(rect, labels.len()); + Rect { + origin: Point2D::new( + col_x + col_w * value_idx as f32, + rect.origin.y + HEADER_HEIGHT + 5.0, + ), + size: Point2D::new( + (label_width(&chip.value, 13.0) + 22.0).min(col_w - 8.0), + 26.0, + ), + } + } + + fn add_theme_rect(&self, rect: Rect) -> Rect { + Rect { + origin: Point2D::new(self.theme_tabs_end_x(rect) + 6.0, rect.origin.y + 8.0), + size: Point2D::new(28.0, 28.0), + } + } + + fn preset_rect(&self, rect: Rect) -> Rect { + let add = self.add_theme_rect(rect); + Rect { + origin: Point2D::new(add.origin.x + add.size.x + 8.0, rect.origin.y + 6.0), + size: Point2D::new(122.0, 32.0), + } + } + + fn preset_menu_rect(&self, rect: Rect) -> Rect { + let preset = self.preset_rect(rect); + Rect { + origin: Point2D::new(preset.origin.x, rect.origin.y + HEADER_HEIGHT + 4.0), + size: Point2D::new(224.0, 144.0), + } + } + + fn theme_tabs_end_x(&self, rect: Rect) -> f32 { let mut x = rect.origin.x + PAD_X; - for (i, chip) in self.chips.iter().enumerate() { - let w = chip_width(chip); + for label in self.theme_tab_labels() { + x += self.theme_tab_advance_width(label); + } + x + } + + fn theme_tab_rect(&self, rect: Rect, idx: usize) -> Rect { + let mut x = rect.origin.x + PAD_X; + for (i, label) in self.theme_tab_labels().iter().enumerate() { + let width = self.theme_tab_hit_width(label); if i == idx { return Rect { - origin: Point2D::new(x, rect.origin.y + HEADER_HEIGHT), - size: Point2D::new(w, CHIP_HEIGHT), + origin: Point2D::new(x - 6.0, rect.origin.y + 6.0), + size: Point2D::new(width + 12.0, 32.0), }; } - x += w + 6.0; + x += self.theme_tab_advance_width(label); } Rect { - origin: Point2D::new(rect.origin.x + PAD_X, rect.origin.y + HEADER_HEIGHT), - size: Point2D::new(0.0, CHIP_HEIGHT), + origin: Point2D::new(rect.origin.x + PAD_X, rect.origin.y + 6.0), + size: Point2D::new(0.0, 32.0), + } + } + + fn theme_rename_input_width(&self) -> f32 { + (label_width(&self.editing_draft, 13.0) + 28.0).max(96.0) + } + + fn theme_tab_hit_width(&self, label: &str) -> f32 { + if self.renaming_theme.as_deref() == Some(label) { + self.theme_rename_input_width() + } else { + label_width(label, 13.0) + 20.0 + } + } + + fn theme_tab_advance_width(&self, label: &str) -> f32 { + if self.renaming_theme.as_deref() == Some(label) { + self.theme_rename_input_width() + 4.0 + } else { + label_width(label, 13.0) + 24.0 + } + } + + fn variant_header_rect(&self, rect: Rect, idx: usize) -> Rect { + let variants = self.variant_column_labels(); + let col_w = variant_column_width(rect, variants.len()); + Rect { + origin: Point2D::new( + value_column_x(rect) + col_w * idx as f32 - 6.0, + rect.origin.y + HEADER_HEIGHT + 4.0, + ), + size: Point2D::new(col_w.min(176.0), 30.0), + } + } + + fn theme_menu_rect(&self, rect: Rect, axis: &str) -> Rect { + let idx = self + .theme_tab_labels() + .iter() + .position(|label| *label == axis) + .unwrap_or(0); + let anchor = self.theme_tab_rect(rect, idx); + Rect { + origin: Point2D::new(anchor.origin.x, rect.origin.y + HEADER_HEIGHT + 4.0), + size: Point2D::new(176.0, menu_rows_height(self.theme_tab_labels().len())), + } + } + + fn variant_menu_rect(&self, rect: Rect, value: &str) -> Rect { + let variants = self.variant_column_labels(); + let idx = variants + .iter() + .position(|label| *label == value) + .unwrap_or(0); + let anchor = self.variant_header_rect(rect, idx); + Rect { + origin: Point2D::new( + anchor.origin.x + 6.0, + rect.origin.y + HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + 4.0, + ), + size: Point2D::new(176.0, menu_rows_height(variants.len())), + } + } + + pub fn name_caret_for_row(&self, idx: usize) -> Option { + if self.editing_name_row == Some(idx) + && jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500) + { + Some(self.caret_pos.min(self.editing_draft.len())) + } else { + None + } + } + + fn rename_text_caret(&self, target: RenameTarget<'_>) -> Option { + let is_active = match target { + RenameTarget::Theme(axis) => self.renaming_theme.as_deref() == Some(axis), + RenameTarget::Variant(value) => self.renaming_variant.as_deref() == Some(value), + }; + if is_active && jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500) { + Some(self.caret_pos.min(self.editing_draft.len())) + } else { + None + } + } + + pub fn value_caret_for_cell(&self, row: usize, variant: usize) -> Option { + if self.editing_value_cell == Some((row, variant)) + && jian_core::anim::blink_visible(self.now_ms, self.caret_anchor_ms, 500) + { + Some(self.caret_pos.min(self.editing_draft.len())) + } else { + None } } @@ -203,7 +439,62 @@ impl VariablesPanel { if !rect_contains(rect, point) { return None; } - let mut y = rect.origin.y + HEADER_HEIGHT; + if rect_contains(close_rect(rect), point) { + return Some(VariablesPanelHit::Close); + } + if let Some(axis) = self.theme_menu_open.as_deref() { + if let Some(hit) = theme_menu_hit( + self.theme_menu_rect(rect, axis), + point, + axis, + self.theme_tab_labels().len(), + ) { + return Some(hit); + } + } + if let Some(value) = self.variant_menu_open.as_deref() { + if let Some(hit) = variant_menu_hit( + self.variant_menu_rect(rect, value), + point, + value, + self.variant_column_labels().len(), + ) { + return Some(hit); + } + } + for (idx, axis) in self.theme_tab_labels().iter().enumerate() { + if rect_contains(self.theme_tab_rect(rect, idx), point) { + if *axis == self.active_axis_label() { + return Some(VariablesPanelHit::ToggleThemeMenu((*axis).to_string())); + } + return Some(VariablesPanelHit::ThemeTab((*axis).to_string())); + } + } + if self.add_menu_open { + if let Some(hit) = add_variable_menu_hit(rect, point) { + return Some(hit); + } + } + if self.preset_menu_open && rect_contains(self.preset_menu_rect(rect), point) { + return Some(VariablesPanelHit::TogglePresetMenu); + } + if rect_contains(self.add_theme_rect(rect), point) { + return Some(VariablesPanelHit::AddTheme); + } + if rect_contains(self.preset_rect(rect), point) { + return Some(VariablesPanelHit::TogglePresetMenu); + } + if rect_contains(add_variant_rect(rect), point) { + return Some(VariablesPanelHit::AddVariant); + } + for (idx, value) in self.variant_column_labels().iter().enumerate() { + if rect_contains(self.variant_header_rect(rect, idx), point) { + return Some(VariablesPanelHit::ToggleVariantMenu((*value).to_string())); + } + } + if rect_contains(add_variable_rect(rect), point) { + return Some(VariablesPanelHit::ToggleAddVariableMenu); + } // Dropdown overlay — top-most. Paints when the host // marked an axis open AND that axis is one of the // active-theme chips. @@ -216,7 +507,7 @@ impl VariablesPanel { { 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_y_start = chip_rect.origin.y + chip_rect.size.y + 4.0; let menu_rect = Rect { origin: Point2D::new(chip_rect.origin.x, menu_y_start), size: Point2D::new( @@ -239,39 +530,206 @@ impl VariablesPanel { } } } - // Chip row. + // Theme value chip in the column header. if !self.chips.is_empty() { - let chip_y = y; - if point.y >= chip_y && point.y < chip_y + CHIP_HEIGHT { - let mut x = rect.origin.x + PAD_X; - for (i, chip) in self.chips.iter().enumerate() { - let w = chip_width(chip); - if point.x >= x && point.x < x + w { - return Some(VariablesPanelHit::AxisChip(i)); - } - x += w + 6.0; + for (i, _chip) in self.chips.iter().enumerate() { + if rect_contains(self.chip_rect(rect, i), point) { + return Some(VariablesPanelHit::AxisChip(i)); } } - y += CHIP_HEIGHT + 8.0; } // Variable rows. + let y = rows_start_y(rect); + let footer_y = rect.origin.y + rect.size.y - FOOTER_HEIGHT; + if point.y >= footer_y { + return None; + } let idx = ((point.y - y) / ROW_HEIGHT).floor(); if idx >= 0.0 { let i = idx as usize; if i < self.row_count() { + if rect_contains(name_cell_rect(rect, i), point) { + return Some(VariablesPanelHit::NameCell(i)); + } + let variant_count = self.variant_column_labels().len().max(1); + for variant in 0..variant_count { + if rect_contains(value_cell_rect(rect, i, variant, variant_count), point) { + return Some(VariablesPanelHit::ValueCell { row: i, variant }); + } + } return Some(VariablesPanelHit::Row(i)); } } None } + + fn labels(&self) -> VariablePanelLabels { + let t = |key| crate::i18n::translate(self.locale, key); + VariablePanelLabels { + preset: t("variables.presets"), + name: t("common.name"), + empty: t("variables.noDefined"), + add_variable: t("variables.addVariable"), + color: t("variables.typeColor"), + number: t("variables.typeNumber"), + string: t("variables.typeString"), + save_preset: t("variables.savePreset"), + no_presets: t("variables.noPresets"), + import: t("variables.importPreset"), + export: t("variables.exportPreset"), + rename: t("common.rename"), + delete: t("common.delete"), + } + } } -fn chip_width(chip: &AxisChip) -> f32 { - // Approximate at 7 px per visible char + 16 px chrome. Real - // measure_text would land once the panel is hosted; for now the - // hit-test budget is generous enough that off-by-one is fine. - let label_len = chip.axis.len() + 2 + chip.value.len(); // "axis: value" - (label_len as f32) * 7.0 + 16.0 +enum RenameTarget<'a> { + Theme(&'a str), + Variant(&'a str), +} + +struct VariablePanelLabels { + preset: &'static str, + name: &'static str, + empty: &'static str, + add_variable: &'static str, + color: &'static str, + number: &'static str, + string: &'static str, + save_preset: &'static str, + no_presets: &'static str, + import: &'static str, + export: &'static str, + rename: &'static str, + delete: &'static str, +} + +fn close_rect(rect: Rect) -> Rect { + Rect { + origin: Point2D::new(rect.origin.x + rect.size.x - 42.0, rect.origin.y + 9.0), + size: Point2D::new(26.0, 26.0), + } +} + +fn add_variant_rect(rect: Rect) -> Rect { + Rect { + origin: Point2D::new( + rect.origin.x + rect.size.x - PAD_X - 28.0, + rect.origin.y + HEADER_HEIGHT + 4.0, + ), + size: Point2D::new(28.0, 28.0), + } +} + +fn add_variable_rect(rect: Rect) -> Rect { + Rect { + origin: Point2D::new( + rect.origin.x + 8.0, + rect.origin.y + rect.size.y - FOOTER_HEIGHT + 5.0, + ), + size: Point2D::new(164.0, 30.0), + } +} + +fn add_variable_menu_rect(rect: Rect) -> Rect { + let height = ADD_VARIABLE_MENU_ROW_HEIGHT * ADD_VARIABLE_MENU_ROWS; + Rect { + origin: Point2D::new( + rect.origin.x + PAD_X, + rect.origin.y + rect.size.y - FOOTER_HEIGHT - height - 6.0, + ), + size: Point2D::new(ADD_VARIABLE_MENU_WIDTH, height), + } +} + +fn add_variable_menu_hit(rect: Rect, point: Point2D) -> Option { + let menu = add_variable_menu_rect(rect); + if !rect_contains(menu, point) { + return None; + } + let row = ((point.y - menu.origin.y) / ADD_VARIABLE_MENU_ROW_HEIGHT).floor() as usize; + match row { + 0 => Some(VariablesPanelHit::AddVariableColor), + 1 => Some(VariablesPanelHit::AddVariableNumber), + 2 => Some(VariablesPanelHit::AddVariableString), + _ => None, + } +} + +fn menu_rows_height(sibling_count: usize) -> f32 { + let rows = if sibling_count > 1 { 2.0 } else { 1.0 }; + ADD_VARIABLE_MENU_ROW_HEIGHT * rows +} + +fn theme_menu_hit( + rect: Rect, + point: Point2D, + axis: &str, + theme_count: usize, +) -> Option { + if !rect_contains(rect, point) { + return None; + } + let row = ((point.y - rect.origin.y) / ADD_VARIABLE_MENU_ROW_HEIGHT).floor() as usize; + match row { + 0 => Some(VariablesPanelHit::ThemeMenuRename(axis.to_string())), + 1 if theme_count > 1 => Some(VariablesPanelHit::ThemeMenuDelete(axis.to_string())), + _ => None, + } +} + +fn variant_menu_hit( + rect: Rect, + point: Point2D, + value: &str, + variant_count: usize, +) -> Option { + if !rect_contains(rect, point) { + return None; + } + let row = ((point.y - rect.origin.y) / ADD_VARIABLE_MENU_ROW_HEIGHT).floor() as usize; + match row { + 0 => Some(VariablesPanelHit::VariantMenuRename(value.to_string())), + 1 if variant_count > 1 => Some(VariablesPanelHit::VariantMenuDelete(value.to_string())), + _ => None, + } +} + +fn rows_start_y(rect: Rect) -> f32 { + rect.origin.y + HEADER_HEIGHT + COLUMN_HEADER_HEIGHT +} + +fn name_cell_rect(rect: Rect, idx: usize) -> Rect { + let y = rows_start_y(rect) + ROW_HEIGHT * idx as f32; + Rect { + origin: Point2D::new(rect.origin.x + PAD_X + 28.0, y + 7.0), + size: Point2D::new(NAME_COLUMN_WIDTH - 36.0, 30.0), + } +} + +fn value_column_x(rect: Rect) -> f32 { + rect.origin.x + PAD_X + NAME_COLUMN_WIDTH +} + +fn value_cell_rect(rect: Rect, row: usize, variant: usize, variant_count: usize) -> Rect { + let width = variant_column_width(rect, variant_count); + Rect { + origin: Point2D::new( + value_column_x(rect) + width * variant as f32, + rows_start_y(rect) + ROW_HEIGHT * row as f32, + ), + size: Point2D::new(width, ROW_HEIGHT), + } +} + +fn variant_column_width(rect: Rect, count: usize) -> f32 { + let count = count.max(1) as f32; + let available = rect.size.x - PAD_X * 2.0 - NAME_COLUMN_WIDTH - ACTION_COLUMN_WIDTH; + (available / count).max(156.0) +} + +fn label_width(text: &str, size: f32) -> f32 { + text.chars().count() as f32 * size * 0.58 } fn rect_contains(r: Rect, p: Point2D) -> bool { @@ -305,360 +763,9 @@ impl Widget for VariablesPanel { } fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) { - let theme = Theme::dark(); - // Background — `card` is the closest token to "right-rail - // panel surface"; theme has no dedicated `panel` field today. - cx.backend.fill_rect(rect, theme.card); - // Section label. - let label_layout = crate::TextLayout::single_run( - "Variables", - "system-ui", - 13.0, - to_jian_color(theme.foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &label_layout, - Point2D::new(rect.origin.x + PAD_X, rect.origin.y + 20.0), - ); - let mut y = rect.origin.y + HEADER_HEIGHT; - // Active theme chips. - if !self.chips.is_empty() { - let mut x = rect.origin.x + PAD_X; - for chip in &self.chips { - let w = chip_width(chip); - let chip_rect = Rect { - origin: Point2D::new(x, y), - size: Point2D::new(w, CHIP_HEIGHT), - }; - cx.backend.fill_round_rect(chip_rect, 4.0, theme.muted); - let label = format!("{}: {}", chip.axis, chip.value); - let layout = crate::TextLayout::single_run( - &label, - "system-ui", - 11.0, - to_jian_color(theme.muted_foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &layout, - Point2D::new(chip_rect.origin.x + 6.0, chip_rect.origin.y + 14.0), - ); - x += w + 6.0; - } - y += CHIP_HEIGHT + 8.0; - } - // Variable rows. - 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), - }; - // Name on the left. - let name_layout = crate::TextLayout::single_run( - &var.name, - "system-ui", - 12.0, - to_jian_color(theme.foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &name_layout, - Point2D::new(row.origin.x + PAD_X, row.origin.y + 19.0), - ); - // Preview on the right. - 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. - let draft_layout = crate::TextLayout::single_run( - &self.editing_draft, - "system-ui", - 11.0, - to_jian_color(theme.foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &draft_layout, - Point2D::new(preview_x - 64.0, row.origin.y + 21.0), - ); - let underline = Rect { - origin: Point2D::new(preview_x - 70.0, row.origin.y + 23.0), - size: Point2D::new(80.0, 1.0), - }; - cx.backend.fill_rect(underline, theme.foreground); - } else { - paint_preview(cx, &theme, var, preview_x, row.origin.y + 7.0); - } - y += ROW_HEIGHT; - } - // Axis dropdown overlay — paints LAST so it covers the - // chip row + variable rows beneath. Anchored under the - // chip whose axis matches `dropdown_open`. - if let Some(open_axis) = self.dropdown_open.as_deref() { - if let Some((chip_idx, _)) = self - .chips - .iter() - .enumerate() - .find(|(_, c)| c.axis == open_axis) - { - 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 { - origin: Point2D::new(chip_rect.origin.x, menu_y), - size: Point2D::new( - DROPDOWN_WIDTH, - DROPDOWN_ROW_HEIGHT * (values.len() as f32), - ), - }; - cx.backend.fill_round_rect(menu_rect, 6.0, theme.popover); - cx.backend - .stroke_round_rect(menu_rect, 6.0, theme.border, 1.0); - let active_value = self - .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; - let is_active = *v == active_value; - if is_active { - let highlight = Rect { - origin: Point2D::new(menu_rect.origin.x + 2.0, row_y), - size: Point2D::new(menu_rect.size.x - 4.0, DROPDOWN_ROW_HEIGHT), - }; - cx.backend.fill_round_rect(highlight, 4.0, theme.muted); - } - let label = crate::TextLayout::single_run( - v, - "system-ui", - 11.0, - to_jian_color(theme.foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend.draw_text( - &label, - Point2D::new(menu_rect.origin.x + 10.0, row_y + 16.0), - ); - } - } - } - } + paint::paint_panel(self, cx, rect); } } -fn paint_preview(cx: &mut PaintCx<'_>, theme: &Theme, var: &VarRow, x: f32, y: f32) { - match var.kind { - VariableKind::Color => { - let rgba = var - .resolved - .as_ref() - .and_then(scalar_as_color) - .unwrap_or(Color::WHITE); - let swatch = Rect { - origin: Point2D::new(x, y), - size: Point2D::new(SWATCH_SIZE, SWATCH_SIZE), - }; - cx.backend.fill_round_rect(swatch, 3.0, rgba); - cx.backend.stroke_round_rect(swatch, 3.0, theme.border, 1.0); - } - _ => { - // 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 var.resolved.as_ref() { - Some(s) => scalar_to_label(s), - None => "—".into(), - }; - // Truncate long labels so they don't overflow. - let display = truncate(&text, 12); - let layout = crate::TextLayout::single_run( - &display, - "system-ui", - 11.0, - to_jian_color(theme.muted_foreground), - Point2D::new(0.0, 0.0), - ); - cx.backend - .draw_text(&layout, Point2D::new(x - 24.0, y + 14.0)); - } - } -} - -/// 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(), - VariableScalar::Num(n) => format!("{n}"), - VariableScalar::Bool(b) => if *b { "true" } else { "false" }.to_string(), - } -} - -fn truncate(s: &str, max: usize) -> String { - if s.chars().count() <= max { - return s.to_string(); - } - let mut out: String = s.chars().take(max - 1).collect(); - out.push('…'); - out -} - -fn to_jian_color(c: Color) -> jian_core::scene::Color { - fn ch(v: f32) -> u8 { - (v.clamp(0.0, 1.0) * 255.0).round() as u8 - } - jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) -} - #[cfg(test)] -mod tests { - use super::*; - use jian_ops_schema::variable::VariableScalar; - - 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 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 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 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); - 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 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()), - }; - 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; - match p.hit_test(rect, Point2D::new(click_x, click_y)) { - Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => { - assert_eq!(axis, "mode"); - assert_eq!(value, "light"); - } - other => panic!("expected AxisDropdownItem for row 0, got {other:?}"), - } - 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 }) => { - assert_eq!(axis, "mode"); - assert_eq!(value, "system"); - } - other => panic!("expected AxisDropdownItem for row 2, got {other:?}"), - } - } - - #[test] - fn hit_test_returns_row_index_for_in_row_click() { - 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()), - }; - 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)) { - Some(VariablesPanelHit::Row(1)) => {} - other => panic!("expected Row(1), got {other:?}"), - } - } - - #[test] - fn hit_test_returns_axis_chip_for_chip_click() { - 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()), - }; - let y = HEADER_HEIGHT + CHIP_HEIGHT / 2.0; - match p.hit_test(rect, Point2D::new(PAD_X + 4.0, y)) { - Some(VariablesPanelHit::AxisChip(0)) => {} - other => panic!("expected AxisChip(0), got {other:?}"), - } - } - - #[test] - fn hit_test_returns_none_outside_rect() { - 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), - }; - assert!(p.hit_test(rect, Point2D::new(-10.0, 50.0)).is_none()); - assert!(p.hit_test(rect, Point2D::new(50.0, 1000.0)).is_none()); - } - - #[test] - fn axis_chip_table_mirrors_active_theme_btree_order() { - 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"); - } -} +mod tests; diff --git a/crates/op-editor-ui/src/widgets/variables_panel/header.rs b/crates/op-editor-ui/src/widgets/variables_panel/header.rs new file mode 100644 index 00000000..761dcd98 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/variables_panel/header.rs @@ -0,0 +1,16 @@ +use crate::{Point2D, Rect}; + +pub(super) fn control_center_y(rect: Rect) -> f32 { + rect.origin.y + super::HEADER_HEIGHT / 2.0 +} + +pub(super) fn icon_origin(rect: Rect, x_offset: f32, size: f32) -> Point2D { + Point2D::new( + rect.origin.x + x_offset, + control_center_y(rect) - size / 2.0, + ) +} + +pub(super) fn text_baseline(rect: Rect, font_size: f32) -> f32 { + control_center_y(rect) + font_size * 0.36 +} diff --git a/crates/op-editor-ui/src/widgets/variables_panel/paint.rs b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs new file mode 100644 index 00000000..328e2a97 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/variables_panel/paint.rs @@ -0,0 +1,627 @@ +use super::*; +use crate::widgets::{draw_icon, Icon, PaintCx}; +use crate::{Color, Point2D, Rect}; + +const VARIABLE_NAME_PREFIX_STEP: f32 = 8.0; +const VARIABLE_NAME_PREFIX_TEXT_GAP: f32 = 2.0; + +pub(super) fn paint_panel(panel: &VariablesPanel, cx: &mut PaintCx<'_>, rect: Rect) { + let theme = panel.theme; + let labels = panel.labels(); + paint_panel_shadow(cx, rect); + cx.backend.fill_round_rect(rect, PANEL_RADIUS, theme.card); + cx.backend + .stroke_round_rect(rect, PANEL_RADIUS, theme.border, 1.0); + + let header_bottom = rect.origin.y + HEADER_HEIGHT; + let column_bottom = header_bottom + COLUMN_HEADER_HEIGHT; + let footer_top = rect.origin.y + rect.size.y - FOOTER_HEIGHT; + paint_hairline(cx, rect.origin.x, header_bottom, rect.size.x, theme.border); + paint_hairline(cx, rect.origin.x, column_bottom, rect.size.x, theme.border); + paint_hairline(cx, rect.origin.x, footer_top, rect.size.x, theme.border); + + paint_theme_header(panel, cx, rect); + paint_variant_header(panel, cx, rect, &labels); + paint_rows(panel, cx, rect, footer_top, &labels); + paint_footer(panel, cx, rect, footer_top, &labels); + paint_menus(panel, cx, rect, &labels); +} + +fn paint_theme_header(panel: &VariablesPanel, cx: &mut PaintCx<'_>, rect: Rect) { + let theme = panel.theme; + let mut x = rect.origin.x + PAD_X; + let active_axis = panel.active_axis_label(); + for axis in panel.theme_tab_labels() { + let is_active = axis == active_axis; + let color = if is_active { + theme.foreground + } else { + theme.muted_foreground + }; + if panel.renaming_theme.as_deref() == Some(axis) { + let input = Rect { + origin: Point2D::new(x - 2.0, rect.origin.y + 8.0), + size: Point2D::new(panel.theme_rename_input_width(), 28.0), + }; + paint_inline_input( + panel, + cx, + input, + RenameTarget::Theme(axis), + panel.editing_draft.as_str(), + ); + } else { + paint_text(cx, axis, 13.0, color, x, header::text_baseline(rect, 13.0)); + } + if is_active && panel.renaming_theme.as_deref() != Some(axis) { + let chevron_x = x + label_width(axis, 13.0) + 5.0; + draw_icon( + cx.backend, + Icon::ChevronDown, + header::icon_origin(rect, chevron_x - rect.origin.x, 11.0), + 11.0, + theme.muted_foreground, + 1.5, + ); + } + x += panel.theme_tab_advance_width(axis); + } + + let add_theme = panel.add_theme_rect(rect); + draw_icon( + cx.backend, + Icon::Plus, + Point2D::new(add_theme.origin.x + 6.0, add_theme.origin.y + 6.0), + 16.0, + theme.muted_foreground, + 1.8, + ); + + let preset = panel.preset_rect(rect); + draw_icon( + cx.backend, + Icon::BookOpen, + Point2D::new(preset.origin.x + 7.0, preset.origin.y + 8.0), + 15.0, + theme.muted_foreground, + 1.6, + ); + paint_text( + cx, + panel.labels().preset, + 13.0, + theme.muted_foreground, + preset.origin.x + 29.0, + header::text_baseline(rect, 13.0), + ); + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new(preset.origin.x + 92.0, preset.origin.y + 10.0), + 11.0, + theme.muted_foreground, + 1.5, + ); + + let close = close_rect(rect); + draw_icon( + cx.backend, + Icon::Close, + Point2D::new(close.origin.x + 5.0, close.origin.y + 5.0), + 16.0, + theme.muted_foreground, + 1.8, + ); +} + +fn paint_variant_header( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + labels: &VariablePanelLabels, +) { + let theme = panel.theme; + let header_bottom = rect.origin.y + HEADER_HEIGHT; + paint_text( + cx, + labels.name, + 13.0, + theme.muted_foreground, + rect.origin.x + PAD_X, + header_bottom + 23.0, + ); + + let value_x = value_column_x(rect); + let variants = panel.variant_column_labels(); + let col_w = variant_column_width(rect, variants.len()); + for (idx, variant) in variants.iter().enumerate() { + let x = value_x + col_w * idx as f32; + if panel.renaming_variant.as_deref() == Some(*variant) { + let input = Rect { + origin: Point2D::new(x - 2.0, header_bottom + 5.0), + size: Point2D::new( + (label_width(&panel.editing_draft, 13.0) + 28.0).max(128.0), + 26.0, + ), + }; + paint_inline_input( + panel, + cx, + input, + RenameTarget::Variant(variant), + panel.editing_draft.as_str(), + ); + } else { + paint_text( + cx, + variant, + 13.0, + theme.muted_foreground, + x, + header_bottom + 23.0, + ); + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new(x + label_width(variant, 13.0) + 6.0, header_bottom + 12.0), + 11.0, + theme.muted_foreground, + 1.5, + ); + } + } + draw_icon( + cx.backend, + Icon::Plus, + Point2D::new( + rect.origin.x + rect.size.x - PAD_X - 16.0, + header_bottom + 10.0, + ), + 16.0, + theme.muted_foreground, + 1.7, + ); +} + +fn paint_rows( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + footer_top: f32, + labels: &VariablePanelLabels, +) { + let theme = panel.theme; + if panel.rows.is_empty() { + paint_text( + cx, + labels.empty, + 14.0, + theme.muted_foreground, + rect.origin.x + rect.size.x / 2.0 - 52.0, + rect.origin.y + rect.size.y / 2.0, + ); + return; + } + + let variants = panel.variant_column_labels(); + let active_axis = panel.active_axis_label(); + let col_w = variant_column_width(rect, variants.len()); + let value_x = value_column_x(rect); + let mut y = rows_start_y(rect); + for (idx, var) in panel.rows.iter().enumerate() { + if y + ROW_HEIGHT > footer_top { + break; + } + paint_variable_name_cell(panel, cx, rect, var, idx, y); + for (variant_idx, variant) in variants.iter().enumerate() { + let cell_x = value_x + col_w * variant_idx as f32; + let scalar = panel.variant_scalar_for(var, active_axis, variant); + paint_value_cell( + panel, + cx, + var, + (idx, variant_idx), + scalar, + Point2D::new(cell_x, y + 10.0), + ); + } + y += ROW_HEIGHT; + } +} + +fn paint_variable_name_cell( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + var: &VarRow, + idx: usize, + y: f32, +) { + let theme = panel.theme; + let icon = match var.kind { + VariableKind::Color => Icon::Circle, + VariableKind::Number => Icon::Hash, + VariableKind::Boolean | VariableKind::String => Icon::Type, + }; + draw_icon( + cx.backend, + icon, + Point2D::new(rect.origin.x + PAD_X, y + 14.0), + 15.0, + theme.muted_foreground, + 1.6, + ); + let pill = name_cell_rect(rect, idx); + cx.backend.fill_round_rect(pill, 8.0, theme.muted); + let is_editing = panel.editing_name_row == Some(idx); + let name = if is_editing { + panel.editing_draft.as_str() + } else { + var.name.as_str() + }; + let text_x = pill.origin.x + 10.0; + let text_y = pill.origin.y + 20.0; + if is_editing { + let display = truncate(name, 24); + paint_text(cx, &display, 13.0, theme.foreground, text_x, text_y); + let Some(pos) = panel.name_caret_for_row(idx) else { + return; + }; + paint_caret_in_text(cx, theme, &display, pos, text_x, pill.origin.y + 6.0); + } else { + let display = truncate(name, 24); + paint_text(cx, "-", 13.0, theme.foreground, text_x, text_y); + paint_text( + cx, + "-", + 13.0, + theme.foreground, + text_x + VARIABLE_NAME_PREFIX_STEP, + text_y, + ); + paint_text( + cx, + &display, + 13.0, + theme.foreground, + text_x + VARIABLE_NAME_PREFIX_STEP * 2.0 + VARIABLE_NAME_PREFIX_TEXT_GAP, + text_y, + ); + } +} + +fn paint_value_cell( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + var: &VarRow, + cell: (usize, usize), + scalar: Option<&VariableScalar>, + origin: Point2D, +) { + let theme = panel.theme; + match var.kind { + VariableKind::Color => { + let rgba = scalar.and_then(scalar_as_color).unwrap_or(Color::WHITE); + let swatch = Rect { + origin, + size: Point2D::new(SWATCH_SIZE, SWATCH_SIZE), + }; + cx.backend.fill_round_rect(swatch, 3.0, rgba); + cx.backend.stroke_round_rect(swatch, 3.0, theme.border, 1.0); + let label = scalar + .and_then(scalar_hex_label) + .unwrap_or_else(|| "#000000".to_string()); + paint_text( + cx, + &label, + 13.0, + theme.foreground, + origin.x + 32.0, + origin.y + 15.0, + ); + paint_text( + cx, + "100 %", + 13.0, + theme.muted_foreground, + origin.x + 118.0, + origin.y + 15.0, + ); + } + _ => { + let text = if panel.editing_value_cell == Some(cell) { + panel.editing_draft.clone() + } else { + scalar + .map(scalar_to_label) + .or_else(|| var.resolved.as_ref().map(scalar_to_label)) + .unwrap_or_else(|| "—".into()) + }; + paint_text( + cx, + &truncate(&text, 18), + 13.0, + theme.foreground, + origin.x, + origin.y + 15.0, + ); + if let Some(pos) = panel.value_caret_for_cell(cell.0, cell.1) { + paint_caret_in_text(cx, theme, &text, pos, origin.x, origin.y); + } + } + } +} + +fn paint_footer( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + footer_top: f32, + labels: &VariablePanelLabels, +) { + let theme = panel.theme; + draw_icon( + cx.backend, + Icon::Plus, + Point2D::new(rect.origin.x + PAD_X, footer_top + 12.0), + 16.0, + theme.muted_foreground, + 1.8, + ); + paint_text( + cx, + labels.add_variable, + 14.0, + theme.muted_foreground, + rect.origin.x + PAD_X + 28.0, + footer_top + 27.0, + ); + draw_icon( + cx.backend, + Icon::ChevronDown, + Point2D::new(rect.origin.x + PAD_X + 84.0, footer_top + 14.0), + 12.0, + theme.muted_foreground, + 1.5, + ); +} + +fn paint_menus( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + labels: &VariablePanelLabels, +) { + let theme = panel.theme; + if panel.preset_menu_open { + paint_popover_rows( + cx, + theme, + panel.preset_menu_rect(rect), + &[ + labels.save_preset, + labels.no_presets, + labels.import, + labels.export, + ], + ); + } + if panel.add_menu_open { + paint_popover_rows( + cx, + theme, + add_variable_menu_rect(rect), + &[labels.color, labels.number, labels.string], + ); + } + if let Some(axis) = panel.theme_menu_open.as_deref() { + let mut rows = vec![labels.rename]; + if panel.theme_tab_labels().len() > 1 { + rows.push(labels.delete); + } + paint_popover_rows(cx, theme, panel.theme_menu_rect(rect, axis), &rows); + } + if let Some(value) = panel.variant_menu_open.as_deref() { + let mut rows = vec![labels.rename]; + if panel.variant_column_labels().len() > 1 { + rows.push(labels.delete); + } + paint_popover_rows(cx, theme, panel.variant_menu_rect(rect, value), &rows); + } + paint_axis_dropdown(panel, cx, rect); +} + +fn paint_axis_dropdown(panel: &VariablesPanel, cx: &mut PaintCx<'_>, rect: Rect) { + let theme = panel.theme; + let Some(open_axis) = panel.dropdown_open.as_deref() else { + return; + }; + let Some((chip_idx, _)) = panel + .chips + .iter() + .enumerate() + .find(|(_, c)| c.axis == open_axis) + else { + return; + }; + let Some(values) = panel.axis_values(open_axis) else { + return; + }; + let chip_rect = panel.chip_rect(rect, chip_idx); + let menu_y = chip_rect.origin.y + chip_rect.size.y + 4.0; + let menu_rect = Rect { + origin: Point2D::new(chip_rect.origin.x, menu_y), + size: Point2D::new(DROPDOWN_WIDTH, DROPDOWN_ROW_HEIGHT * (values.len() as f32)), + }; + cx.backend.fill_round_rect(menu_rect, 12.0, theme.popover); + cx.backend + .stroke_round_rect(menu_rect, 12.0, theme.border, 1.0); + let active_value = panel + .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; + if *v == active_value { + let highlight = Rect { + origin: Point2D::new(menu_rect.origin.x + 4.0, row_y + 3.0), + size: Point2D::new(menu_rect.size.x - 8.0, DROPDOWN_ROW_HEIGHT - 6.0), + }; + cx.backend.fill_round_rect(highlight, 8.0, theme.muted); + } + paint_text( + cx, + v, + 13.0, + theme.foreground, + menu_rect.origin.x + 12.0, + row_y + 23.0, + ); + } +} + +fn paint_hairline(cx: &mut PaintCx<'_>, x: f32, y: f32, w: f32, color: Color) { + cx.backend.fill_rect( + Rect { + origin: Point2D::new(x, y), + size: Point2D::new(w, 1.0), + }, + color, + ); +} + +fn paint_panel_shadow(cx: &mut PaintCx<'_>, rect: Rect) { + for (dy, alpha) in [(18.0, 0.08), (34.0, 0.04)] { + cx.backend.fill_round_rect( + Rect { + origin: Point2D::new(rect.origin.x, rect.origin.y + dy), + size: rect.size, + }, + PANEL_RADIUS, + Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: alpha, + }, + ); + } +} + +fn paint_popover_rows(cx: &mut PaintCx<'_>, theme: Theme, rect: Rect, rows: &[&str]) { + cx.backend.fill_round_rect(rect, 12.0, theme.popover); + cx.backend.stroke_round_rect(rect, 12.0, theme.border, 1.0); + for (idx, label) in rows.iter().enumerate() { + let row_y = rect.origin.y + idx as f32 * ADD_VARIABLE_MENU_ROW_HEIGHT; + paint_text( + cx, + label, + 12.0, + theme.popover_foreground, + rect.origin.x + 12.0, + row_y + 20.0, + ); + } +} + +fn paint_inline_input( + panel: &VariablesPanel, + cx: &mut PaintCx<'_>, + rect: Rect, + target: RenameTarget<'_>, + value: &str, +) { + let theme = panel.theme; + cx.backend.fill_round_rect(rect, 8.0, theme.muted); + cx.backend.stroke_round_rect(rect, 8.0, theme.primary, 1.5); + let value_x = rect.origin.x + 8.0; + let baseline_y = rect.origin.y + rect.size.y / 2.0 + 4.0; + paint_text(cx, value, 13.0, theme.foreground, value_x, baseline_y); + if let Some(pos) = panel.rename_text_caret(target) { + paint_caret_in_text(cx, theme, value, pos, value_x, rect.origin.y + 6.0); + } +} + +fn paint_caret_in_text( + cx: &mut PaintCx<'_>, + theme: Theme, + value: &str, + pos: usize, + x: f32, + y: f32, +) { + let clipped = text_boundary_at_or_before(value, pos); + let value_w = cx.backend.measure_text(&value[..clipped], 13.0); + cx.backend.fill_rect( + Rect { + origin: Point2D::new(x + value_w, y), + size: Point2D::new(1.5, 18.0), + }, + theme.foreground, + ); +} + +fn text_boundary_at_or_before(value: &str, pos: usize) -> usize { + let mut clipped = pos.min(value.len()); + while clipped > 0 && !value.is_char_boundary(clipped) { + clipped -= 1; + } + clipped +} + +fn paint_text(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32, baseline_y: f32) { + let layout = crate::TextLayout::single_run( + text, + "system-ui", + size, + to_jian_color(color), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text(&layout, Point2D::new(x, baseline_y)); +} + +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_hex_label(s: &VariableScalar) -> Option { + let VariableScalar::Str(hex) = s else { + return None; + }; + if hex.starts_with('#') && hex.len() >= 7 { + Some(hex[..7].to_string()) + } else { + Some(hex.clone()) + } +} + +fn scalar_to_label(s: &VariableScalar) -> String { + match s { + VariableScalar::Str(s) => s.clone(), + VariableScalar::Num(n) => format!("{n}"), + VariableScalar::Bool(b) => if *b { "true" } else { "false" }.to_string(), + } +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max - 1).collect(); + out.push('…'); + out +} + +fn to_jian_color(c: Color) -> jian_core::scene::Color { + fn ch(v: f32) -> u8 { + (v.clamp(0.0, 1.0) * 255.0).round() as u8 + } + jian_core::scene::Color::rgba(ch(c.r), ch(c.g), ch(c.b), ch(c.a)) +} diff --git a/crates/op-editor-ui/src/widgets/variables_panel/tests.rs b/crates/op-editor-ui/src/widgets/variables_panel/tests.rs new file mode 100644 index 00000000..0c418cca --- /dev/null +++ b/crates/op-editor-ui/src/widgets/variables_panel/tests.rs @@ -0,0 +1,491 @@ +use super::*; +use crate::widgets::PaintCx; +use crate::Color; +use jian_ops_schema::variable::{ThemedValue, VariableScalar, VariableValue}; +use std::collections::BTreeMap; + +#[derive(Default)] +struct TextCaptureBackend { + texts: Vec, + origins: Vec, +} + +impl crate::RenderBackend for TextCaptureBackend { + fn begin_frame(&mut self) {} + fn end_frame(&mut self) {} + fn fill_rect(&mut self, _: Rect, _: Color) {} + fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {} + fn draw_text(&mut self, layout: &crate::TextLayout, origin: Point2D) { + if let Some(run) = layout.runs().first() { + self.texts.push(run.content.clone()); + self.origins.push(origin); + } + } + fn clip_rect(&mut self, _: Rect) {} + fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {} + fn fill_round_rect(&mut self, _: Rect, _: f32, _: Color) {} + fn stroke_round_rect(&mut self, _: Rect, _: f32, _: Color, _: f32) {} + fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, _: Color, _: f32) {} + fn save(&mut self) {} + fn restore(&mut self) {} + fn translate(&mut self, _: Point2D) {} + fn resize(&mut self, _: u32, _: u32) {} + fn dpi_scale(&self) -> f32 { + 1.0 + } +} + +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 +} + +fn state_with_ts_like_themes() -> EditorState { + let mut s = EditorState::new(); + s.doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into(), "Variant-1".into()]); + s.doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-2".into(), vec!["Default".into(), "Compact".into()]); + s.ui.variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + s +} + +#[test] +fn row_count_matches_variable_count() { + 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 s = state_with_three_vars(); + let p = VariablesPanel::for_editor(&s); + assert_eq!(p.axis_count(), 1); +} + +#[test] +fn theme_tabs_follow_document_axes_like_ts() { + let s = state_with_ts_like_themes(); + let p = VariablesPanel::for_editor(&s); + + assert_eq!(p.theme_tab_labels(), vec!["Theme-1", "Theme-2"]); + assert_eq!(p.active_axis_label(), "Theme-1"); +} + +#[test] +fn variant_columns_follow_active_axis_values_like_ts() { + let s = state_with_ts_like_themes(); + let p = VariablesPanel::for_editor(&s); + + assert_eq!(p.variant_column_labels(), vec!["Default", "Variant-1"]); + assert_eq!(p.variant_column_count(), 2); +} + +#[test] +fn variables_without_themes_show_implicit_default_theme() { + let mut s = EditorState::new(); + s.create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + ); + let p = VariablesPanel::for_editor(&s); + + assert_eq!(p.theme_tab_labels(), vec!["Theme-1"]); + assert_eq!(p.variant_column_labels(), vec!["Default"]); +} + +#[test] +fn theme_tab_hit_targets_document_axis_like_ts() { + let s = state_with_ts_like_themes(); + 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()), + }; + + match p.hit_test(rect, Point2D::new(120.0, 22.0)) { + Some(VariablesPanelHit::ThemeTab(axis)) => assert_eq!(axis, "Theme-2"), + other => panic!("expected ThemeTab(Theme-2), got {other:?}"), + } +} + +#[test] +fn active_theme_tab_hit_toggles_theme_menu_like_ts() { + let s = state_with_ts_like_themes(); + 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()), + }; + + match p.hit_test(rect, Point2D::new(22.0, 22.0)) { + Some(VariablesPanelHit::ToggleThemeMenu(axis)) => assert_eq!(axis, "Theme-1"), + other => panic!("expected ToggleThemeMenu(Theme-1), got {other:?}"), + } +} + +#[test] +fn variant_header_hit_toggles_variant_menu_like_ts() { + let s = state_with_ts_like_themes(); + 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()), + }; + let point = Point2D::new(value_column_x(rect) + 12.0, HEADER_HEIGHT + 20.0); + + match p.hit_test(rect, point) { + Some(VariablesPanelHit::ToggleVariantMenu(value)) => assert_eq!(value, "Default"), + other => panic!("expected ToggleVariantMenu(Default), got {other:?}"), + } +} + +#[test] +fn open_theme_and_variant_menus_route_rename_rows() { + let mut s = state_with_ts_like_themes(); + s.editor_ui.variables_theme_menu_axis = Some("Theme-1".into()); + 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()), + }; + match p.hit_test(rect, Point2D::new(18.0, HEADER_HEIGHT + 22.0)) { + Some(VariablesPanelHit::ThemeMenuRename(axis)) => assert_eq!(axis, "Theme-1"), + other => panic!("expected ThemeMenuRename(Theme-1), got {other:?}"), + } + + s.editor_ui.variables_theme_menu_axis = None; + s.editor_ui.variables_variant_menu_value = Some("Variant-1".into()); + let p = VariablesPanel::for_editor(&s); + let menu_x = value_column_x(rect) + variant_column_width(rect, 2) + 8.0; + match p.hit_test( + rect, + Point2D::new(menu_x, HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + 20.0), + ) { + Some(VariablesPanelHit::VariantMenuRename(value)) => assert_eq!(value, "Variant-1"), + other => panic!("expected VariantMenuRename(Variant-1), got {other:?}"), + } +} + +#[test] +fn theme_rename_input_reserves_header_space_like_ts() { + let mut s = EditorState::new(); + s.doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + s.ui.variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + s.editor_ui.variables_current_axis = Some("Theme-1".into()); + s.editor_ui.variables_theme_rename_axis = Some("Theme-1".into()); + s.ui.property_input_draft = "ewe".into(); + 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()), + }; + let input_end = rect.origin.x + PAD_X - 2.0 + + (label_width(&s.ui.property_input_draft, 13.0) + 28.0).max(96.0); + + assert!( + p.add_theme_rect(rect).origin.x >= input_end + 4.0, + "add theme button must sit after the active rename input" + ); +} + +#[test] +fn editing_variable_name_caret_uses_raw_name_like_ts() { + let mut s = state_with_three_vars(); + s.editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + s.ui.property_input_draft = "color-1".into(); + s.ui.property_caret_pos = 3; + let p = VariablesPanel::for_editor_at(&s, 0); + + assert_eq!(p.name_caret_for_row(0), Some(3)); +} + +#[test] +fn variable_name_display_paints_two_literal_hyphens_like_ts() { + 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()), + }; + let mut backend = TextCaptureBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + p.paint(&mut cx, rect); + + assert!( + !backend.texts.iter().any(|text| text == "--color-1"), + "display mode should not shape the variable prefix as one text run" + ); + let idx = backend + .texts + .iter() + .position(|text| text == "color-1") + .expect("painted variable name text"); + assert!(idx >= 2, "name should be preceded by two hyphen runs"); + assert_eq!(backend.texts[idx - 2], "-"); + assert_eq!(backend.texts[idx - 1], "-"); + assert!( + backend.origins[idx - 1].x - backend.origins[idx - 2].x >= 8.0, + "the two variable prefix hyphens should be visually separated" + ); + assert!( + backend.origins[idx].x - backend.origins[idx - 1].x >= 8.0, + "the variable name should start after the second prefix hyphen" + ); +} + +#[test] +fn variable_rows_resolve_per_variant_values_like_ts() { + let mut s = state_with_ts_like_themes(); + let mut default_theme = BTreeMap::new(); + default_theme.insert("Theme-1".to_string(), "Default".to_string()); + let mut variant_theme = BTreeMap::new(); + variant_theme.insert("Theme-1".to_string(), "Variant-1".to_string()); + s.doc.variables.get_or_insert_with(Default::default).insert( + "color-1".into(), + jian_ops_schema::variable::VariableDefinition { + kind: VariableKind::Color, + value: VariableValue::Themed(vec![ + ThemedValue { + value: VariableScalar::Str("#c81919".into()), + theme: Some(default_theme), + }, + ThemedValue { + value: VariableScalar::Str("#0066ff".into()), + theme: Some(variant_theme), + }, + ]), + }, + ); + let p = VariablesPanel::for_editor(&s); + + assert_eq!( + p.variant_scalar_for(&p.rows[0], "Theme-1", "Default"), + Some(&VariableScalar::Str("#c81919".into())) + ); + assert_eq!( + p.variant_scalar_for(&p.rows[0], "Theme-1", "Variant-1"), + Some(&VariableScalar::Str("#0066ff".into())) + ); +} + +#[test] +fn intrinsic_height_grows_with_rows_and_chips() { + let s_empty = EditorState::new(); + let p = VariablesPanel::for_editor(&s_empty); + let empty_h = p.intrinsic_height(); + assert!( + (empty_h - (HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + FOOTER_HEIGHT)).abs() < f32::EPSILON + ); + 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 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()), + }; + let chip = p.chip_rect(rect, 0); + let menu_y = chip.origin.y + chip.size.y + 4.0; + let click_y = menu_y + DROPDOWN_ROW_HEIGHT * 0.5; + let click_x = chip.origin.x + 10.0; + match p.hit_test(rect, Point2D::new(click_x, click_y)) { + Some(VariablesPanelHit::AxisDropdownItem { axis, value }) => { + assert_eq!(axis, "mode"); + assert_eq!(value, "light"); + } + other => panic!("expected AxisDropdownItem for row 0, got {other:?}"), + } + 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 }) => { + assert_eq!(axis, "mode"); + assert_eq!(value, "system"); + } + other => panic!("expected AxisDropdownItem for row 2, got {other:?}"), + } +} + +#[test] +fn hit_test_returns_row_index_for_in_row_click() { + 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()), + }; + let y = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + ROW_HEIGHT * 1.0 + ROW_HEIGHT / 2.0; + match p.hit_test(rect, Point2D::new(PAD_X + 4.0, y)) { + Some(VariablesPanelHit::Row(1)) => {} + other => panic!("expected Row(1), got {other:?}"), + } +} + +#[test] +fn hit_test_returns_name_cell_for_variable_name_pill() { + 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()), + }; + let y = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + ROW_HEIGHT / 2.0; + match p.hit_test(rect, Point2D::new(PAD_X + 42.0, y)) { + Some(VariablesPanelHit::NameCell(0)) => {} + other => panic!("expected NameCell(0), got {other:?}"), + } + match p.hit_test(rect, Point2D::new(PAD_X + 4.0, y)) { + Some(VariablesPanelHit::Row(0)) => {} + other => panic!("expected Row(0), got {other:?}"), + } +} + +#[test] +fn hit_test_returns_variant_menu_for_column_header_click() { + 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()), + }; + let y = HEADER_HEIGHT + 8.0 + CHIP_HEIGHT / 2.0; + match p.hit_test(rect, Point2D::new(value_column_x(rect) + 4.0, y)) { + Some(VariablesPanelHit::ToggleVariantMenu(value)) => assert_eq!(value, "Default"), + other => panic!("expected ToggleVariantMenu(Default), got {other:?}"), + } +} + +#[test] +fn hit_test_returns_value_cell_for_variant_value_click() { + let mut s = state_with_ts_like_themes(); + s.create_variable("number", VariableKind::Number, VariableScalar::Num(0.0)); + 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()), + }; + let col_w = variant_column_width(rect, 2); + let x = value_column_x(rect) + col_w + 12.0; + let y = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + ROW_HEIGHT / 2.0; + + match p.hit_test(rect, Point2D::new(x, y)) { + Some(VariablesPanelHit::ValueCell { row, variant }) => { + assert_eq!(row, 0); + assert_eq!(variant, 1); + } + other => panic!("expected ValueCell(row=0, variant=1), got {other:?}"), + } +} + +#[test] +fn panel_buttons_are_hittable() { + let p = VariablesPanel::for_editor(&EditorState::new()); + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(VARIABLES_PANEL_WIDTH, 480.0), + }; + for point in [ + Point2D::new(24.0, 22.0), + Point2D::new(82.0, 22.0), + Point2D::new(rect.size.x - 24.0, HEADER_HEIGHT + 18.0), + Point2D::new(62.0, rect.size.y - 20.0), + ] { + assert!(p.hit_test(rect, point).is_some(), "{point:?}"); + } +} + +#[test] +fn header_controls_use_shared_vertical_center() { + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(VARIABLES_PANEL_WIDTH, 480.0), + }; + let center = header::control_center_y(rect); + assert_eq!(center, 22.0); + assert_eq!(header::icon_origin(rect, 16.0, 16.0).y, 14.0); + assert_eq!(header::icon_origin(rect, 118.0, 12.0).y, 16.0); + assert!((header::text_baseline(rect, 14.0) - 27.0).abs() < 0.1); +} + +#[test] +fn labels_follow_active_i18n_locale() { + let mut s = EditorState::new(); + s.editor_ui.locale = Locale::Ja; + let labels = VariablesPanel::for_editor(&s).labels(); + + assert_eq!(labels.preset, "プリセット"); + assert_eq!(labels.name, "名前"); + assert_eq!(labels.empty, "変数が定義されていません"); + assert_eq!(labels.add_variable, "変数を追加"); + assert_eq!(labels.save_preset, "現在の設定をプリセットとして保存…"); + assert_eq!(labels.color, "色"); + assert_eq!(labels.number, "数値"); + assert_eq!(labels.string, "文字列"); +} + +#[test] +fn hit_test_returns_none_outside_rect() { + 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), + }; + assert!(p.hit_test(rect, Point2D::new(-10.0, 50.0)).is_none()); + assert!(p.hit_test(rect, Point2D::new(50.0, 1000.0)).is_none()); +} + +#[test] +fn axis_chip_table_mirrors_active_theme_btree_order() { + 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); + assert_eq!(p.chips.len(), 2); + assert_eq!(p.chips[0].axis, "a-axis"); + assert_eq!(p.chips[1].axis, "z-axis"); +} diff --git a/crates/op-host-desktop/src/keyboard_input.rs b/crates/op-host-desktop/src/keyboard_input.rs index ad69ed7c..23f5e879 100644 --- a/crates/op-host-desktop/src/keyboard_input.rs +++ b/crates/op-host-desktop/src/keyboard_input.rs @@ -196,6 +196,8 @@ impl DesktopApp { "z" => consumed = self.host.apply_redo(), "g" => consumed = self.host.apply_ungroup(), "c" => consumed = self.host.apply_toggle_code_panel(), + "v" => consumed = self.host.apply_toggle_variables_panel(), + "d" => consumed = self.host.apply_toggle_design_md_panel(), _ => {} } } diff --git a/crates/op-host-native/src/lib.rs b/crates/op-host-native/src/lib.rs index ef50130a..12fe617a 100644 --- a/crates/op-host-native/src/lib.rs +++ b/crates/op-host-native/src/lib.rs @@ -74,6 +74,14 @@ pub mod canvas_view_stub; target_os = "ios", target_os = "android" ))] +mod system_fonts; +#[cfg(any( + target_os = "macos", + target_os = "linux", + target_os = "windows", + target_os = "ios", + target_os = "android" +))] pub mod widget_host; #[cfg(any( diff --git a/crates/op-host-native/src/system_fonts.rs b/crates/op-host-native/src/system_fonts.rs new file mode 100644 index 00000000..8634d1eb --- /dev/null +++ b/crates/op-host-native/src/system_fonts.rs @@ -0,0 +1,46 @@ +//! System font family enumeration for the native property panel. + +use std::sync::mpsc::{self, Receiver}; +use std::sync::OnceLock; + +static SYSTEM_FONT_FAMILIES: OnceLock> = OnceLock::new(); + +pub(crate) fn system_font_families() -> Vec { + SYSTEM_FONT_FAMILIES + .get_or_init(enumerate_system_font_families) + .clone() +} + +pub(crate) fn spawn_system_font_loader() -> Receiver> { + let (tx, rx) = mpsc::channel(); + let _ = std::thread::Builder::new() + .name("op-system-fonts".into()) + .spawn(move || { + let _ = tx.send(system_font_families()); + }); + rx +} + +fn enumerate_system_font_families() -> Vec { + let mgr = skia_safe::FontMgr::new(); + let families: Vec = mgr + .family_names() + .map(|family| family.trim().to_string()) + .filter(|family| !family.is_empty()) + .collect(); + op_editor_ui::widgets::property_panel_font_picker::prepare_system_font_families(families) +} + +#[cfg(test)] +mod tests { + #[test] + fn system_font_enumeration_returns_unique_names() { + let families = super::enumerate_system_font_families(); + for family in &families { + assert!(!family.trim().is_empty()); + } + for pair in families.windows(2) { + assert!(!pair[0].eq_ignore_ascii_case(&pair[1])); + } + } +} diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 3a282778..65d61d69 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -33,6 +33,7 @@ use op_editor_ui::widgets::SelectionHandle; use op_editor_ui::{Rect, Theme}; +use std::sync::mpsc::{Receiver, TryRecvError}; mod click; mod color_picker_press; @@ -47,6 +48,7 @@ mod input; #[cfg(test)] mod input_tests; mod keyboard; +mod keyboard_motion; mod paint; mod press; mod press_helpers; @@ -56,6 +58,11 @@ mod scroll; mod shape_picker_press; mod shortcuts; mod toolbar_hover; +mod variables_panel_commit; +mod variables_panel_geometry; +mod variables_panel_press; +#[cfg(test)] +mod variables_panel_tests; pub use frame_backend::NativeFrameBackend; @@ -193,6 +200,7 @@ pub struct WidgetHostNative { /// driving the color-picker drag). pub(in crate::widget_host) last_viewport_w: f32, pub(in crate::widget_host) last_viewport_h: f32, + system_font_rx: Option>>, } #[derive(Debug, Clone, Copy)] @@ -419,6 +427,7 @@ impl WidgetHostNative { shift_held: false, last_viewport_w: 0.0, last_viewport_h: 0.0, + system_font_rx: Some(crate::system_fonts::spawn_system_font_loader()), } } @@ -434,6 +443,30 @@ impl WidgetHostNative { /// animations via `jian_core::anim`. pub fn set_now_ms(&mut self, now_ms: u64) { self.now_ms = now_ms; + if self.poll_system_font_loader() { + self.mark_dirty(); + } + } + + fn poll_system_font_loader(&mut self) -> bool { + let Some(result) = self.system_font_rx.as_ref().map(|rx| rx.try_recv()) else { + return false; + }; + match result { + Ok(families) => { + self.system_font_rx = None; + if self.editor_state.editor_ui.system_font_families == families { + return false; + } + self.editor_state.editor_ui.system_font_families = families; + true + } + Err(TryRecvError::Empty) => false, + Err(TryRecvError::Disconnected) => { + self.system_font_rx = None; + false + } + } } /// Run a path boolean op on the active selection (Union / @@ -622,6 +655,31 @@ impl WidgetHostNative { 500, )); } + if self.editor_state.editor_ui.variable_row_focus.is_some() + || self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some() + { + return Some(jian_core::anim::next_blink_flip_ms( + self.now_ms, + ui.property_caret_anchor_ms, + 500, + )); + } + if self.editor_state.editor_ui.icon_picker_open { + return Some(jian_core::anim::next_blink_flip_ms( + self.now_ms, + self.editor_state.editor_ui.icon_picker_caret_anchor_ms, + 500, + )); + } if self.editor_state.chat.focused { return Some(jian_core::anim::next_blink_flip_ms( self.now_ms, diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index dc7f9085..0bd89cdd 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -502,6 +502,7 @@ impl WidgetHostNative { let p = Point2D::new(x, y); self.design_md_panel_rect(viewport_w, viewport_h) .is_some_and(|r| rect_contains(r, p)) + || self.over_variables_panel(x, y, viewport_w, viewport_h) || self .icon_picker_panel_rect(viewport_w, viewport_h) .is_some_and(|r| rect_contains(r, p)) diff --git a/crates/op-host-native/src/widget_host/icon_picker_press.rs b/crates/op-host-native/src/widget_host/icon_picker_press.rs index d3d8d6aa..2b1dac73 100644 --- a/crates/op-host-native/src/widget_host/icon_picker_press.rs +++ b/crates/op-host-native/src/widget_host/icon_picker_press.rs @@ -27,6 +27,7 @@ impl WidgetHostNative { self.editor_state.editor_ui.icon_picker_open = false; self.editor_state.editor_ui.icon_picker_replace_selection = false; self.editor_state.editor_ui.icon_picker_search.clear(); + self.editor_state.editor_ui.icon_picker_scroll = 0.0; } IconPickerHit::DragHeader => { self.icon_picker_drag = Some(IconPickerDragState { @@ -70,6 +71,7 @@ impl WidgetHostNative { ); self.editor_state.editor_ui.icon_picker_open = false; self.editor_state.editor_ui.icon_picker_search.clear(); + self.editor_state.editor_ui.icon_picker_scroll = 0.0; self.editor_state.editor_ui.icon_picker_replace_selection = false; if inserted.is_some() { self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index ba4e2875..d6651940 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -11,6 +11,16 @@ impl WidgetHostNative { ui.layer_rename.is_some() || ui.text_editing.is_some() || ui.property_focus.is_some() + || self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some() || self.editor_state.editor_ui.variable_row_focus.is_some() || self.editor_state.editor_ui.effect_param_focus.is_some() || self.editor_state.editor_ui.agent_settings.focus.is_some() diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index 37464dd0..711b6108 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -5,10 +5,12 @@ //! `host.editor_state` from canonical-schema JSON and assert against //! `editor_state` + the derived `LayoutScene` render scene. -use super::{NodeDragState, WidgetHostNative}; +use super::{helpers::TOOLBAR_INSET_X, helpers::TOOLBAR_INSET_Y, NodeDragState, WidgetHostNative}; use op_editor_core::ui_draft::PropertyFocus; use op_editor_core::NodeId; use op_editor_core::PenNodeExt; +use op_editor_ui::widgets::{LayoutCx, Toolbar, ToolbarAction, ToolbarHit, Widget, TOOLBAR_WIDTH}; +use op_editor_ui::{Point2D, Rect}; /// Seed a host's `editor_state` from a canonical `.op` JSON snippet. fn seed(host: &mut WidgetHostNative, json: &str) { @@ -36,6 +38,41 @@ fn three_rects(boxes: [(f64, f64, f64, f64); 3], ids: [&str; 3]) -> String { ) } +fn toolbar_action_point( + host: &WidgetHostNative, + action: ToolbarAction, + viewport_w: f32, + viewport_h: f32, +) -> Point2D { + let (cx0, _cy0, _cw, _ch) = host.canvas_region(viewport_w, viewport_h); + let toolbar = Toolbar::for_editor(host.editor_state()); + let toolbar_h = toolbar + .layout(&LayoutCx { + available_width: TOOLBAR_WIDTH, + dpi: 1.0, + }) + .rect + .size + .y; + let rect = Rect { + origin: Point2D::new( + cx0 + TOOLBAR_INSET_X, + op_editor_ui::widgets::TOP_BAR_HEIGHT + TOOLBAR_INSET_Y, + ), + size: Point2D::new(TOOLBAR_WIDTH, toolbar_h), + }; + let x = rect.origin.x + rect.size.x / 2.0; + let mut y = rect.origin.y; + while y <= rect.origin.y + rect.size.y { + let point = Point2D::new(x, y); + if toolbar.hit_test(rect, point) == Some(ToolbarHit::Action(action)) { + return point; + } + y += 1.0; + } + panic!("toolbar action {action:?} not hittable"); +} + #[test] fn escape_closes_one_overlay_per_press_in_priority_order() { // Codex CONCERN-2 regression: Escape used to clear all @@ -692,15 +729,34 @@ fn icon_picker_open_owns_keyboard_search() { host.editor_state_mut().editor_ui.icon_picker_open = true; assert!(host.input_active_pub()); + host.set_now_ms(42); assert!(host.apply_text('h')); + assert_eq!( + host.editor_state().editor_ui.icon_picker_caret_anchor_ms, + 42 + ); assert!(host.apply_text('o')); assert_eq!(host.editor_state().editor_ui.icon_picker_search, "ho"); + host.set_now_ms(96); assert!(host.apply_backspace()); + assert_eq!( + host.editor_state().editor_ui.icon_picker_caret_anchor_ms, + 96 + ); assert_eq!(host.editor_state().editor_ui.icon_picker_search, "h"); assert!(host.apply_escape()); assert!(!host.editor_state().editor_ui.icon_picker_open); } +#[test] +fn icon_picker_open_schedules_search_caret_blink() { + let mut host = WidgetHostNative::new(); + host.set_now_ms(1200); + host.editor_state_mut().editor_ui.icon_picker_open = true; + + assert!(host.next_animation_deadline_ms().is_some()); +} + #[test] fn icon_picker_click_inserts_icon_font_node() { let mut host = WidgetHostNative::new(); @@ -731,6 +787,46 @@ fn icon_picker_click_inserts_icon_font_node() { assert_eq!(host.editor_state().selection.anchor.as_str(), icon.base.id); } +#[test] +fn toolbar_design_button_toggles_design_md_panel() { + let mut host = WidgetHostNative::new(); + let viewport_w = 1280.0; + let viewport_h = 900.0; + assert!(!host.editor_state().editor_ui.design_md_panel_open); + + let point = toolbar_action_point( + &host, + ToolbarAction::ToggleDesignPanel, + viewport_w, + viewport_h, + ); + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert!(host.editor_state().editor_ui.design_md_panel_open); + + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert!(!host.editor_state().editor_ui.design_md_panel_open); +} + +#[test] +fn toolbar_variables_button_toggles_variables_panel() { + let mut host = WidgetHostNative::new(); + let viewport_w = 1280.0; + let viewport_h = 900.0; + assert!(!host.editor_state().editor_ui.variables_panel_open); + + let point = toolbar_action_point( + &host, + ToolbarAction::ToggleVariablesPanel, + viewport_w, + viewport_h, + ); + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert!(host.editor_state().editor_ui.variables_panel_open); + + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert!(!host.editor_state().editor_ui.variables_panel_open); +} + #[test] fn icon_picker_header_drag_moves_the_panel() { let mut host = WidgetHostNative::new(); diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 484a9f2a..52c3242d 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -68,20 +68,63 @@ impl WidgetHostNative { } return false; } - if let Some(focus) = self.editor_state.editor_ui.variable_row_focus { + if (self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some()) + && !c.is_control() + { + if self.editor_state.ui.property_draft_select_all { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + } self.editor_state.ui.property_draft_select_all = false; + let pos = text_boundary_at_or_before( + &self.editor_state.ui.property_input_draft, + self.editor_state.ui.property_caret_pos, + ); + self.editor_state.ui.property_input_draft.insert(pos, c); + self.editor_state.ui.property_caret_pos = pos + c.len_utf8(); + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + if let Some(focus) = self.editor_state.editor_ui.variable_row_focus { + let replace_selection = self.editor_state.ui.property_draft_select_all; + let draft = &self.editor_state.ui.property_input_draft; + let pos = if replace_selection { + 0 + } else { + text_boundary_at_or_before(draft, self.editor_state.ui.property_caret_pos) + }; let allowed = match focus { - VariableRowFocus::Number(_) => { + VariableRowFocus::Name(_) => !c.is_control(), + VariableRowFocus::Number(_) | VariableRowFocus::NumberCell { .. } => { c.is_ascii_digit() - || (c == '-' && self.editor_state.ui.property_input_draft.is_empty()) - || (c == '.' && !self.editor_state.ui.property_input_draft.contains('.')) + || (c == '-' + && (replace_selection || (pos == 0 && !draft.starts_with('-')))) + || (c == '.' && (replace_selection || !draft.contains('.'))) + } + VariableRowFocus::String(_) | VariableRowFocus::StringCell { .. } => { + !c.is_control() } - VariableRowFocus::String(_) => !c.is_control(), }; if !allowed { return false; } - self.editor_state.ui.property_input_draft.push(c); + if replace_selection { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + } + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_input_draft.insert(pos, c); + self.editor_state.ui.property_caret_pos = pos + c.len_utf8(); self.editor_state.ui.property_caret_anchor_ms = self.now_ms; self.mark_dirty(); return true; @@ -138,6 +181,8 @@ impl WidgetHostNative { } if self.editor_state.editor_ui.icon_picker_open && !c.is_control() { self.editor_state.editor_ui.icon_picker_search.push(c); + self.editor_state.editor_ui.icon_picker_caret_anchor_ms = self.now_ms; + self.editor_state.editor_ui.icon_picker_scroll = 0.0; self.mark_dirty(); return true; } @@ -222,9 +267,57 @@ impl WidgetHostNative { } return ok; } + if self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some() + { + if self.editor_state.ui.property_draft_select_all { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + let pos = text_boundary_at_or_before( + &self.editor_state.ui.property_input_draft, + self.editor_state.ui.property_caret_pos, + ); + if pos > 0 { + let prev = previous_text_boundary(&self.editor_state.ui.property_input_draft, pos); + self.editor_state.ui.property_input_draft.drain(prev..pos); + self.editor_state.ui.property_caret_pos = prev; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + return false; + } if self.editor_state.editor_ui.variable_row_focus.is_some() { + if self.editor_state.ui.property_draft_select_all { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } self.editor_state.ui.property_draft_select_all = false; - if self.editor_state.ui.property_input_draft.pop().is_some() { + let pos = text_boundary_at_or_before( + &self.editor_state.ui.property_input_draft, + self.editor_state.ui.property_caret_pos, + ); + if pos > 0 { + let prev = previous_text_boundary(&self.editor_state.ui.property_input_draft, pos); + self.editor_state.ui.property_input_draft.drain(prev..pos); + self.editor_state.ui.property_caret_pos = prev; self.editor_state.ui.property_caret_anchor_ms = self.now_ms; self.mark_dirty(); return true; @@ -255,6 +348,8 @@ impl WidgetHostNative { .pop() .is_some() { + self.editor_state.editor_ui.icon_picker_caret_anchor_ms = self.now_ms; + self.editor_state.editor_ui.icon_picker_scroll = 0.0; self.mark_dirty(); return true; } @@ -296,9 +391,57 @@ impl WidgetHostNative { /// Delete — pops a char from rename / text-edit when active; /// otherwise deletes the selected node. pub fn apply_delete(&mut self) -> bool { + if self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some() + { + if self.editor_state.ui.property_draft_select_all { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + let pos = text_boundary_at_or_before( + &self.editor_state.ui.property_input_draft, + self.editor_state.ui.property_caret_pos, + ); + if pos < self.editor_state.ui.property_input_draft.len() { + let next = next_text_boundary(&self.editor_state.ui.property_input_draft, pos); + self.editor_state.ui.property_input_draft.drain(pos..next); + self.editor_state.ui.property_caret_pos = pos; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + return false; + } if self.editor_state.editor_ui.variable_row_focus.is_some() { + if self.editor_state.ui.property_draft_select_all { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } self.editor_state.ui.property_draft_select_all = false; - if self.editor_state.ui.property_input_draft.pop().is_some() { + let pos = text_boundary_at_or_before( + &self.editor_state.ui.property_input_draft, + self.editor_state.ui.property_caret_pos, + ); + if pos < self.editor_state.ui.property_input_draft.len() { + let next = next_text_boundary(&self.editor_state.ui.property_input_draft, pos); + self.editor_state.ui.property_input_draft.drain(pos..next); + self.editor_state.ui.property_caret_pos = pos; self.editor_state.ui.property_caret_anchor_ms = self.now_ms; self.mark_dirty(); return true; @@ -346,133 +489,6 @@ impl WidgetHostNative { false } - /// Cmd-D — duplicate selection as a sibling at +10 doc px. - pub fn apply_duplicate(&mut self) -> bool { - if self.input_active() { - return false; - } - if self.editor_state.selection.is_empty() { - return false; - } - self.editor_state.commit_history(); - let dup = self - .editor_state - .duplicate_selected(&mut self.next_node_id, 10.0) - .is_some(); - if dup { - self.mark_dirty(); - } - dup - } - - /// Up / Down arrow on a focused numeric property input — steps - /// the value by `delta` and commits it (like a `−` / `+` - /// stepper). Returns `false` when no numeric property input is - /// focused, so the caller falls back to nudging the selection. - pub fn apply_property_step(&mut self, delta: f32) -> bool { - // Effect-parameter focus: step the value, commit via - // `SetEffectParam`, and reflect it back into the draft. - if let Some(ef) = self.editor_state.editor_ui.effect_param_focus { - let current: f32 = self - .editor_state - .ui - .property_input_draft - .trim() - .parse() - .unwrap_or(0.0); - let next = current + delta; - let id = self.editor_state.selection.anchor.clone(); - if id.is_real() { - self.editor_state.commit_history(); - let _ = self - .editor_state - .apply(op_editor_core::EditorCommand::SetEffectParam { - node_id: id, - index: ef.effect as u32, - field: ef.field, - value: next, - }); - } - self.editor_state.ui.property_input_draft = if next.fract() == 0.0 { - format!("{}", next as i64) - } else { - format!("{next}") - }; - self.editor_state.ui.property_caret_pos = - self.editor_state.ui.property_input_draft.len(); - self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.mark_dirty(); - return true; - } - let Some(focus) = self.editor_state.ui.property_focus else { - return false; - }; - // Hex colour fields aren't numerically steppable. - if focus.is_hex() { - return false; - } - let current: f32 = self - .editor_state - .ui - .property_input_draft - .trim() - .parse() - .unwrap_or(0.0); - let next = current + delta; - let _ = self.editor_state.commit_property_edit(focus, next); - // Reflect the committed value back into the draft so the - // field shows it and a further step builds on the new value. - self.editor_state.ui.property_input_draft = if next.fract() == 0.0 { - format!("{}", next as i64) - } else { - format!("{next}") - }; - self.editor_state.ui.property_caret_pos = self.editor_state.ui.property_input_draft.len(); - self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.mark_dirty(); - true - } - - /// Left / Right arrow on a focused property input — moves the - /// text caret one character. Returns `false` when no property - /// input is focused, so the caller falls back to node-nudge. - pub fn apply_property_caret(&mut self, forward: bool) -> bool { - if self.editor_state.ui.property_focus.is_none() - && self.editor_state.editor_ui.effect_param_focus.is_none() - { - return false; - } - let len = self.editor_state.ui.property_input_draft.len(); - let pos = self.editor_state.ui.property_caret_pos.min(len); - let next = if forward { - (pos + 1).min(len) - } else { - pos.saturating_sub(1) - }; - if next != self.editor_state.ui.property_caret_pos { - self.editor_state.ui.property_caret_pos = next; - self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - self.mark_dirty(); - } - // Consumed regardless — an arrow over a focused input must - // never fall through to nudging the selected node. - true - } - - /// Arrow-key nudge — translate selection by (dx, dy) doc px. - pub fn apply_nudge(&mut self, dx: f32, dy: f32) -> bool { - if self.input_active() { - return false; - } - if self.editor_state.selection.is_empty() { - return false; - } - self.editor_state.commit_history(); - self.editor_state.translate_selected(dx as f64, dy as f64); - self.mark_dirty(); - true - } - pub fn apply_send(&mut self) -> bool { if self.editor_state.editor_ui.agent_settings.focus.is_some() { self.commit_settings_focus_if_any(); @@ -533,6 +549,20 @@ impl WidgetHostNative { } return ok; } + if self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_some() + { + self.commit_variables_panel_header_focus_if_any(); + return true; + } if self.editor_state.editor_ui.variable_row_focus.is_some() { self.commit_variable_row_focus_if_any(); return true; @@ -623,6 +653,24 @@ impl WidgetHostNative { self.mark_dirty(); return true; } + if self + .editor_state + .editor_ui + .variables_theme_rename_axis + .take() + .is_some() + || self + .editor_state + .editor_ui + .variables_variant_rename_value + .take() + .is_some() + { + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_draft_select_all = false; + self.mark_dirty(); + return true; + } if self .editor_state .editor_ui @@ -669,6 +717,7 @@ impl WidgetHostNative { self.editor_state.editor_ui.icon_picker_open = false; self.editor_state.editor_ui.icon_picker_replace_selection = false; self.editor_state.editor_ui.icon_picker_search.clear(); + self.editor_state.editor_ui.icon_picker_scroll = 0.0; self.mark_dirty(); return true; } @@ -700,3 +749,28 @@ impl WidgetHostNative { false } } + +fn text_boundary_at_or_before(value: &str, pos: usize) -> usize { + let mut clipped = pos.min(value.len()); + while clipped > 0 && !value.is_char_boundary(clipped) { + clipped -= 1; + } + clipped +} + +fn previous_text_boundary(value: &str, pos: usize) -> usize { + let pos = text_boundary_at_or_before(value, pos); + value[..pos] + .char_indices() + .last() + .map(|(idx, _)| idx) + .unwrap_or(0) +} + +fn next_text_boundary(value: &str, pos: usize) -> usize { + let pos = text_boundary_at_or_before(value, pos); + if pos >= value.len() { + return value.len(); + } + pos + value[pos..].chars().next().map(char::len_utf8).unwrap_or(0) +} diff --git a/crates/op-host-native/src/widget_host/keyboard_motion.rs b/crates/op-host-native/src/widget_host/keyboard_motion.rs new file mode 100644 index 00000000..126a0ab3 --- /dev/null +++ b/crates/op-host-native/src/widget_host/keyboard_motion.rs @@ -0,0 +1,152 @@ +use super::WidgetHostNative; + +impl WidgetHostNative { + /// Cmd-D — duplicate selection as a sibling at +10 doc px. + pub fn apply_duplicate(&mut self) -> bool { + if self.input_active() { + return false; + } + if self.editor_state.selection.is_empty() { + return false; + } + self.editor_state.commit_history(); + let dup = self + .editor_state + .duplicate_selected(&mut self.next_node_id, 10.0) + .is_some(); + if dup { + self.mark_dirty(); + } + dup + } + + /// Up / Down arrow on a focused numeric property input — steps + /// the value by `delta` and commits it. + pub fn apply_property_step(&mut self, delta: f32) -> bool { + if let Some(ef) = self.editor_state.editor_ui.effect_param_focus { + let current: f32 = self + .editor_state + .ui + .property_input_draft + .trim() + .parse() + .unwrap_or(0.0); + let next = current + delta; + let id = self.editor_state.selection.anchor.clone(); + if id.is_real() { + self.editor_state.commit_history(); + let _ = self + .editor_state + .apply(op_editor_core::EditorCommand::SetEffectParam { + node_id: id, + index: ef.effect as u32, + field: ef.field, + value: next, + }); + } + self.seed_property_step_draft(next); + return true; + } + let Some(focus) = self.editor_state.ui.property_focus else { + return false; + }; + if focus.is_hex() { + return false; + } + let current: f32 = self + .editor_state + .ui + .property_input_draft + .trim() + .parse() + .unwrap_or(0.0); + let next = current + delta; + let _ = self.editor_state.commit_property_edit(focus, next); + self.seed_property_step_draft(next); + true + } + + /// Left / Right arrow on a focused property input. + pub fn apply_property_caret(&mut self, forward: bool) -> bool { + if self.editor_state.ui.property_focus.is_none() + && self.editor_state.editor_ui.effect_param_focus.is_none() + && self + .editor_state + .editor_ui + .variables_theme_rename_axis + .is_none() + && self + .editor_state + .editor_ui + .variables_variant_rename_value + .is_none() + && self.editor_state.editor_ui.variable_row_focus.is_none() + { + return false; + } + let draft = &self.editor_state.ui.property_input_draft; + let pos = text_boundary_at_or_before(draft, self.editor_state.ui.property_caret_pos); + let next = if forward { + next_text_boundary(draft, pos) + } else { + previous_text_boundary(draft, pos) + }; + if next != self.editor_state.ui.property_caret_pos { + self.editor_state.ui.property_caret_pos = next; + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + } + true + } + + /// Arrow-key nudge — translate selection by (dx, dy) doc px. + pub fn apply_nudge(&mut self, dx: f32, dy: f32) -> bool { + if self.input_active() { + return false; + } + if self.editor_state.selection.is_empty() { + return false; + } + self.editor_state.commit_history(); + self.editor_state.translate_selected(dx as f64, dy as f64); + self.mark_dirty(); + true + } + + fn seed_property_step_draft(&mut self, value: f32) { + self.editor_state.ui.property_input_draft = if value.fract() == 0.0 { + format!("{}", value as i64) + } else { + format!("{value}") + }; + self.editor_state.ui.property_caret_pos = self.editor_state.ui.property_input_draft.len(); + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + } +} + +fn text_boundary_at_or_before(value: &str, pos: usize) -> usize { + let mut clipped = pos.min(value.len()); + while clipped > 0 && !value.is_char_boundary(clipped) { + clipped -= 1; + } + clipped +} + +fn previous_text_boundary(value: &str, pos: usize) -> usize { + let pos = text_boundary_at_or_before(value, pos); + value[..pos] + .char_indices() + .last() + .map(|(idx, _)| idx) + .unwrap_or(0) +} + +fn next_text_boundary(value: &str, pos: usize) -> usize { + let pos = text_boundary_at_or_before(value, pos); + if pos >= value.len() { + return value.len(); + } + pos + value[pos..].chars().next().map(char::len_utf8).unwrap_or(0) +} diff --git a/crates/op-host-native/src/widget_host/paint.rs b/crates/op-host-native/src/widget_host/paint.rs index 6948857c..7ef5d8fe 100644 --- a/crates/op-host-native/src/widget_host/paint.rs +++ b/crates/op-host-native/src/widget_host/paint.rs @@ -121,7 +121,6 @@ impl WidgetHostNative { // 5. PropertyPanel — only when selection. let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms); - let has_property = property_panel.is_some(); let property_panel_width = ui.property_panel_width; let right_rail_x = viewport_width - property_panel_width; if let Some(panel) = property_panel.as_ref() { @@ -138,39 +137,10 @@ impl WidgetHostNative { panel.paint(&mut cx, property_rect); } - // 5b. VariablesPanel — paints whenever the document has - // variables (so users with themed `.op` files see them - // immediately without needing to select a node). Sits in - // the same right-rail column as PropertyPanel. When a - // 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. - 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 - // PropertyPanel's own intrinsic height proxy. The - // property panel paints to fill the rail, so we put - // Variables at the bottom of the rail above the - // status bar; users scroll the property pane - // separately. Approximate: anchor to bottom-of-rail. - let bottom_pad = STATUS_BAR_HEIGHT + 16.0; - (viewport_height - bottom_pad - intrinsic).max(TOP_BAR_HEIGHT + 8.0) - } else { - TOP_BAR_HEIGHT + 8.0 - }; - let vars_rect = Rect { - origin: Point2D::new(right_rail_x, top_y), - size: Point2D::new(property_panel_width, intrinsic), - }; + // 5b. VariablesPanel — mirrors TS' `{}` toolbar toggle as a + // floating canvas overlay next to the toolbar. + if let Some(vars_rect) = self.variables_panel_rect(viewport_width, viewport_height) { + let vars = VariablesPanel::for_editor_at(&self.editor_state, self.now_ms); let mut cx = PaintCx { backend: &mut *frame, }; @@ -456,7 +426,7 @@ impl WidgetHostNative { // dropdown. It sits above the component browser and // below Design-MD, matching the press routing order. if let (Some(panel), Some(panel_rect)) = ( - IconPickerPanel::for_editor(&self.editor_state), + IconPickerPanel::for_editor_at(&self.editor_state, self.now_ms), self.icon_picker_panel_rect(viewport_width, viewport_height), ) { let mut cx = PaintCx { diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index 2cd00e3d..1aa6fc68 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -508,7 +508,10 @@ impl WidgetHostNative { let acted = match action { ToolbarAction::Undo => self.editor_state.undo(), ToolbarAction::Redo => self.editor_state.redo(), - _ => false, + ToolbarAction::ToggleVariablesPanel => { + self.apply_toggle_variables_panel() + } + ToolbarAction::ToggleDesignPanel => self.apply_toggle_design_md_panel(), }; self.mark_dirty(); return acted || rename_committed || text_edit_committed; diff --git a/crates/op-host-native/src/widget_host/property_dispatch.rs b/crates/op-host-native/src/widget_host/property_dispatch.rs index 38ad67ff..86263f80 100644 --- a/crates/op-host-native/src/widget_host/property_dispatch.rs +++ b/crates/op-host-native/src/widget_host/property_dispatch.rs @@ -107,6 +107,8 @@ impl WidgetHostNative { ui.icon_picker_open = true; ui.icon_picker_replace_selection = true; ui.icon_picker_search.clear(); + ui.icon_picker_caret_anchor_ms = self.now_ms; + ui.icon_picker_scroll = 0.0; ui.fill_type_picker_open = false; ui.image_fill_popover_open = false; ui.font_family_picker_open = false; @@ -124,15 +126,20 @@ impl WidgetHostNative { } A::ToggleFontFamilyPicker => { let ui = &mut self.editor_state.editor_ui; - ui.font_family_picker_open = !ui.font_family_picker_open; + let opening = !ui.font_family_picker_open; + ui.font_family_picker_open = opening; + if opening { + ui.font_family_picker_scroll = 0.0; + } ui.fill_type_picker_open = false; ui.image_fill_popover_open = false; ui.export_scale_picker_open = false; ui.export_format_picker_open = false; } - A::SetFontFamily(choice) => { - self.set_selected_text_font_family(choice.family()); + A::SetFontFamily(family) => { + self.set_selected_text_font_family(&family); self.editor_state.editor_ui.font_family_picker_open = false; + self.editor_state.editor_ui.font_family_picker_scroll = 0.0; } A::OpenColorPicker(target) => { // Fallback anchor when called outside the press path. @@ -364,6 +371,7 @@ impl WidgetHostNative { } } self.editor_state.editor_ui.font_family_picker_open = false; + self.editor_state.editor_ui.font_family_picker_scroll = 0.0; self.mark_dirty(); true } @@ -490,51 +498,6 @@ impl WidgetHostNative { self.mark_dirty(); } - /// Commit any pending VariablesPanel row edit (Number / String). - pub(in crate::widget_host) fn commit_variable_row_focus_if_any(&mut self) { - use op_editor_core::editor_ui_state::VariableRowFocus; - let Some(focus) = self.editor_state.editor_ui.variable_row_focus.take() else { - return; - }; - self.editor_state.ui.property_draft_select_all = false; - let draft = std::mem::take(&mut self.editor_state.ui.property_input_draft); - // Resolve the row index → variable name off the editor-state - // var-table (the same Vec the VariablesPanel widget walks). - let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); - let snap = self.editor_state.snapshot_for_history(); - // Every path below has already cleared focus + drained the - // draft, so each exit must finalize through `mark_dirty` or the - // derived render scene stays stale after an invalid edit. An - // inner closure makes the "did the value commit" branches - // return into one place that always marks dirty. - let committed = (|| -> bool { - match focus { - VariableRowFocus::Number(idx) => { - let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { - return false; - }; - let Ok(n) = draft.trim().parse::() else { - return false; - }; - if !n.is_finite() { - return false; - } - self.editor_state.set_variable_number(&name, n) - } - VariableRowFocus::String(idx) => { - let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { - return false; - }; - self.editor_state.set_variable_string(&name, draft) - } - } - })(); - if committed { - self.editor_state.history_push_past(snap); - } - self.mark_dirty(); - } - /// Commit a pending effect-parameter edit (Effects section's /// editable value box). Parses the shared draft and writes it /// via `SetEffectParam`; a non-numeric draft is discarded. @@ -565,6 +528,7 @@ impl WidgetHostNative { pub(in crate::widget_host) fn commit_property_focus_if_any(&mut self) { // Commit any pending variable-row / effect-param edit first. + self.commit_variables_panel_header_focus_if_any(); self.commit_variable_row_focus_if_any(); self.commit_effect_param_focus_if_any(); let Some(focus) = self.editor_state.ui.property_focus.take() else { @@ -627,136 +591,6 @@ impl WidgetHostNative { } self.mark_dirty(); } - - /// VariablesPanel press dispatcher. - pub(in crate::widget_host) fn dispatch_variables_panel_press( - &mut self, - x: f32, - y: f32, - viewport_width: f32, - viewport_height: f32, - ) -> bool { - let has_variables = self - .editor_state - .doc - .variables - .as_ref() - .map(|v| !v.is_empty()) - .unwrap_or(false); - if !has_variables { - return false; - } - use op_editor_ui::widgets::variables_panel::{VariablesPanel, VariablesPanelHit}; - use op_editor_ui::widgets::{STATUS_BAR_HEIGHT, TOP_BAR_HEIGHT}; - use op_editor_ui::{Point2D, Rect}; - 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; - (viewport_height - bottom_pad - intrinsic).max(TOP_BAR_HEIGHT + 8.0) - } else { - TOP_BAR_HEIGHT + 8.0 - }; - let vars_rect = Rect { - origin: Point2D::new( - viewport_width - self.editor_state.editor_ui.property_panel_width, - top_y, - ), - size: Point2D::new(self.editor_state.editor_ui.property_panel_width, intrinsic), - }; - let Some(hit) = vars.hit_test(vars_rect, Point2D::new(x, y)) else { - return false; - }; - match hit { - VariablesPanelHit::Row(idx) => { - // Resolve (name, kind) off the editor-state var-table. - use op_editor_ui::scene_vars::VariableKind; - let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); - let Some((name, kind)) = var_table - .variables - .get(idx) - .map(|v| (v.name.clone(), v.kind)) - else { - return true; - }; - match kind { - VariableKind::Color => { - self.commit_property_focus_if_any(); - let _ = self.editor_state.open_color_picker_for_variable(name, y); - } - VariableKind::Boolean => { - // Toggle the boolean value through the - // active-theme-aware setter. - let current = self - .editor_state - .resolve_variable(&name) - .and_then(|s| match s { - jian_ops_schema::variable::VariableScalar::Bool(b) => Some(*b), - _ => None, - }) - .unwrap_or(false); - self.commit_property_focus_if_any(); - let snap = self.editor_state.snapshot_for_history(); - if self.editor_state.set_variable_boolean(&name, !current) { - self.editor_state.history_push_past(snap); - } - } - VariableKind::Number | VariableKind::String => { - use op_editor_core::editor_ui_state::VariableRowFocus; - self.commit_property_focus_if_any(); - self.commit_variable_row_focus_if_any(); - // Seed the draft from the resolved scalar. - use jian_ops_schema::variable::VariableScalar; - let resolved = self.editor_state.resolve_variable(&name).cloned(); - self.editor_state.ui.property_input_draft = match (&kind, &resolved) { - (VariableKind::Number, Some(VariableScalar::Num(n))) => { - format!("{n}") - } - (VariableKind::String, Some(VariableScalar::Str(s))) => s.clone(), - _ => String::new(), - }; - self.editor_state.editor_ui.variable_row_focus = Some(match kind { - VariableKind::Number => VariableRowFocus::Number(idx), - VariableKind::String => VariableRowFocus::String(idx), - _ => return true, - }); - self.editor_state.ui.property_caret_anchor_ms = self.now_ms; - } - } - self.mark_dirty(); - true - } - VariablesPanelHit::AxisChip(idx) => { - // Look up the axis name from the active-theme map - // (BTreeMap iteration order is stable, matching the - // chip walk order in VariablesPanel). - let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); - let axis = var_table.active_theme.keys().nth(idx).cloned(); - if let Some(name) = axis { - self.commit_property_focus_if_any(); - if self.editor_state.editor_ui.axis_dropdown_open.as_deref() - == Some(name.as_str()) - { - self.editor_state.editor_ui.axis_dropdown_open = None; - } else { - self.editor_state.editor_ui.axis_dropdown_open = Some(name); - } - } - self.mark_dirty(); - true - } - VariablesPanelHit::AxisDropdownItem { axis, value } => { - self.commit_property_focus_if_any(); - let snap = self.editor_state.snapshot_for_history(); - if self.editor_state.set_active_axis_value(&axis, &value) { - self.editor_state.history_push_past(snap); - } - self.editor_state.editor_ui.axis_dropdown_open = None; - self.mark_dirty(); - true - } - } - } } /// Read the live alpha of gradient stop `index` on `node`, parsed diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index fc1c8396..cf7613f5 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -5,10 +5,72 @@ use super::helpers::rect_contains; use super::WidgetHostNative; -use op_editor_ui::widgets::GitPanel; +use op_editor_ui::widgets::{GitPanel, IconPickerPanel}; use op_editor_ui::Point2D; impl WidgetHostNative { + fn try_scroll_icon_picker( + &mut self, + x: f32, + y: f32, + delta: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + let Some(panel_rect) = self.icon_picker_panel_rect(viewport_width, viewport_height) else { + return false; + }; + if !rect_contains(panel_rect, Point2D::new(x, y)) { + return false; + } + let max = IconPickerPanel::for_editor(&self.editor_state) + .map(|panel| panel.max_scroll(panel_rect)) + .unwrap_or(0.0); + let next = (self.editor_state.editor_ui.icon_picker_scroll - delta).clamp(0.0, max); + if next != self.editor_state.editor_ui.icon_picker_scroll { + self.editor_state.editor_ui.icon_picker_scroll = next; + self.mark_dirty(); + } + true + } + + fn try_scroll_font_family_picker( + &mut self, + x: f32, + y: f32, + delta: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + use op_editor_ui::widgets::{PropertyPanel, TOP_BAR_HEIGHT}; + use op_editor_ui::Rect; + if !self.editor_state.editor_ui.font_family_picker_open { + return false; + } + let Some(panel) = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms) else { + return false; + }; + let pw = self.editor_state.editor_ui.property_panel_width; + let property_rect = Rect { + origin: Point2D::new(viewport_width - pw, TOP_BAR_HEIGHT), + size: Point2D::new(pw, (viewport_height - TOP_BAR_HEIGHT).max(0.0)), + }; + let point = Point2D::new(x, y); + let Some(picker) = panel.font_family_picker_bounds(property_rect) else { + return false; + }; + if !rect_contains(picker, point) { + return false; + } + let max = panel.font_family_picker_max_scroll(property_rect); + let next = (self.editor_state.editor_ui.font_family_picker_scroll - delta).clamp(0.0, max); + if next != self.editor_state.editor_ui.font_family_picker_scroll { + self.editor_state.editor_ui.font_family_picker_scroll = next; + self.mark_dirty(); + } + true + } + /// Scroll the right-rail PropertyPanel when a wheel / trackpad /// pan lands over it. `delta` is the vertical scroll delta /// (wheel `delta_y` or pan `dy`). Returns `true` when the cursor @@ -89,6 +151,12 @@ impl WidgetHostNative { viewport_width: f32, viewport_height: f32, ) -> bool { + if self.try_scroll_icon_picker(x, y, delta_y, viewport_width, viewport_height) { + return true; + } + if self.try_scroll_font_family_picker(x, y, delta_y, viewport_width, viewport_height) { + return true; + } // Any top-most floating panel (Design-MD / Component-Browser) // owns the wheel before lower layers — a scroll over them // never reaches the modal / Git panel / canvas. @@ -209,6 +277,12 @@ impl WidgetHostNative { viewport_width: f32, viewport_height: f32, ) -> bool { + if self.try_scroll_icon_picker(x, y, dy, viewport_width, viewport_height) { + return true; + } + if self.try_scroll_font_family_picker(x, y, dy, viewport_width, viewport_height) { + return true; + } // Any top-most floating panel owns trackpad scroll first. if self.over_topmost_panel(x, y, viewport_width, viewport_height) { return true; diff --git a/crates/op-host-native/src/widget_host/shape_picker_press.rs b/crates/op-host-native/src/widget_host/shape_picker_press.rs index 4a54f510..34b39554 100644 --- a/crates/op-host-native/src/widget_host/shape_picker_press.rs +++ b/crates/op-host-native/src/widget_host/shape_picker_press.rs @@ -30,6 +30,8 @@ impl WidgetHostNative { self.editor_state.editor_ui.icon_picker_open = true; self.editor_state.editor_ui.icon_picker_replace_selection = false; self.editor_state.editor_ui.icon_picker_search.clear(); + self.editor_state.editor_ui.icon_picker_caret_anchor_ms = self.now_ms; + self.editor_state.editor_ui.icon_picker_scroll = 0.0; } ShapeChoice::ImportImageOrSvg => { self.editor_state.editor_ui.pending_file_action = diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index 7e6076a6..849ec6c1 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -155,6 +155,24 @@ impl WidgetHostNative { true } + /// Cmd+Shift+V / toolbar `{}` — open / close the Variables panel. + pub fn apply_toggle_variables_panel(&mut self) -> bool { + self.commit_variable_row_focus_if_any(); + let ui = &mut self.editor_state.editor_ui; + ui.variables_panel_open = !ui.variables_panel_open; + self.mark_dirty(); + true + } + + /// Cmd+Shift+D / toolbar book — open / close the Design.md panel. + pub fn apply_toggle_design_md_panel(&mut self) -> bool { + self.commit_variable_row_focus_if_any(); + let ui = &mut self.editor_state.editor_ui; + ui.design_md_panel_open = !ui.design_md_panel_open; + self.mark_dirty(); + true + } + /// Cmd+, — open / close the floating agent-settings modal. pub fn apply_toggle_agent_settings(&mut self) -> bool { self.commit_variable_row_focus_if_any(); @@ -199,6 +217,7 @@ impl WidgetHostNative { /// export actions so the typed value lands before the file /// op runs. pub fn commit_variable_row_focus_if_any_pub(&mut self) { + self.commit_variables_panel_header_focus_if_any(); self.commit_variable_row_focus_if_any(); } diff --git a/crates/op-host-native/src/widget_host/variables_panel_commit.rs b/crates/op-host-native/src/widget_host/variables_panel_commit.rs new file mode 100644 index 00000000..b151cc86 --- /dev/null +++ b/crates/op-host-native/src/widget_host/variables_panel_commit.rs @@ -0,0 +1,262 @@ +use super::WidgetHostNative; +use std::collections::BTreeMap; + +enum VariableHeaderFocus { + Theme(String), + Variant(String), +} + +impl WidgetHostNative { + /// Commit any pending VariablesPanel theme/variant header rename. + pub(in crate::widget_host) fn commit_variables_panel_header_focus_if_any(&mut self) { + let theme_axis = self + .editor_state + .editor_ui + .variables_theme_rename_axis + .take(); + let variant_value = self + .editor_state + .editor_ui + .variables_variant_rename_value + .take(); + let Some(focus) = theme_axis + .map(VariableHeaderFocus::Theme) + .or_else(|| variant_value.map(VariableHeaderFocus::Variant)) + else { + return; + }; + self.editor_state.ui.property_draft_select_all = false; + let draft = self.editor_state.ui.property_input_draft.clone(); + let snap = self.editor_state.snapshot_for_history(); + let committed = match focus { + VariableHeaderFocus::Theme(old_axis) => { + self.rename_variable_theme_axis(&old_axis, &draft) + } + VariableHeaderFocus::Variant(old_value) => { + self.rename_variable_variant_value(&old_value, &draft) + } + }; + if committed { + self.editor_state.history_push_past(snap); + } + self.mark_dirty(); + } + + fn rename_variable_theme_axis(&mut self, old_axis: &str, new_axis: &str) -> bool { + let new_axis = new_axis.trim(); + if new_axis.is_empty() { + self.editor_state.ui.property_input_draft = old_axis.to_string(); + return false; + } + let Some(themes) = self.editor_state.doc.themes.as_mut() else { + self.editor_state.ui.property_input_draft = old_axis.to_string(); + return false; + }; + if old_axis == new_axis { + self.editor_state.ui.property_input_draft = old_axis.to_string(); + return false; + } + if themes.contains_key(new_axis) { + self.editor_state.ui.property_input_draft = old_axis.to_string(); + return false; + } + let mut updated = BTreeMap::new(); + let mut found = false; + for (axis, values) in std::mem::take(themes) { + if axis == old_axis { + updated.insert(new_axis.to_string(), values); + found = true; + } else { + updated.insert(axis, values); + } + } + *themes = updated; + if !found { + self.editor_state.ui.property_input_draft = old_axis.to_string(); + return false; + } + if let Some(value) = self.editor_state.ui.variables.active_theme.remove(old_axis) { + self.editor_state + .ui + .variables + .active_theme + .insert(new_axis.to_string(), value); + } + if self + .editor_state + .editor_ui + .variables_current_axis + .as_deref() + == Some(old_axis) + { + self.editor_state.editor_ui.variables_current_axis = Some(new_axis.to_string()); + } + self.editor_state.ui.property_input_draft = new_axis.to_string(); + true + } + + fn rename_variable_variant_value(&mut self, old_value: &str, new_value: &str) -> bool { + let new_value = new_value.trim(); + if new_value.is_empty() { + self.editor_state.ui.property_input_draft = old_value.to_string(); + return false; + } + let Some(axis) = self.active_variable_axis() else { + self.editor_state.ui.property_input_draft = old_value.to_string(); + return false; + }; + let Some(values) = self + .editor_state + .doc + .themes + .as_mut() + .and_then(|themes| themes.get_mut(&axis)) + else { + self.editor_state.ui.property_input_draft = old_value.to_string(); + return false; + }; + if old_value == new_value { + self.editor_state.ui.property_input_draft = old_value.to_string(); + return false; + } + if values.iter().any(|v| v == new_value) { + self.editor_state.ui.property_input_draft = old_value.to_string(); + return false; + } + let mut found = false; + for value in values.iter_mut() { + if value == old_value { + *value = new_value.to_string(); + found = true; + } + } + if found + && self + .editor_state + .ui + .variables + .active_theme + .get(&axis) + .is_some_and(|active| active == old_value) + { + self.editor_state + .ui + .variables + .active_theme + .insert(axis, new_value.to_string()); + } + if found { + self.editor_state.ui.property_input_draft = new_value.to_string(); + } else { + self.editor_state.ui.property_input_draft = old_value.to_string(); + } + found + } + + pub(in crate::widget_host) fn active_variable_axis(&self) -> Option { + self.editor_state + .editor_ui + .variables_current_axis + .as_ref() + .filter(|axis| { + self.editor_state + .doc + .themes + .as_ref() + .is_some_and(|themes| themes.contains_key(*axis)) + }) + .cloned() + .or_else(|| { + self.editor_state + .doc + .themes + .as_ref() + .and_then(|themes| themes.keys().next().cloned()) + }) + } + + pub(in crate::widget_host) fn variable_axis_value_for_variant( + &self, + variant: usize, + ) -> Option<(String, String)> { + let axis = self.active_variable_axis()?; + let value = self + .editor_state + .doc + .themes + .as_ref()? + .get(&axis)? + .get(variant)? + .clone(); + Some((axis, value)) + } + + /// Commit any pending VariablesPanel row edit (Number / String). + pub(in crate::widget_host) fn commit_variable_row_focus_if_any(&mut self) { + use op_editor_core::editor_ui_state::VariableRowFocus; + let Some(focus) = self.editor_state.editor_ui.variable_row_focus.take() else { + return; + }; + self.editor_state.ui.property_draft_select_all = false; + let draft = self.editor_state.ui.property_input_draft.clone(); + let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); + let snap = self.editor_state.snapshot_for_history(); + let committed = (|| -> bool { + match focus { + VariableRowFocus::Name(idx) => { + let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { + return false; + }; + let next = draft.trim(); + if next.is_empty() || next == name { + self.editor_state.ui.property_input_draft = name; + return false; + } + if self.editor_state.rename_variable(&name, next) { + self.editor_state.ui.property_input_draft = next.to_string(); + true + } else { + self.editor_state.ui.property_input_draft = name; + false + } + } + VariableRowFocus::Number(idx) | VariableRowFocus::NumberCell { row: idx, .. } => { + let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { + return false; + }; + let Ok(n) = draft.trim().parse::() else { + return false; + }; + if !n.is_finite() { + return false; + } + if let VariableRowFocus::NumberCell { variant, .. } = focus { + if let Some((axis, value)) = self.variable_axis_value_for_variant(variant) { + return self + .editor_state + .set_variable_number_for_theme(&name, &axis, &value, n); + } + } + self.editor_state.set_variable_number(&name, n) + } + VariableRowFocus::String(idx) | VariableRowFocus::StringCell { row: idx, .. } => { + let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { + return false; + }; + if let VariableRowFocus::StringCell { variant, .. } = focus { + if let Some((axis, value)) = self.variable_axis_value_for_variant(variant) { + return self + .editor_state + .set_variable_string_for_theme(&name, &axis, &value, draft); + } + } + self.editor_state.set_variable_string(&name, draft) + } + } + })(); + if committed { + self.editor_state.history_push_past(snap); + } + self.mark_dirty(); + } +} diff --git a/crates/op-host-native/src/widget_host/variables_panel_geometry.rs b/crates/op-host-native/src/widget_host/variables_panel_geometry.rs new file mode 100644 index 00000000..c507c432 --- /dev/null +++ b/crates/op-host-native/src/widget_host/variables_panel_geometry.rs @@ -0,0 +1,48 @@ +use super::helpers::{rect_contains, TOOLBAR_INSET_X, TOOLBAR_INSET_Y}; +use super::WidgetHostNative; +use op_editor_ui::widgets::TOOLBAR_WIDTH; +use op_editor_ui::{Point2D, Rect}; + +const VARIABLES_PANEL_DEFAULT_W: f32 = 820.0; +const VARIABLES_PANEL_DEFAULT_H: f32 = 480.0; +const VARIABLES_PANEL_MIN_W: f32 = 240.0; +const VARIABLES_PANEL_MIN_H: f32 = 120.0; +const VARIABLES_PANEL_GAP: f32 = 8.0; + +impl WidgetHostNative { + pub(in crate::widget_host) fn variables_panel_rect( + &self, + viewport_w: f32, + viewport_h: f32, + ) -> Option { + if !self.editor_state.editor_ui.variables_panel_open { + return None; + } + let (cx0, cy0, cw, ch) = self.canvas_region(viewport_w, viewport_h); + let x = cx0 + TOOLBAR_INSET_X + TOOLBAR_WIDTH + VARIABLES_PANEL_GAP; + let y = cy0 + TOOLBAR_INSET_Y; + let max_w = cx0 + cw - x - VARIABLES_PANEL_GAP; + let max_h = cy0 + ch - y - VARIABLES_PANEL_GAP; + if max_w < VARIABLES_PANEL_MIN_W || max_h < VARIABLES_PANEL_MIN_H { + return None; + } + Some(Rect { + origin: Point2D::new(x, y), + size: Point2D::new( + VARIABLES_PANEL_DEFAULT_W.min(max_w), + VARIABLES_PANEL_DEFAULT_H.min(max_h), + ), + }) + } + + pub(in crate::widget_host) fn over_variables_panel( + &self, + x: f32, + y: f32, + viewport_w: f32, + viewport_h: f32, + ) -> bool { + self.variables_panel_rect(viewport_w, viewport_h) + .is_some_and(|r| rect_contains(r, Point2D::new(x, y))) + } +} diff --git a/crates/op-host-native/src/widget_host/variables_panel_press.rs b/crates/op-host-native/src/widget_host/variables_panel_press.rs new file mode 100644 index 00000000..8ed4d837 --- /dev/null +++ b/crates/op-host-native/src/widget_host/variables_panel_press.rs @@ -0,0 +1,635 @@ +use super::helpers::rect_contains; +use super::WidgetHostNative; +use jian_ops_schema::variable::{VariableKind, VariableScalar, VariableValue}; +use op_editor_ui::widgets::variables_panel::{VariablesPanel, VariablesPanelHit}; +use op_editor_ui::Point2D; +use std::collections::BTreeMap; + +impl WidgetHostNative { + pub(in crate::widget_host) fn dispatch_variables_panel_press( + &mut self, + x: f32, + y: f32, + viewport_width: f32, + viewport_height: f32, + ) -> bool { + if !self.editor_state.editor_ui.variables_panel_open { + return false; + } + let Some(vars_rect) = self.variables_panel_rect(viewport_width, viewport_height) else { + return false; + }; + let point = Point2D::new(x, y); + if !rect_contains(vars_rect, point) { + return false; + } + let vars = VariablesPanel::for_editor(&self.editor_state); + let Some(hit) = vars.hit_test(vars_rect, point) else { + self.close_variable_menus(); + self.mark_dirty(); + return true; + }; + match hit { + VariablesPanelHit::Close => { + self.commit_variables_panel_header_focus_if_any(); + self.commit_variable_row_focus_if_any(); + self.editor_state.editor_ui.variables_panel_open = false; + self.close_variable_menus(); + self.mark_dirty(); + true + } + VariablesPanelHit::ThemeTab(axis) => self.select_variable_axis(axis), + VariablesPanelHit::ToggleThemeMenu(axis) => self.toggle_theme_menu(axis), + VariablesPanelHit::ThemeMenuRename(axis) => self.start_theme_rename(axis), + VariablesPanelHit::ThemeMenuDelete(axis) => self.delete_theme_axis(axis), + VariablesPanelHit::AddTheme => self.add_variable_theme(), + VariablesPanelHit::TogglePresetMenu => { + let ui = &mut self.editor_state.editor_ui; + ui.variables_preset_menu_open = !ui.variables_preset_menu_open; + ui.variables_add_menu_open = false; + ui.axis_dropdown_open = None; + ui.variables_theme_menu_axis = None; + ui.variables_variant_menu_value = None; + self.mark_dirty(); + true + } + VariablesPanelHit::AddVariant => self.add_variable_variant(), + VariablesPanelHit::ToggleVariantMenu(value) => self.toggle_variant_menu(value), + VariablesPanelHit::VariantMenuRename(value) => self.start_variant_rename(value), + VariablesPanelHit::VariantMenuDelete(value) => self.delete_variant_value(value), + VariablesPanelHit::ToggleAddVariableMenu => { + let ui = &mut self.editor_state.editor_ui; + ui.variables_add_menu_open = !ui.variables_add_menu_open; + ui.variables_preset_menu_open = false; + ui.axis_dropdown_open = None; + ui.variables_theme_menu_axis = None; + ui.variables_variant_menu_value = None; + self.mark_dirty(); + true + } + VariablesPanelHit::AddVariableColor => self.add_variable( + "color", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + ), + VariablesPanelHit::AddVariableNumber => { + self.add_variable("number", VariableKind::Number, VariableScalar::Num(0.0)) + } + VariablesPanelHit::AddVariableString => self.add_variable( + "string", + VariableKind::String, + VariableScalar::Str("string".into()), + ), + VariablesPanelHit::NameCell(idx) => self.press_variable_name_cell(idx), + VariablesPanelHit::ValueCell { row, variant } => { + self.press_variable_value_cell(row, variant, x, y) + } + VariablesPanelHit::Row(idx) => self.press_variable_row(idx, x, y), + VariablesPanelHit::AxisChip(idx) => self.toggle_variable_axis(idx), + VariablesPanelHit::AxisDropdownItem { axis, value } => { + self.commit_property_focus_if_any(); + let snap = self.editor_state.snapshot_for_history(); + if self.editor_state.set_active_axis_value(&axis, &value) { + self.editor_state.history_push_past(snap); + } + self.close_variable_menus(); + self.mark_dirty(); + true + } + } + } + + fn toggle_theme_menu(&mut self, axis: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + let ui = &mut self.editor_state.editor_ui; + ui.variables_theme_menu_axis = if ui.variables_theme_menu_axis.as_deref() == Some(&axis) { + None + } else { + Some(axis) + }; + ui.variables_variant_menu_value = None; + ui.variables_add_menu_open = false; + ui.variables_preset_menu_open = false; + ui.axis_dropdown_open = None; + self.mark_dirty(); + true + } + + fn start_theme_rename(&mut self, axis: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.editor_state.ui.property_input_draft = axis.clone(); + self.editor_state.ui.property_caret_pos = axis.len(); + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + let ui = &mut self.editor_state.editor_ui; + ui.variables_theme_rename_axis = Some(axis); + ui.variables_theme_menu_axis = None; + ui.variables_variant_menu_value = None; + self.mark_dirty(); + true + } + + fn delete_theme_axis(&mut self, axis: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + let Some(themes) = self.editor_state.doc.themes.as_ref() else { + return true; + }; + if themes.len() <= 1 || !themes.contains_key(&axis) { + self.close_variable_menus(); + self.mark_dirty(); + return true; + } + let snap = self.editor_state.snapshot_for_history(); + if let Some(themes) = self.editor_state.doc.themes.as_mut() { + themes.remove(&axis); + } + self.editor_state.ui.variables.active_theme.remove(&axis); + if self + .editor_state + .editor_ui + .variables_current_axis + .as_deref() + == Some(axis.as_str()) + { + self.editor_state.editor_ui.variables_current_axis = + self.editor_state.doc.themes.as_ref().and_then(|themes| { + themes.iter().next().map(|(next_axis, values)| { + self.editor_state.ui.variables.active_theme.insert( + next_axis.clone(), + values.first().cloned().unwrap_or_else(|| "Default".into()), + ); + next_axis.clone() + }) + }); + } + self.editor_state.history_push_past(snap); + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn toggle_variant_menu(&mut self, value: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + let ui = &mut self.editor_state.editor_ui; + ui.variables_variant_menu_value = + if ui.variables_variant_menu_value.as_deref() == Some(value.as_str()) { + None + } else { + Some(value) + }; + ui.variables_theme_menu_axis = None; + ui.variables_add_menu_open = false; + ui.variables_preset_menu_open = false; + ui.axis_dropdown_open = None; + self.mark_dirty(); + true + } + + fn start_variant_rename(&mut self, value: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.editor_state.ui.property_input_draft = value.clone(); + self.editor_state.ui.property_caret_pos = value.len(); + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + let ui = &mut self.editor_state.editor_ui; + ui.variables_variant_rename_value = Some(value); + ui.variables_variant_menu_value = None; + ui.variables_theme_menu_axis = None; + self.mark_dirty(); + true + } + + fn delete_variant_value(&mut self, value: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + let axis = self.ensure_variable_axis(); + let Some(values) = self + .editor_state + .doc + .themes + .as_ref() + .and_then(|themes| themes.get(&axis)) + else { + return true; + }; + if values.len() <= 1 || !values.iter().any(|v| v == &value) { + self.close_variable_menus(); + self.mark_dirty(); + return true; + } + let snap = self.editor_state.snapshot_for_history(); + if let Some(values) = self + .editor_state + .doc + .themes + .as_mut() + .and_then(|themes| themes.get_mut(&axis)) + { + values.retain(|v| v != &value); + if self + .editor_state + .ui + .variables + .active_theme + .get(&axis) + .is_some_and(|active| active == &value) + { + if let Some(next) = values.first().cloned() { + self.editor_state + .ui + .variables + .active_theme + .insert(axis, next); + } + } + } + self.editor_state.history_push_past(snap); + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn press_variable_name_cell(&mut self, idx: usize) -> bool { + use op_editor_core::editor_ui_state::VariableRowFocus; + let is_double = matches!( + self.editor_state.editor_ui.last_variable_name_click, + Some((prev, t)) if prev == idx && self.now_ms.saturating_sub(t) < 400 + ); + self.editor_state.editor_ui.last_variable_name_click = Some((idx, self.now_ms)); + self.close_variable_menus(); + if !is_double { + self.mark_dirty(); + return true; + } + + let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); + let Some(name) = var_table.variables.get(idx).map(|v| v.name.clone()) else { + self.mark_dirty(); + return true; + }; + self.commit_property_focus_if_any(); + self.commit_variable_row_focus_if_any(); + self.editor_state.ui.property_input_draft = name; + self.editor_state.ui.property_caret_pos = self.editor_state.ui.property_input_draft.len(); + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.editor_ui.variable_row_focus = Some(VariableRowFocus::Name(idx)); + self.editor_state.editor_ui.last_variable_name_click = None; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + true + } + + fn press_variable_row(&mut self, idx: usize, x: f32, y: f32) -> bool { + use op_editor_ui::scene_vars::VariableKind as UiVariableKind; + let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); + let Some((name, kind)) = var_table + .variables + .get(idx) + .map(|v| (v.name.clone(), v.kind)) + else { + return true; + }; + match kind { + UiVariableKind::Color => { + self.commit_property_focus_if_any(); + let _ = self + .editor_state + .open_color_picker_for_variable_at(name, x, y); + } + UiVariableKind::Boolean => { + let current = self + .editor_state + .resolve_variable(&name) + .and_then(|s| match s { + VariableScalar::Bool(b) => Some(*b), + _ => None, + }) + .unwrap_or(false); + self.commit_property_focus_if_any(); + let snap = self.editor_state.snapshot_for_history(); + if self.editor_state.set_variable_boolean(&name, !current) { + self.editor_state.history_push_past(snap); + } + } + UiVariableKind::Number | UiVariableKind::String => { + use op_editor_core::editor_ui_state::VariableRowFocus; + self.commit_property_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let resolved = self.editor_state.resolve_variable(&name).cloned(); + self.editor_state.ui.property_input_draft = match (&kind, &resolved) { + (UiVariableKind::Number, Some(VariableScalar::Num(n))) => format!("{n}"), + (UiVariableKind::String, Some(VariableScalar::Str(s))) => s.clone(), + _ => String::new(), + }; + self.editor_state.editor_ui.variable_row_focus = Some(match kind { + UiVariableKind::Number => VariableRowFocus::Number(idx), + UiVariableKind::String => VariableRowFocus::String(idx), + _ => return true, + }); + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + } + } + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn press_variable_value_cell(&mut self, idx: usize, variant: usize, x: f32, y: f32) -> bool { + use op_editor_core::editor_ui_state::VariableRowFocus; + use op_editor_ui::scene_vars::VariableKind as UiVariableKind; + let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); + let Some((name, kind)) = var_table + .variables + .get(idx) + .map(|v| (v.name.clone(), v.kind)) + else { + return true; + }; + match kind { + UiVariableKind::Color | UiVariableKind::Boolean => self.press_variable_row(idx, x, y), + UiVariableKind::Number | UiVariableKind::String => { + self.commit_property_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let scalar = + self.variable_axis_value_for_variant(variant) + .and_then(|(axis, value)| { + self.editor_state + .find_variable(&name) + .and_then(|def| scalar_for_axis_value(&def.value, &axis, &value)) + }); + let scalar = scalar.or_else(|| self.editor_state.resolve_variable(&name).cloned()); + self.editor_state.ui.property_input_draft = match (&kind, &scalar) { + (UiVariableKind::Number, Some(VariableScalar::Num(n))) => format!("{n}"), + (UiVariableKind::String, Some(VariableScalar::Str(s))) => s.clone(), + _ => String::new(), + }; + self.editor_state.ui.property_caret_pos = + self.editor_state.ui.property_input_draft.len(); + self.editor_state.ui.property_draft_select_all = false; + self.editor_state.editor_ui.variable_row_focus = Some(match kind { + UiVariableKind::Number => VariableRowFocus::NumberCell { row: idx, variant }, + UiVariableKind::String => VariableRowFocus::StringCell { row: idx, variant }, + _ => return true, + }); + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.close_variable_menus(); + self.mark_dirty(); + true + } + } + } + + fn toggle_variable_axis(&mut self, idx: usize) -> bool { + let var_table = op_pen_loader::editor_state_var_table(&self.editor_state); + let axis = var_table.active_theme.keys().nth(idx).cloned(); + if let Some(name) = axis { + self.commit_property_focus_if_any(); + let ui = &mut self.editor_state.editor_ui; + ui.axis_dropdown_open = if ui.axis_dropdown_open.as_deref() == Some(name.as_str()) { + None + } else { + Some(name) + }; + ui.variables_add_menu_open = false; + ui.variables_preset_menu_open = false; + } + self.mark_dirty(); + true + } + + fn add_variable_theme(&mut self) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let snap = self.editor_state.snapshot_for_history(); + let themes = self + .editor_state + .doc + .themes + .get_or_insert_with(BTreeMap::new); + let name = unique_numbered("Theme", |candidate| themes.contains_key(candidate)); + themes.insert(name.clone(), vec!["Default".into()]); + let current_axis = name.clone(); + self.editor_state + .ui + .variables + .active_theme + .insert(name, "Default".into()); + self.editor_state.editor_ui.variables_current_axis = Some(current_axis); + self.editor_state.history_push_past(snap); + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn select_variable_axis(&mut self, axis: String) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let Some(values) = self + .editor_state + .doc + .themes + .as_ref() + .and_then(|themes| themes.get(&axis)) + else { + return true; + }; + let fallback = values.first().cloned().unwrap_or_else(|| "Default".into()); + self.editor_state + .ui + .variables + .active_theme + .entry(axis.clone()) + .or_insert(fallback); + self.editor_state.editor_ui.variables_current_axis = Some(axis); + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn add_variable_variant(&mut self) -> bool { + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let snap = self.editor_state.snapshot_for_history(); + let axis = self.ensure_variable_axis(); + let Some(values) = self + .editor_state + .doc + .themes + .as_mut() + .and_then(|themes| themes.get_mut(&axis)) + else { + return true; + }; + let variant = unique_numbered("Variant", |candidate| values.iter().any(|v| v == candidate)); + values.push(variant); + self.editor_state.history_push_past(snap); + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn add_variable(&mut self, base: &str, kind: VariableKind, default: VariableScalar) -> bool { + use op_editor_core::editor_ui_state::VariableRowFocus; + self.commit_property_focus_if_any(); + self.commit_variables_panel_header_focus_if_any(); + self.commit_variable_row_focus_if_any(); + let snap = self.editor_state.snapshot_for_history(); + self.ensure_variable_axis(); + let name = unique_numbered(base, |candidate| { + self.editor_state + .doc + .variables + .as_ref() + .is_some_and(|vars| vars.contains_key(candidate)) + }); + let default_focus = match (&kind, &default) { + (VariableKind::Number, VariableScalar::Num(value)) => Some(( + VariableRowFocus::NumberCell { row: 0, variant: 0 }, + format!("{value}"), + true, + )), + (VariableKind::String, VariableScalar::Str(value)) => Some(( + VariableRowFocus::StringCell { row: 0, variant: 0 }, + value.clone(), + !value.is_empty(), + )), + _ => None, + }; + if self.editor_state.create_variable(&name, kind, default) { + self.editor_state.history_push_past(snap); + if let Some((focus, draft, select_all)) = default_focus { + let row = op_pen_loader::editor_state_var_table(&self.editor_state) + .variables + .iter() + .position(|var| var.name == name) + .unwrap_or(0); + self.editor_state.ui.property_input_draft = draft; + self.editor_state.ui.property_caret_pos = + self.editor_state.ui.property_input_draft.len(); + self.editor_state.ui.property_draft_select_all = select_all; + self.editor_state.ui.property_caret_anchor_ms = self.now_ms; + self.editor_state.editor_ui.variable_row_focus = Some(match focus { + VariableRowFocus::NumberCell { variant, .. } => { + VariableRowFocus::NumberCell { row, variant } + } + VariableRowFocus::StringCell { variant, .. } => { + VariableRowFocus::StringCell { row, variant } + } + other => other, + }); + } + } + self.close_variable_menus(); + self.mark_dirty(); + true + } + + fn ensure_variable_axis(&mut self) -> String { + if let Some(axis) = self + .editor_state + .editor_ui + .variables_current_axis + .as_ref() + .filter(|axis| { + self.editor_state + .doc + .themes + .as_ref() + .is_some_and(|themes| themes.contains_key(*axis)) + }) + .cloned() + { + return axis; + } + if let Some(axis) = self + .editor_state + .ui + .variables + .active_theme + .keys() + .next() + .cloned() + { + return axis; + } + if let Some((axis, values)) = self + .editor_state + .doc + .themes + .as_ref() + .and_then(|themes| themes.iter().next()) + { + let value = values.first().cloned().unwrap_or_else(|| "Default".into()); + self.editor_state + .ui + .variables + .active_theme + .insert(axis.clone(), value); + self.editor_state.editor_ui.variables_current_axis = Some(axis.clone()); + return axis.clone(); + } + let themes = self + .editor_state + .doc + .themes + .get_or_insert_with(BTreeMap::new); + let axis = unique_numbered("Theme", |candidate| themes.contains_key(candidate)); + themes.insert(axis.clone(), vec!["Default".into()]); + self.editor_state + .ui + .variables + .active_theme + .insert(axis.clone(), "Default".into()); + self.editor_state.editor_ui.variables_current_axis = Some(axis.clone()); + axis + } + + fn close_variable_menus(&mut self) { + let ui = &mut self.editor_state.editor_ui; + ui.axis_dropdown_open = None; + ui.variables_add_menu_open = false; + ui.variables_preset_menu_open = false; + ui.variables_theme_menu_axis = None; + ui.variables_variant_menu_value = None; + } +} + +fn scalar_for_axis_value( + value: &VariableValue, + axis: &str, + theme_value: &str, +) -> Option { + match value { + VariableValue::Scalar(scalar) => Some(scalar.clone()), + VariableValue::Themed(entries) => entries + .iter() + .find(|entry| { + entry + .theme + .as_ref() + .and_then(|theme| theme.get(axis)) + .is_some_and(|value| value == theme_value) + }) + .or_else(|| entries.iter().find(|entry| entry.theme.is_none())) + .or_else(|| entries.first()) + .map(|entry| entry.value.clone()), + } +} + +fn unique_numbered(base: &str, exists: impl Fn(&str) -> bool) -> String { + for idx in 1.. { + let candidate = format!("{base}-{idx}"); + if !exists(&candidate) { + return candidate; + } + } + unreachable!() +} diff --git a/crates/op-host-native/src/widget_host/variables_panel_tests.rs b/crates/op-host-native/src/widget_host/variables_panel_tests.rs new file mode 100644 index 00000000..5ea0eae3 --- /dev/null +++ b/crates/op-host-native/src/widget_host/variables_panel_tests.rs @@ -0,0 +1,759 @@ +use super::{ + helpers::{TOOLBAR_INSET_X, TOOLBAR_INSET_Y}, + WidgetHostNative, +}; +use jian_ops_schema::variable::{VariableKind, VariableScalar, VariableValue}; +use op_editor_core::editor_ui_state::VariableRowFocus; +use op_editor_ui::widgets::{TOOLBAR_WIDTH, TOP_BAR_HEIGHT}; + +const VIEWPORT_W: f32 = 1280.0; +const VIEWPORT_H: f32 = 900.0; + +#[test] +fn variables_panel_floats_next_to_toolbar_like_ts() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "spacing", + VariableKind::Number, + VariableScalar::Num(8.0), + )); + host.editor_state_mut().editor_ui.variables_panel_open = true; + + let (canvas_left, _canvas_top, _canvas_w, _canvas_h) = + host.canvas_region(VIEWPORT_W, VIEWPORT_H); + let panel_x = canvas_left + TOOLBAR_INSET_X + TOOLBAR_WIDTH + 8.0; + let panel_y = TOP_BAR_HEIGHT + TOOLBAR_INSET_Y; + let row_point_x = panel_x + 16.0; + let row_point_y = panel_y + 44.0 + 36.0 + 18.0; + + assert!(host.apply_press(row_point_x, row_point_y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::Number(0)) + ); +} + +#[test] +fn variables_panel_name_pill_double_click_starts_rename_without_opening_color_picker() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + let x = rect.origin.x + 16.0 + 42.0; + let y = rect.origin.y + 44.0 + 36.0 + 22.0; + + host.set_now_ms(100); + assert!(host.apply_press(x, y, VIEWPORT_W, VIEWPORT_H)); + assert!(host.editor_state().ui.color_picker.is_none()); + assert_eq!(host.editor_state().editor_ui.variable_row_focus, None); + + host.set_now_ms(240); + assert!(host.apply_press(x, y, VIEWPORT_W, VIEWPORT_H)); + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::Name(0)) + ); + assert_eq!(host.editor_state().ui.property_input_draft, "color-1"); + assert!( + !host.editor_state().ui.property_draft_select_all, + "variable-name rename should start with a caret, not selected text" + ); + + assert!(host.apply_text('b')); + assert_eq!(host.editor_state().ui.property_input_draft, "color-1b"); +} + +#[test] +fn variables_panel_number_value_cell_edits_clicked_variant_only() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into(), "Variant-1".into()]); + state.editor_ui.variables_current_axis = Some("Theme-1".into()); + state + .ui + .variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + assert!(state.create_variable("number", VariableKind::Number, VariableScalar::Num(0.0),)); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + let second_variant_x = rect.origin.x + 16.0 + 220.0 + 262.0 + 12.0; + let first_row_y = rect.origin.y + 44.0 + 36.0 + 22.0; + + assert!(host.apply_press(second_variant_x, first_row_y, VIEWPORT_W, VIEWPORT_H)); + + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::NumberCell { row: 0, variant: 1 }) + ); + assert_eq!(host.editor_state().ui.property_input_draft, "0"); + assert!(host.apply_text('7')); + assert!(host.apply_send()); + + let def = host + .editor_state() + .doc + .variables + .as_ref() + .unwrap() + .get("number") + .unwrap(); + let VariableValue::Themed(values) = &def.value else { + panic!("variant edit should convert scalar variable to themed values"); + }; + let default = values + .iter() + .find(|entry| entry.theme.as_ref().and_then(|t| t.get("Theme-1")).unwrap() == "Default") + .unwrap(); + let variant = values + .iter() + .find(|entry| entry.theme.as_ref().and_then(|t| t.get("Theme-1")).unwrap() == "Variant-1") + .unwrap(); + assert_eq!(default.value, VariableScalar::Num(0.0)); + assert_eq!(variant.value, VariableScalar::Num(7.0)); +} + +#[test] +fn variables_panel_string_value_cell_edits_clicked_variant_only() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into(), "Variant-1".into()]); + state.editor_ui.variables_current_axis = Some("Theme-1".into()); + state + .ui + .variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + assert!(state.create_variable( + "string", + VariableKind::String, + VariableScalar::Str(String::new()), + )); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + let second_variant_x = rect.origin.x + 16.0 + 220.0 + 262.0 + 12.0; + let first_row_y = rect.origin.y + 44.0 + 36.0 + 22.0; + + assert!(host.apply_press(second_variant_x, first_row_y, VIEWPORT_W, VIEWPORT_H)); + + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::StringCell { row: 0, variant: 1 }) + ); + assert_eq!(host.editor_state().ui.property_input_draft, ""); + assert!(host.apply_text('a')); + assert!(host.apply_text('b')); + assert!(host.apply_send()); + + let def = host + .editor_state() + .doc + .variables + .as_ref() + .unwrap() + .get("string") + .unwrap(); + let VariableValue::Themed(values) = &def.value else { + panic!("variant edit should convert scalar variable to themed values"); + }; + let default = values + .iter() + .find(|entry| entry.theme.as_ref().and_then(|t| t.get("Theme-1")).unwrap() == "Default") + .unwrap(); + let variant = values + .iter() + .find(|entry| entry.theme.as_ref().and_then(|t| t.get("Theme-1")).unwrap() == "Variant-1") + .unwrap(); + assert_eq!(default.value, VariableScalar::Str(String::new())); + assert_eq!(variant.value, VariableScalar::Str("ab".into())); +} + +#[test] +fn variables_panel_name_edit_commits_variable_rename() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft = "brand".into(); + + host.commit_variable_row_focus_if_any_pub(); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + assert!(vars.contains_key("brand")); + assert!(!vars.contains_key("color-1")); +} + +#[test] +fn variables_panel_name_edit_enter_does_not_clear_unchanged_name() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft = "color-1".into(); + host.editor_state_mut().ui.property_draft_select_all = true; + + assert!(host.apply_send()); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + assert!(vars.contains_key("color-1")); + assert_eq!(host.editor_state().ui.property_input_draft, "color-1"); +} + +#[test] +fn variables_panel_name_edit_typing_inserts_at_caret() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft = "color-1".into(); + host.editor_state_mut().ui.property_caret_pos = 5; + + assert!(host.apply_text('X')); + + assert_eq!(host.editor_state().ui.property_input_draft, "colorX-1"); + assert_eq!(host.editor_state().ui.property_caret_pos, 6); +} + +#[test] +fn variables_panel_name_edit_backspace_and_delete_are_caret_aware() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft = "color-1".into(); + host.editor_state_mut().ui.property_caret_pos = 5; + + assert!(host.apply_backspace()); + assert_eq!(host.editor_state().ui.property_input_draft, "colo-1"); + assert_eq!(host.editor_state().ui.property_caret_pos, 4); + + assert!(host.apply_delete()); + assert_eq!(host.editor_state().ui.property_input_draft, "colo1"); + assert_eq!(host.editor_state().ui.property_caret_pos, 4); +} + +#[test] +fn variables_panel_name_edit_arrow_keys_move_caret() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft = "color-1".into(); + host.editor_state_mut().ui.property_caret_pos = "color-1".len(); + + assert!(host.apply_property_caret(false)); + assert_eq!(host.editor_state().ui.property_caret_pos, "color-".len()); + assert!(host.apply_property_caret(false)); + assert_eq!(host.editor_state().ui.property_caret_pos, "color".len()); + assert!(host.apply_property_caret(true)); + assert_eq!(host.editor_state().ui.property_caret_pos, "color-".len()); +} + +#[test] +fn variables_panel_name_edit_enter_with_empty_name_restores_old_name() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variable_row_focus = Some(VariableRowFocus::Name(0)); + host.editor_state_mut().ui.property_input_draft.clear(); + + assert!(host.apply_send()); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + assert!(vars.contains_key("color-1")); + assert_eq!(host.editor_state().ui.property_input_draft, "color-1"); +} + +#[test] +fn variables_panel_color_picker_anchors_near_clicked_value_cell() { + let mut host = WidgetHostNative::new(); + assert!(host.editor_state_mut().create_variable( + "color-1", + VariableKind::Color, + VariableScalar::Str("#000000".into()), + )); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + let click_x = rect.origin.x + 16.0 + 220.0 + 4.0; + let click_y = rect.origin.y + 44.0 + 36.0 + 18.0; + + assert!(host.apply_press(click_x, click_y, VIEWPORT_W, VIEWPORT_H)); + + let state = host + .editor_state() + .ui + .color_picker + .clone() + .expect("color picker open"); + let picker = + op_editor_ui::widgets::color_picker::ColorPicker::for_state(host.editor_state(), state); + let picker_rect = picker.rect(VIEWPORT_W, VIEWPORT_H); + assert!( + picker_rect.origin.x >= click_x && picker_rect.origin.x <= click_x + 40.0, + "picker should open near clicked variable swatch; click_x={click_x}, picker_x={}", + picker_rect.origin.x + ); +} + +#[test] +fn variables_panel_theme_menu_rename_commits_axis_name() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + state.editor_ui.variables_theme_rename_axis = Some("Theme-1".into()); + state.ui.property_input_draft = "Mode".into(); + + assert!(host.apply_send()); + + let themes = host.editor_state().doc.themes.as_ref().unwrap(); + assert!(themes.contains_key("Mode")); + assert!(!themes.contains_key("Theme-1")); +} + +#[test] +fn variables_panel_variant_menu_rename_commits_variant_name() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into(), "Variant-1".into()]); + state.editor_ui.variables_current_axis = Some("Theme-1".into()); + state.editor_ui.variables_variant_rename_value = Some("Variant-1".into()); + state.ui.property_input_draft = "Dark".into(); + + assert!(host.apply_send()); + + assert_eq!( + host.editor_state() + .doc + .themes + .as_ref() + .unwrap() + .get("Theme-1") + .unwrap(), + &vec!["Default".to_string(), "Dark".to_string()] + ); +} + +#[test] +fn variables_panel_theme_menu_rename_starts_with_caret_not_selection() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + state.editor_ui.variables_theme_menu_axis = Some("Theme-1".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 18.0, + rect.origin.y + 44.0 + 22.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state().editor_ui.variables_theme_rename_axis, + Some("Theme-1".into()) + ); + assert!(!host.editor_state().ui.property_draft_select_all); + assert!(host.apply_text('d')); + assert_eq!(host.editor_state().ui.property_input_draft, "Theme-1d"); +} + +#[test] +fn variables_panel_variant_menu_rename_starts_with_caret_not_selection() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into(), "Variant-1".into()]); + state.editor_ui.variables_current_axis = Some("Theme-1".into()); + state.editor_ui.variables_variant_menu_value = Some("Variant-1".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 510.0, + rect.origin.y + 99.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state().editor_ui.variables_variant_rename_value, + Some("Variant-1".into()) + ); + assert!(!host.editor_state().ui.property_draft_select_all); + assert!(host.apply_text('d')); + assert_eq!(host.editor_state().ui.property_input_draft, "Variant-1d"); +} + +#[test] +fn variables_panel_header_rename_accepts_unicode_text() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + state.editor_ui.variables_theme_rename_axis = Some("Theme-1".into()); + + assert!(host.apply_text('中')); + assert!(host.apply_text('文')); + assert_eq!(host.editor_state().ui.property_input_draft, "中文"); + assert_eq!( + host.editor_state().ui.property_caret_pos, + "中文".len(), + "caret is a valid byte offset for multibyte names" + ); + + assert!(host.apply_backspace()); + assert_eq!(host.editor_state().ui.property_input_draft, "中"); + assert_eq!(host.editor_state().ui.property_caret_pos, "中".len()); +} + +#[test] +fn variables_panel_blank_click_closes_open_header_menus() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + state.editor_ui.variables_theme_menu_axis = Some("Theme-1".into()); + state.editor_ui.variables_variant_menu_value = Some("Default".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + rect.size.x - 24.0, + rect.origin.y + rect.size.y / 2.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state().editor_ui.variables_theme_menu_axis, + None + ); + assert_eq!( + host.editor_state().editor_ui.variables_variant_menu_value, + None + ); +} + +#[test] +fn variables_panel_add_theme_button_creates_theme() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 24.0, + rect.origin.y + 22.0, + VIEWPORT_W, + VIEWPORT_H + )); + + let state = host.editor_state(); + let themes = state.doc.themes.as_ref().unwrap(); + assert_eq!(themes.get("Theme-1").unwrap(), &vec!["Default".to_string()]); + assert_eq!( + state.ui.variables.active_theme.get("Theme-1"), + Some(&"Default".to_string()) + ); +} + +#[test] +fn variables_panel_preset_button_toggles_menu() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 82.0, + rect.origin.y + 22.0, + VIEWPORT_W, + VIEWPORT_H + )); + assert!(host.editor_state().editor_ui.variables_preset_menu_open); + + assert!(host.apply_press( + rect.origin.x + 82.0, + rect.origin.y + 58.0, + VIEWPORT_W, + VIEWPORT_H + )); + assert!(!host.editor_state().editor_ui.variables_preset_menu_open); +} + +#[test] +fn variables_panel_add_variant_button_appends_variant() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + state + .doc + .themes + .get_or_insert_with(Default::default) + .insert("Theme-1".into(), vec!["Default".into()]); + state + .ui + .variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + rect.size.x - 24.0, + rect.origin.y + 44.0 + 18.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state() + .doc + .themes + .as_ref() + .unwrap() + .get("Theme-1") + .unwrap(), + &vec!["Default".to_string(), "Variant-1".to_string()] + ); +} + +#[test] +fn variables_panel_theme_tab_selects_current_axis() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + let themes = state.doc.themes.get_or_insert_with(Default::default); + themes.insert("Theme-1".into(), vec!["Default".into()]); + themes.insert("Theme-2".into(), vec!["Default".into(), "Compact".into()]); + state + .ui + .variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + state.editor_ui.variables_current_axis = Some("Theme-1".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 120.0, + rect.origin.y + 22.0, + VIEWPORT_W, + VIEWPORT_H + )); + + let state = host.editor_state(); + assert_eq!( + state.editor_ui.variables_current_axis.as_deref(), + Some("Theme-2") + ); + assert_eq!( + state.ui.variables.active_theme.get("Theme-2"), + Some(&"Default".to_string()) + ); +} + +#[test] +fn variables_panel_add_variant_uses_current_axis() { + let mut host = WidgetHostNative::new(); + let state = host.editor_state_mut(); + state.editor_ui.variables_panel_open = true; + let themes = state.doc.themes.get_or_insert_with(Default::default); + themes.insert("Theme-1".into(), vec!["Default".into()]); + themes.insert("Theme-2".into(), vec!["Default".into()]); + state + .ui + .variables + .active_theme + .insert("Theme-1".into(), "Default".into()); + state.editor_ui.variables_current_axis = Some("Theme-2".into()); + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + rect.size.x - 24.0, + rect.origin.y + 44.0 + 18.0, + VIEWPORT_W, + VIEWPORT_H + )); + + let themes = host.editor_state().doc.themes.as_ref().unwrap(); + assert_eq!(themes.get("Theme-1").unwrap(), &vec!["Default".to_string()]); + assert_eq!( + themes.get("Theme-2").unwrap(), + &vec!["Default".to_string(), "Variant-1".to_string()] + ); +} + +#[test] +fn variables_panel_footer_add_menu_creates_color_variable() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 62.0, + rect.origin.y + rect.size.y - 20.0, + VIEWPORT_W, + VIEWPORT_H + )); + assert!(host.editor_state().editor_ui.variables_add_menu_open); + + assert!(host.apply_press( + rect.origin.x + 30.0, + rect.origin.y + rect.size.y - 40.0 - 90.0 - 6.0 + 15.0, + VIEWPORT_W, + VIEWPORT_H + )); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + assert!(vars.contains_key("color-1")); + let state = host.editor_state(); + assert_eq!( + state + .doc + .themes + .as_ref() + .and_then(|themes| themes.get("Theme-1")), + Some(&vec!["Default".to_string()]) + ); + assert_eq!( + state.ui.variables.active_theme.get("Theme-1"), + Some(&"Default".to_string()) + ); + assert_eq!( + state.editor_ui.variables_current_axis.as_deref(), + Some("Theme-1") + ); + assert!(!host.editor_state().editor_ui.variables_add_menu_open); +} + +#[test] +fn variables_panel_add_number_focuses_new_default_value() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 62.0, + rect.origin.y + rect.size.y - 20.0, + VIEWPORT_W, + VIEWPORT_H + )); + assert!(host.apply_press( + rect.origin.x + 30.0, + rect.origin.y + rect.size.y - 40.0 - 90.0 - 6.0 + 45.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::NumberCell { row: 0, variant: 0 }) + ); + assert_eq!(host.editor_state().ui.property_input_draft, "0"); + assert!(host.editor_state().ui.property_draft_select_all); + assert!(host.apply_text('2')); + assert!(host.apply_text('1')); + assert!(host.apply_text('3')); + assert_eq!(host.editor_state().ui.property_input_draft, "213"); + assert!(host.apply_send()); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + let VariableValue::Themed(values) = &vars.get("number-1").unwrap().value else { + panic!("editing the focused default column should write a themed value"); + }; + assert_eq!(values.len(), 1); + assert_eq!(values[0].value, VariableScalar::Num(213.0)); + assert_eq!( + values[0] + .theme + .as_ref() + .and_then(|theme| theme.get("Theme-1")), + Some(&"Default".to_string()) + ); +} + +#[test] +fn variables_panel_add_string_focuses_new_default_value() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.variables_panel_open = true; + let rect = host.variables_panel_rect(VIEWPORT_W, VIEWPORT_H).unwrap(); + + assert!(host.apply_press( + rect.origin.x + 62.0, + rect.origin.y + rect.size.y - 20.0, + VIEWPORT_W, + VIEWPORT_H + )); + assert!(host.apply_press( + rect.origin.x + 30.0, + rect.origin.y + rect.size.y - 40.0 - 90.0 - 6.0 + 75.0, + VIEWPORT_W, + VIEWPORT_H + )); + + assert_eq!( + host.editor_state().editor_ui.variable_row_focus, + Some(VariableRowFocus::StringCell { row: 0, variant: 0 }) + ); + assert_eq!(host.editor_state().ui.property_input_draft, "string"); + assert!(host.editor_state().ui.property_draft_select_all); + assert!(host.apply_text('a')); + assert_eq!(host.editor_state().ui.property_input_draft, "a"); + assert!(host.apply_send()); + + let vars = host.editor_state().doc.variables.as_ref().unwrap(); + let VariableValue::Themed(values) = &vars.get("string-1").unwrap().value else { + panic!("editing the focused default column should write a themed value"); + }; + assert_eq!(values.len(), 1); + assert_eq!(values[0].value, VariableScalar::Str("a".into())); + assert_eq!( + values[0] + .theme + .as_ref() + .and_then(|theme| theme.get("Theme-1")), + Some(&"Default".to_string()) + ); +} diff --git a/crates/op-i18n/src/i18n/de.rs b/crates/op-i18n/src/i18n/de.rs index 1f8540e7..8ac2a4c4 100644 --- a/crates/op-i18n/src/i18n/de.rs +++ b/crates/op-i18n/src/i18n/de.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "In Datei exportieren…", "variables.presetName" => "Vorlagenname", "variables.noPresets" => "Keine gespeicherten Vorlagen", + "variables.typeColor" => "Farbe", + "variables.typeNumber" => "Zahl", + "variables.typeString" => "Text", "designMd.title" => "Designsystem", "designMd.import" => "design.md importieren", "designMd.export" => "design.md exportieren", diff --git a/crates/op-i18n/src/i18n/en.rs b/crates/op-i18n/src/i18n/en.rs index 6d958f9f..98035182 100644 --- a/crates/op-i18n/src/i18n/en.rs +++ b/crates/op-i18n/src/i18n/en.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Export to File…", "variables.presetName" => "Preset name", "variables.noPresets" => "No saved presets", + "variables.typeColor" => "Color", + "variables.typeNumber" => "Number", + "variables.typeString" => "String", "designMd.title" => "Design System", "designMd.import" => "Import design.md", "designMd.export" => "Export design.md", diff --git a/crates/op-i18n/src/i18n/es.rs b/crates/op-i18n/src/i18n/es.rs index 1b99a94e..86971047 100644 --- a/crates/op-i18n/src/i18n/es.rs +++ b/crates/op-i18n/src/i18n/es.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Exportar a archivo…", "variables.presetName" => "Nombre del preajuste", "variables.noPresets" => "No hay preajustes guardados", + "variables.typeColor" => "Color", + "variables.typeNumber" => "Número", + "variables.typeString" => "Texto", "designMd.title" => "Sistema de diseño", "designMd.import" => "Importar design.md", "designMd.export" => "Exportar design.md", diff --git a/crates/op-i18n/src/i18n/fr.rs b/crates/op-i18n/src/i18n/fr.rs index 0b881afb..d17f9c4b 100644 --- a/crates/op-i18n/src/i18n/fr.rs +++ b/crates/op-i18n/src/i18n/fr.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Exporter vers un fichier…", "variables.presetName" => "Nom du préréglage", "variables.noPresets" => "Aucun préréglage enregistré", + "variables.typeColor" => "Couleur", + "variables.typeNumber" => "Nombre", + "variables.typeString" => "Texte", "designMd.title" => "Système de design", "designMd.import" => "Importer design.md", "designMd.export" => "Exporter design.md", diff --git a/crates/op-i18n/src/i18n/hi.rs b/crates/op-i18n/src/i18n/hi.rs index 10088f10..3fa95ff5 100644 --- a/crates/op-i18n/src/i18n/hi.rs +++ b/crates/op-i18n/src/i18n/hi.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "फ़ाइल में निर्यात करें…", "variables.presetName" => "प्रीसेट का नाम", "variables.noPresets" => "कोई सहेजा गया प्रीसेट नहीं", + "variables.typeColor" => "रंग", + "variables.typeNumber" => "संख्या", + "variables.typeString" => "पाठ", "designMd.title" => "डिज़ाइन सिस्टम", "designMd.import" => "design.md आयात करें", "designMd.export" => "design.md निर्यात करें", diff --git a/crates/op-i18n/src/i18n/id.rs b/crates/op-i18n/src/i18n/id.rs index f2f26cc8..17a814bc 100644 --- a/crates/op-i18n/src/i18n/id.rs +++ b/crates/op-i18n/src/i18n/id.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Ekspor ke file…", "variables.presetName" => "Nama preset", "variables.noPresets" => "Tidak ada preset tersimpan", + "variables.typeColor" => "Warna", + "variables.typeNumber" => "Angka", + "variables.typeString" => "Teks", "designMd.title" => "Sistem Desain", "designMd.import" => "Impor design.md", "designMd.export" => "Ekspor design.md", diff --git a/crates/op-i18n/src/i18n/ja.rs b/crates/op-i18n/src/i18n/ja.rs index 2c73e56f..fa8498fa 100644 --- a/crates/op-i18n/src/i18n/ja.rs +++ b/crates/op-i18n/src/i18n/ja.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "ファイルにエクスポート…", "variables.presetName" => "プリセット名", "variables.noPresets" => "保存されたプリセットはありません", + "variables.typeColor" => "色", + "variables.typeNumber" => "数値", + "variables.typeString" => "文字列", "designMd.title" => "デザインシステム", "designMd.import" => "design.md をインポート", "designMd.export" => "design.md をエクスポート", diff --git a/crates/op-i18n/src/i18n/ko.rs b/crates/op-i18n/src/i18n/ko.rs index 1021f0e6..6c244033 100644 --- a/crates/op-i18n/src/i18n/ko.rs +++ b/crates/op-i18n/src/i18n/ko.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "파일로 내보내기…", "variables.presetName" => "프리셋 이름", "variables.noPresets" => "저장된 프리셋 없음", + "variables.typeColor" => "색상", + "variables.typeNumber" => "숫자", + "variables.typeString" => "문자열", "designMd.title" => "디자인 시스템", "designMd.import" => "design.md 가져오기", "designMd.export" => "design.md 내보내기", diff --git a/crates/op-i18n/src/i18n/pt.rs b/crates/op-i18n/src/i18n/pt.rs index 0adb530f..01b70908 100644 --- a/crates/op-i18n/src/i18n/pt.rs +++ b/crates/op-i18n/src/i18n/pt.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Exportar para arquivo…", "variables.presetName" => "Nome da predefinição", "variables.noPresets" => "Nenhuma predefinição salva", + "variables.typeColor" => "Cor", + "variables.typeNumber" => "Número", + "variables.typeString" => "Texto", "designMd.title" => "Sistema de design", "designMd.import" => "Importar design.md", "designMd.export" => "Exportar design.md", diff --git a/crates/op-i18n/src/i18n/ru.rs b/crates/op-i18n/src/i18n/ru.rs index cd86a4f7..9d190891 100644 --- a/crates/op-i18n/src/i18n/ru.rs +++ b/crates/op-i18n/src/i18n/ru.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Экспорт в файл…", "variables.presetName" => "Название пресета", "variables.noPresets" => "Нет сохранённых пресетов", + "variables.typeColor" => "Цвет", + "variables.typeNumber" => "Число", + "variables.typeString" => "Строка", "designMd.title" => "Дизайн-система", "designMd.import" => "Импортировать design.md", "designMd.export" => "Экспортировать design.md", diff --git a/crates/op-i18n/src/i18n/th.rs b/crates/op-i18n/src/i18n/th.rs index 6ff1ee02..bd59a7b4 100644 --- a/crates/op-i18n/src/i18n/th.rs +++ b/crates/op-i18n/src/i18n/th.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "ส่งออกเป็นไฟล์…", "variables.presetName" => "ชื่อพรีเซ็ต", "variables.noPresets" => "ไม่มีพรีเซ็ตที่บันทึกไว้", + "variables.typeColor" => "สี", + "variables.typeNumber" => "ตัวเลข", + "variables.typeString" => "ข้อความ", "designMd.title" => "ระบบการออกแบบ", "designMd.import" => "นำเข้า design.md", "designMd.export" => "ส่งออก design.md", diff --git a/crates/op-i18n/src/i18n/tr.rs b/crates/op-i18n/src/i18n/tr.rs index 78c8d0b0..5b29a54d 100644 --- a/crates/op-i18n/src/i18n/tr.rs +++ b/crates/op-i18n/src/i18n/tr.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Dosyaya dışa aktar…", "variables.presetName" => "Ön ayar adı", "variables.noPresets" => "Kayıtlı ön ayar yok", + "variables.typeColor" => "Renk", + "variables.typeNumber" => "Sayı", + "variables.typeString" => "Metin", "designMd.title" => "Tasarım Sistemi", "designMd.import" => "design.md içe aktar", "designMd.export" => "design.md dışa aktar", diff --git a/crates/op-i18n/src/i18n/vi.rs b/crates/op-i18n/src/i18n/vi.rs index 5500645c..29b58824 100644 --- a/crates/op-i18n/src/i18n/vi.rs +++ b/crates/op-i18n/src/i18n/vi.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "Xuất ra tệp…", "variables.presetName" => "Tên mẫu", "variables.noPresets" => "Chưa có mẫu nào được lưu", + "variables.typeColor" => "Màu", + "variables.typeNumber" => "Số", + "variables.typeString" => "Văn bản", "designMd.title" => "Hệ thống thiết kế", "designMd.import" => "Nhập design.md", "designMd.export" => "Xuất design.md", diff --git a/crates/op-i18n/src/i18n/zh_cn.rs b/crates/op-i18n/src/i18n/zh_cn.rs index b8bb1f9a..373990a0 100644 --- a/crates/op-i18n/src/i18n/zh_cn.rs +++ b/crates/op-i18n/src/i18n/zh_cn.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "导出到文件…", "variables.presetName" => "预设名称", "variables.noPresets" => "没有保存的预设", + "variables.typeColor" => "颜色", + "variables.typeNumber" => "数字", + "variables.typeString" => "文本", "designMd.title" => "设计系统", "designMd.import" => "导入 design.md", "designMd.export" => "导出 design.md", diff --git a/crates/op-i18n/src/i18n/zh_tw.rs b/crates/op-i18n/src/i18n/zh_tw.rs index 3c71293b..f5eda92d 100644 --- a/crates/op-i18n/src/i18n/zh_tw.rs +++ b/crates/op-i18n/src/i18n/zh_tw.rs @@ -232,6 +232,9 @@ pub fn lookup(key: &str) -> Option<&'static str> { "variables.exportPreset" => "匯出到檔案…", "variables.presetName" => "預設名稱", "variables.noPresets" => "沒有儲存的預設", + "variables.typeColor" => "顏色", + "variables.typeNumber" => "數字", + "variables.typeString" => "文字", "designMd.title" => "設計系統", "designMd.import" => "匯入 design.md", "designMd.export" => "匯出 design.md",