mirror of
https://github.com/ZSeven-W/openpencil.git
synced 2026-05-31 19:04:29 +07:00
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
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:
parent
e1ba222e87
commit
f547fe1737
57 changed files with 5433 additions and 1138 deletions
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ use crate::tool::Tool;
|
|||
pub enum ToolbarAction {
|
||||
Undo,
|
||||
Redo,
|
||||
ToggleCodePanel,
|
||||
ToggleVariablesPanel,
|
||||
ToggleDesignPanel,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
396
crates/op-editor-ui/src/widgets/property_panel_font_picker.rs
Normal file
396
crates/op-editor-ui/src/widgets/property_panel_font_picker.rs
Normal 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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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(['"', '\''])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
16
crates/op-editor-ui/src/widgets/variables_panel/header.rs
Normal file
16
crates/op-editor-ui/src/widgets/variables_panel/header.rs
Normal 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
|
||||
}
|
||||
627
crates/op-editor-ui/src/widgets/variables_panel/paint.rs
Normal file
627
crates/op-editor-ui/src/widgets/variables_panel/paint.rs
Normal 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))
|
||||
}
|
||||
491
crates/op-editor-ui/src/widgets/variables_panel/tests.rs
Normal file
491
crates/op-editor-ui/src/widgets/variables_panel/tests.rs
Normal 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");
|
||||
}
|
||||
|
|
@ -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(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
46
crates/op-host-native/src/system_fonts.rs
Normal file
46
crates/op-host-native/src/system_fonts.rs
Normal 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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
152
crates/op-host-native/src/widget_host/keyboard_motion.rs
Normal file
152
crates/op-host-native/src/widget_host/keyboard_motion.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
262
crates/op-host-native/src/widget_host/variables_panel_commit.rs
Normal file
262
crates/op-host-native/src/widget_host/variables_panel_commit.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
635
crates/op-host-native/src/widget_host/variables_panel_press.rs
Normal file
635
crates/op-host-native/src/widget_host/variables_panel_press.rs
Normal 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!()
|
||||
}
|
||||
759
crates/op-host-native/src/widget_host/variables_panel_tests.rs
Normal file
759
crates/op-host-native/src/widget_host/variables_panel_tests.rs
Normal 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())
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 निर्यात करें",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 をエクスポート",
|
||||
|
|
|
|||
|
|
@ -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 내보내기",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue