diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index f4f556d02..4b93bcfbc 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -72,6 +72,22 @@ impl WidgetHost { self.mark_dirty(); return true; } + // #20: the variables preset dropdown's save-as-name input. Types into + // the shared `property_input_draft` the ThemePresetMenu widget paints + // (native parity, `keyboard.rs::apply_text`). Web keeps the caret at the + // end of the draft (no mid-string editing wired for this field). + if self.editor_state.editor_ui.preset_name_input_active() && !c.is_control() { + let ui = &mut self.editor_state.ui; + if ui.property_draft_select_all { + ui.property_input_draft.clear(); + ui.property_draft_select_all = false; + } + ui.property_input_draft.push(c); + ui.property_caret_pos = ui.property_input_draft.len(); + ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } // Variables-panel row/cell drafts — per-kind char gates // mirror the native host (numeric / free text / hex). if let Some(focus) = self.editor_state.editor_ui.variable_row_focus { @@ -241,6 +257,26 @@ impl WidgetHost { } return false; } + // #20: preset save-as-name input — pop the last char (or clear a + // select-all draft), native parity. + if self.editor_state.editor_ui.preset_name_input_active() { + let ui = &mut self.editor_state.ui; + if ui.property_draft_select_all { + ui.property_input_draft.clear(); + ui.property_caret_pos = 0; + ui.property_draft_select_all = false; + ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + if ui.property_input_draft.pop().is_some() { + ui.property_caret_pos = ui.property_input_draft.len(); + ui.property_caret_anchor_ms = self.now_ms; + self.mark_dirty(); + return true; + } + return false; + } if self .editor_state .editor_ui @@ -389,6 +425,11 @@ impl WidgetHost { } return true; } + // #20: Enter in the preset save-as-name input saves the preset + // (native parity, `variable-theme-manager.tsx:298`). + if self.commit_variables_preset_name_if_any() { + return true; + } // Enter in the variables search box just blurs it (the // filter is already live). if self.variables_search_active() { @@ -604,6 +645,11 @@ impl WidgetHost { self.mark_dirty(); return true; } + // #20: Escape closes just the preset save-as-name input; the preset + // dropdown stays open (native parity, `variable-theme-manager.tsx:299`). + if self.escape_variables_preset_name() { + return true; + } // Modal overlays close one per press (mirrors native order: // export dialog → figma import → file menu). if self.editor_state.editor_ui.export_dialog_open { diff --git a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs index 9c996a091..40cf2057a 100644 --- a/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs +++ b/crates/op-host-web/src/widget_host/keyboard_edit_ops.rs @@ -55,6 +55,40 @@ impl WidgetHost { true } + /// Enter in the preset save-as-name input — saves the current theme as a + /// named preset, clears the draft, and defocuses (the dropdown stays open). + /// Native parity (`variables_preset_press.rs::commit_variables_preset_name_if_any`). + /// Blank names keep the input open. Returns whether the input was active. + pub(in crate::widget_host) fn commit_variables_preset_name_if_any(&mut self) -> bool { + if !self.editor_state.editor_ui.preset_name_input_active() { + return false; + } + let name = self.editor_state.ui.property_input_draft.clone(); + if name.trim().is_empty() { + return true; + } + let now_ms = self.now_ms; + let _ = self.editor_state.save_theme_preset(&name, now_ms); + self.editor_state.editor_ui.variables_preset_name_focus = false; + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.mark_dirty(); + true + } + + /// Escape in the preset save-as-name input — closes just the input, leaving + /// the preset dropdown open. Native parity. + pub(in crate::widget_host) fn escape_variables_preset_name(&mut self) -> bool { + if !self.editor_state.editor_ui.preset_name_input_active() { + return false; + } + self.editor_state.editor_ui.variables_preset_name_focus = false; + self.editor_state.ui.property_input_draft.clear(); + self.editor_state.ui.property_caret_pos = 0; + self.mark_dirty(); + true + } + /// Cmd/Ctrl+D — duplicate the selected node as a sibling /// offset by ~10 doc px. Selection follows the clone. pub fn apply_duplicate(&mut self) -> bool { diff --git a/crates/op-host-web/src/widget_host/keyboard_tests.rs b/crates/op-host-web/src/widget_host/keyboard_tests.rs index 34df7f5e4..4f4ef0e28 100644 --- a/crates/op-host-web/src/widget_host/keyboard_tests.rs +++ b/crates/op-host-web/src/widget_host/keyboard_tests.rs @@ -501,3 +501,41 @@ fn tool_shortcut_does_not_steal_keys_from_preset_name_input() { assert!(!host.apply_tool_shortcut("r")); assert_eq!(host.editor_state.tool, Tool::Select); } + +#[test] +fn preset_name_input_receives_typed_keys_backspace_and_commit() { + let mut host = WidgetHost::new(); + host.editor_state.editor_ui.variables_preset_menu_open = true; + host.editor_state.editor_ui.variables_preset_name_focus = true; + host.editor_state.ui.property_input_draft.clear(); + assert!(host.editor_state.editor_ui.preset_name_input_active()); + + // Typed keys land in the shared draft the preset menu widget paints. + assert!(host.apply_text('M')); + assert!(host.apply_text('i')); + assert!(host.apply_text('d')); + assert_eq!(host.editor_state.ui.property_input_draft, "Mid"); + + // Backspace pops the last char. + assert!(host.apply_backspace()); + assert_eq!(host.editor_state.ui.property_input_draft, "Mi"); + + // Enter commits the preset and defocuses the name input. + assert!(host.apply_send()); + assert!(!host.editor_state.editor_ui.variables_preset_name_focus); + assert!(host.editor_state.ui.property_input_draft.is_empty()); +} + +#[test] +fn preset_name_input_escape_cancels_without_saving() { + let mut host = WidgetHost::new(); + host.editor_state.editor_ui.variables_preset_menu_open = true; + host.editor_state.editor_ui.variables_preset_name_focus = true; + host.editor_state.ui.property_input_draft = "Draft".to_string(); + + // Escape closes just the name input; the dropdown stays open. + assert!(host.apply_escape()); + assert!(!host.editor_state.editor_ui.variables_preset_name_focus); + assert!(host.editor_state.editor_ui.variables_preset_menu_open); + assert!(host.editor_state.ui.property_input_draft.is_empty()); +}