feat(editor): improve native panel parity
Some checks failed
Rust check (native) / macos-latest / 1.94 (push) Waiting to run
Rust check (native) / windows-latest / 1.94 (push) Waiting to run
Rust multi-platform build / linux-aarch64 (push) Waiting to run
Rust multi-platform build / macos-aarch64 (push) Waiting to run
Rust multi-platform build / windows-x86_64 (push) Waiting to run
Rust multi-platform build / macos-x86_64 (push) Waiting to run
Rust multi-platform build / windows-aarch64 (push) Waiting to run
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Waiting to run
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Waiting to run
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 2s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 1s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 1s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 2s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 1s

This commit is contained in:
Kayshen-X 2026-05-26 21:36:03 +08:00
parent e1ba222e87
commit f547fe1737
57 changed files with 5433 additions and 1138 deletions

View file

@ -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<f32>,
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<String>,
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<String>,
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<String>,
anchor_x: Option<f32>,
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,
});

View file

@ -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<String>,
/// Theme-axis tab whose rename/delete menu is open.
pub variables_theme_menu_axis: Option<String>,
/// Variant column whose rename/delete menu is open.
pub variables_variant_menu_value: Option<String>,
/// Theme-axis name currently being edited in the VariablesPanel.
pub variables_theme_rename_axis: Option<String>,
/// Variant column value currently being edited in the VariablesPanel.
pub variables_variant_rename_value: Option<String>,
/// 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<String>,
/// Active-theme axis whose value picker is open; `None` = closed.
pub axis_dropdown_open: Option<String>,
/// 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(),

View file

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

View file

@ -16,7 +16,7 @@ use crate::tool::Tool;
pub enum ToolbarAction {
Undo,
Redo,
ToggleCodePanel,
ToggleVariablesPanel,
ToggleDesignPanel,
}

View file

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

View file

@ -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<String>) -> 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<String>,
) -> 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::*;

View file

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

View file

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

View file

@ -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<IconPickerPanel<'a>> {
Self::for_editor_at(state, 0)
}
pub fn for_editor_at(state: &'a EditorState, now_ms: u64) -> Option<IconPickerPanel<'a>> {
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<IconRow<'a>> {
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<usize> {
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<usize> {
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<IconPickerHit> {
@ -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<usize> {
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());
}
}

View file

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

View file

@ -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<String>,
/// 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<Rect> {
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<Rect> {
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,
);
}
}

View file

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

View file

@ -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<String>) -> Vec<String> {
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<String> {
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<Rect> {
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<Rect> {
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<PropertyPanelAction> {
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<String> {
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<String>, seen: &mut HashSet<String>, 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<String>, 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<f32> {
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
}

View file

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

View file

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

View file

@ -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<String> = (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();

View file

@ -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<f32> {
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(['"', '\''])
}

View file

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

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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<Color> {
let hex = match s {
VariableScalar::Str(hex) => hex,
_ => return None,
};
let (r, g, b) = op_editor_core::color_picker::parse_hex_rgb(hex)?;
Some(Color { r, g, b, a: 1.0 })
}
fn scalar_hex_label(s: &VariableScalar) -> Option<String> {
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))
}

View file

@ -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<String>,
origins: Vec<Point2D>,
}
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");
}

View file

@ -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(),
_ => {}
}
}

View file

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

View file

@ -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<Vec<String>> = OnceLock::new();
pub(crate) fn system_font_families() -> Vec<String> {
SYSTEM_FONT_FAMILIES
.get_or_init(enumerate_system_font_families)
.clone()
}
pub(crate) fn spawn_system_font_loader() -> Receiver<Vec<String>> {
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<String> {
let mgr = skia_safe::FontMgr::new();
let families: Vec<String> = 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]));
}
}
}

View file

@ -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<Receiver<Vec<String>>>,
}
#[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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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::<f64>() 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

View file

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

View file

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

View file

@ -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();
}

View file

@ -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<String> {
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::<f64>() 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();
}
}

View file

@ -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<Rect> {
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)))
}
}

View file

@ -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<VariableScalar> {
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!()
}

View file

@ -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())
);
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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 निर्यात करें",

View file

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

View file

@ -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 をエクスポート",

View file

@ -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 내보내기",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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