diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index e425f5608..907fce124 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -918,18 +918,11 @@ pub struct EditorUiState { pub icon_picker_load_more_request: Option, // --- AI chat model picker -------------------------------------- - /// AI chat model-picker dropdown open. - pub chat_model_picker_open: bool, - /// Vertical scroll offset of the model-picker dropdown, in px. - /// Non-zero only when the connected catalog is taller than the - /// picker's capped height; the host clamps it on wheel input. - pub chat_model_picker_scroll: f32, + /// AI chat model-picker dropdown interaction state. + pub chat_model_picker: jian_widgets::components::select::SelectState, /// Text filter, caret, selection, and blink state for the chat /// model-picker search box. pub chat_model_picker_input: jian_core::text_input::TextInputState, - /// Index into `chat.available_models` of the model row the cursor - /// is over, or `None`. Drives the picker's hover-row tint. - pub chat_model_picker_hover: Option, /// Hovered chat design JSON card `(message_index, block_index)`; /// drives the TS-style hover reveal of the card's copy affordance. pub chat_design_block_hover: Option<(usize, usize)>, @@ -1242,10 +1235,8 @@ impl Default for EditorUiState { icon_picker_hover: None, icon_picker_remote: crate::icon_picker_state::IconPickerRemoteState::default(), icon_picker_load_more_request: None, - chat_model_picker_open: false, - chat_model_picker_scroll: 0.0, + chat_model_picker: jian_widgets::components::select::SelectState::default(), chat_model_picker_input: jian_core::text_input::TextInputState::default(), - chat_model_picker_hover: None, chat_design_block_hover: None, chat_example_hover: None, chat_header_hover: None, @@ -1383,6 +1374,29 @@ impl EditorUiState { changed } + pub fn toggle_chat_model_picker(&mut self) -> bool { + let opening = !self.chat_model_picker.open; + self.close_chat_model_picker(); + if opening { + self.chat_model_picker.open = true; + } + opening + } + + pub fn close_chat_model_picker(&mut self) -> bool { + let changed = self.chat_model_picker.open + || self.chat_model_picker.hover.is_some() + || self.chat_model_picker.pressed.is_some() + || self.chat_model_picker.scroll.offset != 0.0 + || !self.chat_model_picker_input.text().is_empty(); + self.chat_model_picker.open = false; + self.chat_model_picker.hover = None; + self.chat_model_picker.pressed = None; + self.chat_model_picker.scroll.offset = 0.0; + self.chat_model_picker_input.set_text(""); + changed + } + /// Whether the preset dropdown's save-as-name input owns the /// keyboard. Gated on the menu being open so a stale focus flag /// (e.g. the menu closed by a panel-side `close_variable_menus`) @@ -1662,4 +1676,31 @@ mod tests { assert_eq!(ui.font_picker.pressed, None); assert_eq!(ui.font_picker.scroll.offset, 0.0); } + + #[test] + fn chat_model_picker_helpers_reset_select_interaction_state_and_search() { + let mut ui = EditorUiState::new(); + ui.chat_model_picker_input.set_text("gpt"); + ui.chat_model_picker.hover = Some(1); + ui.chat_model_picker.pressed = Some(1); + ui.chat_model_picker.scroll.offset = 28.0; + + assert!(ui.toggle_chat_model_picker()); + assert!(ui.chat_model_picker.open); + assert!(ui.chat_model_picker_input.text().is_empty()); + assert_eq!(ui.chat_model_picker.hover, None); + assert_eq!(ui.chat_model_picker.pressed, None); + assert_eq!(ui.chat_model_picker.scroll.offset, 0.0); + + ui.chat_model_picker_input.set_text("claude"); + ui.chat_model_picker.hover = Some(0); + ui.chat_model_picker.pressed = Some(0); + ui.chat_model_picker.scroll.offset = 28.0; + assert!(!ui.toggle_chat_model_picker()); + assert!(!ui.chat_model_picker.open); + assert!(ui.chat_model_picker_input.text().is_empty()); + assert_eq!(ui.chat_model_picker.hover, None); + assert_eq!(ui.chat_model_picker.pressed, None); + assert_eq!(ui.chat_model_picker.scroll.offset, 0.0); + } } diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index abbfc7958..2241f4a0a 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -579,10 +579,7 @@ impl EditorState { } } } - self.editor_ui.chat_model_picker_open = false; - self.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_ui.chat_model_picker_input.set_text(""); - self.editor_ui.chat_model_picker_hover = None; + self.editor_ui.close_chat_model_picker(); } /// Recompute the chat model-picker's `available_models` from the diff --git a/crates/op-editor-core/src/tests_mutators.rs b/crates/op-editor-core/src/tests_mutators.rs index 112bf5140..d3a40927c 100644 --- a/crates/op-editor-core/src/tests_mutators.rs +++ b/crates/op-editor-core/src/tests_mutators.rs @@ -545,13 +545,13 @@ fn select_chat_model_picks_model_and_syncs_agent() { crate::ModelEntry::new(crate::AgentProvider::ClaudeCode, "claude", "Claude"), crate::ModelEntry::new(crate::AgentProvider::GeminiCli, "gemini", "Gemini"), ]; - s.editor_ui.chat_model_picker_open = true; + s.editor_ui.chat_model_picker.open = true; s.select_chat_model(1); assert_eq!(s.chat.selected_model, 1); // GeminiCli is index 4 in AgentProvider::ALL. assert_eq!(s.editor_ui.chat_selected_agent, 4); // Picker closes on selection. - assert!(!s.editor_ui.chat_model_picker_open); + assert!(!s.editor_ui.chat_model_picker.open); } #[test] @@ -562,11 +562,11 @@ fn select_chat_model_bad_index_still_closes_picker() { "c", "C", )]; - s.editor_ui.chat_model_picker_open = true; + s.editor_ui.chat_model_picker.open = true; s.select_chat_model(9); // Out-of-range index ignored — selected_model unchanged. assert_eq!(s.chat.selected_model, 0); - assert!(!s.editor_ui.chat_model_picker_open); + assert!(!s.editor_ui.chat_model_picker.open); } #[test] @@ -679,13 +679,13 @@ fn select_chat_model_keeps_agent_sync_unchanged_for_acp_models() { crate::ModelEntry::new(crate::AgentProvider::CodexCli, "acp:acp-1", "Local ACP"), ]; s.editor_ui.chat_selected_agent = 0; - s.editor_ui.chat_model_picker_open = true; + s.editor_ui.chat_model_picker.open = true; s.select_chat_model(1); assert_eq!(s.chat.selected_model, 1); assert_eq!(s.editor_ui.chat_selected_agent, 0); - assert!(!s.editor_ui.chat_model_picker_open); + assert!(!s.editor_ui.chat_model_picker.open); } // --- Layer collapse (Gap 3) ----------------------------------------- diff --git a/crates/op-editor-core/src/text_input_focus.rs b/crates/op-editor-core/src/text_input_focus.rs index 42472df45..f7a22880c 100644 --- a/crates/op-editor-core/src/text_input_focus.rs +++ b/crates/op-editor-core/src/text_input_focus.rs @@ -30,7 +30,7 @@ impl EditorState { if self.editor_ui.agent_settings_open && self.editor_ui.agent_settings.focus.is_some() { return Some(&self.editor_ui.settings_input); } - if self.editor_ui.chat_model_picker_open { + if self.editor_ui.chat_model_picker.open { return Some(&self.editor_ui.chat_model_picker_input); } if self.chat.focused { @@ -88,7 +88,7 @@ impl EditorState { if self.editor_ui.agent_settings_open && self.editor_ui.agent_settings.focus.is_some() { return Some(&mut self.editor_ui.settings_input); } - if self.editor_ui.chat_model_picker_open { + if self.editor_ui.chat_model_picker.open { return Some(&mut self.editor_ui.chat_model_picker_input); } if self.chat.focused { diff --git a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs index 00bd45e62..5b10e2e74 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_hit.rs @@ -29,7 +29,7 @@ pub enum AIChatHit { /// Click on the plus affordance in the header. NewChat, /// Click on the model chip (bottom-left of the input toolbar) — - /// host toggles `ui.chat_model_picker_open` to open / close the + /// host toggles `ui.chat_model_picker.open` to open / close the /// model dropdown. ToggleModelPicker, /// Click on a model row in the open picker dropdown — payload diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs index bf1a06ee5..a716bbbbc 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker.rs @@ -11,6 +11,8 @@ use crate::widgets::property_panel_text_input::paint_text_input_view_value; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; use jian_core::text_input::TextInputState; +pub use jian_widgets::components::select::SelectHit; +use jian_widgets::components::select::SelectState; use op_editor_core::chat::{AgentProvider, ModelEntry}; /// Height of a provider group-header row. @@ -184,32 +186,60 @@ pub fn model_at( scroll: f32, search: &str, ) -> Option { + let mut state = SelectState::default(); + state.open = true; + state.scroll.offset = scroll; + match model_picker_hit(&state, rect, point, models, search) { + SelectHit::Row(index) => Some(index), + SelectHit::Inside | SelectHit::Outside => None, + } +} + +/// Shared select-style hit protocol for the searchable model picker. +/// Search/header/empty/padding chrome returns `Inside`; model rows +/// return `Row(index)` where `index` addresses `available_models`. +pub fn model_picker_hit( + state: &SelectState, + rect: Rect, + point: Point2D, + models: &[ModelEntry], + search: &str, +) -> SelectHit { + if !state.open { + return SelectHit::Outside; + } if point.x < rect.origin.x || point.x > rect.origin.x + rect.size.x || point.y < rect.origin.y || point.y > rect.origin.y + rect.size.y { - return None; + return SelectHit::Outside; } let list_rect = model_list_rect(rect); if point.y < list_rect.origin.y { - return None; + return SelectHit::Inside; } - let mut hit = None; + let mut hit = SelectHit::Inside; // Walk from a scroll-shifted origin — the same offset paint // applies via `translate` — then keep only hits whose row band // actually falls inside the (unscrolled) card rect. - walk_rows(models, search, list_rect.origin.y - scroll, |row, y, h| { - if let Row::Model { idx, .. } = row { + walk_rows( + models, + search, + list_rect.origin.y - state.scroll.offset, + |row, y, h| { if point.y >= y && point.y < y + h && point.y >= list_rect.origin.y && point.y <= rect.origin.y + rect.size.y { - hit = Some(*idx); + hit = match row { + Row::Model { idx, .. } => SelectHit::Row(*idx), + Row::Header { .. } => SelectHit::Inside, + }; } - } - }); + }, + ); hit } @@ -243,13 +273,14 @@ pub fn paint_model_picker( rect: Rect, models: &[ModelEntry], selected: usize, - scroll: f32, - hover: Option, + state: &SelectState, input: &TextInputState, now_ms: u64, locale: op_editor_core::Locale, ) { let search = input.text(); + let scroll = state.scroll.offset; + let hover = state.hover; // Card background + border — painted unscrolled so the frame // stays put while the rows scroll inside it. cx.backend.fill_round_rect(rect, 10.0, theme.card); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_model_picker_tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_model_picker_tests.rs index d354af14a..f55057bc7 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_model_picker_tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_model_picker_tests.rs @@ -1,5 +1,6 @@ use super::ai_chat_model_picker::*; use crate::{Point2D, Rect}; +use jian_widgets::components::select::{SelectHit, SelectState}; use op_editor_core::chat::{AgentProvider, ModelEntry}; fn entry(p: AgentProvider, v: &str) -> ModelEntry { @@ -109,6 +110,34 @@ fn model_at_filters_by_search_and_returns_original_index() { ); } +#[test] +fn model_picker_hit_uses_shared_select_state_protocol() { + let models = vec![ + entry(AgentProvider::ClaudeCode, "a"), + entry(AgentProvider::CodexCli, "b"), + ]; + let mut state = SelectState::default(); + state.open = true; + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(220.0, picker_view_height(&models, "")), + }; + let first_row_y = MODEL_SEARCH_H + MODEL_PICKER_PAD_Y + MODEL_GROUP_H + MODEL_ROW_H / 2.0; + + assert_eq!( + model_picker_hit(&state, rect, Point2D::new(100.0, first_row_y), &models, ""), + SelectHit::Row(0) + ); + assert_eq!( + model_picker_hit(&state, rect, Point2D::new(100.0, 12.0), &models, ""), + SelectHit::Inside + ); + assert_eq!( + model_picker_hit(&state, rect, Point2D::new(-1.0, 12.0), &models, ""), + SelectHit::Outside + ); +} + #[test] fn builtin_group_header_prefers_retained_provider_display_name() { let mut entry = ModelEntry::builtin( diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index 4126e9e0e..ba0bbda80 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -11,6 +11,7 @@ use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; use jian_core::text_input::TextInputState; +use jian_widgets::components::select::SelectState; use op_editor_core::chat::ChatState; use op_editor_core::EditorState; @@ -86,12 +87,8 @@ pub struct AIChatPlaceholder<'a> { /// Number of currently selected canvas nodes, shown in the /// bottom toolbar like the TS panel. pub(crate) selected_count: usize, - /// Whether the model-picker dropdown is open. - pub model_picker_open: bool, - /// Vertical scroll offset of the open model-picker dropdown. - pub model_picker_scroll: f32, - /// Index into `state.available_models` of the picker row under the cursor. - pub model_picker_hover: Option, + /// Model-picker dropdown interaction state. + pub model_picker: &'a SelectState, /// Text state for the model-picker search query. pub model_picker_input: &'a TextInputState, pub design_hover: Option<(usize, usize)>, @@ -128,9 +125,7 @@ impl<'a> AIChatPlaceholder<'a> { label_tip_select_elements: translate(ui, "ai.tipSelectElements").to_string(), label_no_models: translate(ui, "ai.noModelsConnected").to_string(), selected_count: state.selection_count(), - model_picker_open: ui.chat_model_picker_open, - model_picker_scroll: ui.chat_model_picker_scroll, - model_picker_hover: ui.chat_model_picker_hover, + model_picker: &ui.chat_model_picker, model_picker_input: &ui.chat_model_picker_input, design_hover: ui.chat_design_block_hover, example_hover: ui.chat_example_hover, @@ -231,7 +226,7 @@ impl<'a> AIChatPlaceholder<'a> { } pub fn model_picker_bounds(&self, rect: Rect) -> Option { - if !self.model_picker_open { + if !self.model_picker.open { return None; } let input_rect = self.input_rect(rect); @@ -692,7 +687,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { // Model-picker dropdown paints last so it sits above the // message list / examples / input. - if self.model_picker_open { + if self.model_picker.open { let picker = self.model_picker_rect(rect, input_rect); crate::widgets::ai_chat_model_picker::paint_model_picker( cx, @@ -700,8 +695,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> { picker, &self.state.available_models, self.state.selected_model, - self.model_picker_scroll, - self.model_picker_hover, + self.model_picker, self.model_picker_input, self.now_ms, self.locale, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs index b2f062eee..192c61de5 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests.rs @@ -265,7 +265,7 @@ fn multiline_input_expands_above_footer_toolbar() { fn hit_test_resolves_model_search_clear_button() { let mut s = EditorState::new(); seed_available_model(&mut s); - s.editor_ui.chat_model_picker_open = true; + s.editor_ui.chat_model_picker.open = true; s.editor_ui.chat_model_picker_input.set_text("231"); let panel = AIChatPlaceholder::from_editor(&s); let rect = Rect::xywh(0.0, 0.0, AI_CHAT_WIDTH, AI_CHAT_HEIGHT); diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs index ec939bc61..53927685f 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel_hit.rs @@ -68,7 +68,7 @@ impl<'a> AIChatPlaceholder<'a> { // open it behaves modally: a row click selects, any other // click dismisses it. Hit-tested before the input so a row // click isn't eaten by the message list beneath. - if self.model_picker_open { + if self.model_picker.open { let picker = self.model_picker_rect(rect, input_rect); if crate::widgets::ai_chat_model_picker::search_clear_hit( picker, @@ -77,17 +77,20 @@ impl<'a> AIChatPlaceholder<'a> { ) { return Some(AIChatHit::ClearModelSearch); } - if let Some(idx) = crate::widgets::ai_chat_model_picker::model_at( + match crate::widgets::ai_chat_model_picker::model_picker_hit( + self.model_picker, picker, point, &self.state.available_models, - self.model_picker_scroll, self.model_picker_input.text(), ) { - return Some(AIChatHit::SelectModel(idx)); - } - if (picker).contains(point) { - return Some(AIChatHit::FocusModelSearch); + jian_widgets::components::select::SelectHit::Row(idx) => { + return Some(AIChatHit::SelectModel(idx)); + } + jian_widgets::components::select::SelectHit::Inside => { + return Some(AIChatHit::FocusModelSearch); + } + jian_widgets::components::select::SelectHit::Outside => {} } return Some(AIChatHit::ToggleModelPicker); } @@ -295,7 +298,7 @@ impl<'a> AIChatPlaceholder<'a> { rect: Rect, point: Point2D, ) -> Option { - if self.state.collapsed || self.model_picker_open { + if self.state.collapsed || self.model_picker.open { return None; } let input_rect = self.input_rect(rect); diff --git a/crates/op-editor-ui/tests/ai_chat_model_picker.rs b/crates/op-editor-ui/tests/ai_chat_model_picker.rs index 87262b1bb..71299e0fc 100644 --- a/crates/op-editor-ui/tests/ai_chat_model_picker.rs +++ b/crates/op-editor-ui/tests/ai_chat_model_picker.rs @@ -1,4 +1,5 @@ use jian_core::text_input::TextInputState; +use jian_widgets::components::select::SelectState; use op_editor_core::chat::{AgentProvider, ModelEntry}; use op_editor_core::Locale; use op_editor_ui::theme::Theme; @@ -52,12 +53,19 @@ fn caret_fills(fills: &[(Rect, Color)], theme: Theme) -> Vec { .collect() } +fn open_select_state() -> SelectState { + let mut state = SelectState::default(); + state.open = true; + state +} + #[test] fn model_picker_search_paints_visible_caret_at_blink_on_phase() { let theme = Theme::dark(); let models = vec![ModelEntry::new(AgentProvider::CodexCli, "gpt-5", "gpt-5")]; let rect = Rect::xywh(10.0, 20.0, 240.0, picker_view_height(&models, "")); let input = TextInputState::default(); + let state = open_select_state(); let mut backend = CaptureBackend::default(); let mut cx = PaintCx { backend: &mut backend, @@ -69,8 +77,7 @@ fn model_picker_search_paints_visible_caret_at_blink_on_phase() { rect, &models, 0, - 0.0, - None, + &state, &input, 100, Locale::EnUs, @@ -85,6 +92,7 @@ fn model_picker_search_hides_caret_at_blink_off_phase() { let models = vec![ModelEntry::new(AgentProvider::CodexCli, "gpt-5", "gpt-5")]; let rect = Rect::xywh(10.0, 20.0, 240.0, picker_view_height(&models, "")); let input = TextInputState::default(); + let state = open_select_state(); let mut backend = CaptureBackend::default(); let mut cx = PaintCx { backend: &mut backend, @@ -96,8 +104,7 @@ fn model_picker_search_hides_caret_at_blink_off_phase() { rect, &models, 0, - 0.0, - None, + &state, &input, 500, Locale::EnUs, diff --git a/crates/op-host-native/src/widget_host/blur_inputs.rs b/crates/op-host-native/src/widget_host/blur_inputs.rs index b991ffa2f..21a297a5f 100644 --- a/crates/op-host-native/src/widget_host/blur_inputs.rs +++ b/crates/op-host-native/src/widget_host/blur_inputs.rs @@ -25,7 +25,7 @@ impl WidgetHostNative { || eui.preset_name_input_active() || self.variables_search_active() || eui.agent_settings.focus.is_some() - || eui.chat_model_picker_open + || eui.chat_model_picker.open || self.editor_state.chat.focused || git.commit_focused || git.remote_focused @@ -61,10 +61,7 @@ impl WidgetHostNative { // Variables-panel search box defocuses; its typed filter // persists (TS keeps the input value on blur). eui.variables_search_focus = false; - eui.chat_model_picker_open = false; - eui.chat_model_picker_scroll = 0.0; - eui.chat_model_picker_input.set_text(""); - eui.chat_model_picker_hover = None; + eui.close_chat_model_picker(); self.editor_state.chat.blur_input(self.now_ms); if was_focused { self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/blur_inputs_tests.rs b/crates/op-host-native/src/widget_host/blur_inputs_tests.rs index 9ebbfbb13..2caba8148 100644 --- a/crates/op-host-native/src/widget_host/blur_inputs_tests.rs +++ b/crates/op-host-native/src/widget_host/blur_inputs_tests.rs @@ -30,7 +30,7 @@ fn top_bar_gap_press_blurs_chat_and_model_picker() { let mut host = WidgetHostNative::new(); seed(&mut host, ONE_RECT); host.editor_state_mut().chat.focused = true; - host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().editor_ui.chat_model_picker.open = true; host.editor_state_mut() .editor_ui .chat_model_picker_input @@ -45,7 +45,7 @@ fn top_bar_gap_press_blurs_chat_and_model_picker() { !state.chat.focused, "top-bar gap press must blur the chat input" ); - assert!(!state.editor_ui.chat_model_picker_open); + assert!(!state.editor_ui.chat_model_picker.open); assert!(state.editor_ui.chat_model_picker_input.text().is_empty()); } diff --git a/crates/op-host-native/src/widget_host/chat_model_picker_caret.rs b/crates/op-host-native/src/widget_host/chat_model_picker_caret.rs index 1c93e47e5..6a1ed75a0 100644 --- a/crates/op-host-native/src/widget_host/chat_model_picker_caret.rs +++ b/crates/op-host-native/src/widget_host/chat_model_picker_caret.rs @@ -2,34 +2,36 @@ use super::WidgetHostNative; impl WidgetHostNative { pub(in crate::widget_host) fn apply_chat_model_picker_text(&mut self, c: char) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open || c.is_control() { + if !self.editor_state.editor_ui.chat_model_picker.open || c.is_control() { return false; } let ui = &mut self.editor_state.editor_ui; let mut s = [0u8; 4]; ui.chat_model_picker_input .insert_str(c.encode_utf8(&mut s), self.now_ms); - ui.chat_model_picker_scroll = 0.0; - ui.chat_model_picker_hover = None; + ui.chat_model_picker.scroll.offset = 0.0; + ui.chat_model_picker.hover = None; + ui.chat_model_picker.pressed = None; self.mark_dirty(); true } pub(in crate::widget_host) fn apply_chat_model_picker_backspace(&mut self) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open { + if !self.editor_state.editor_ui.chat_model_picker.open { return false; } let ui = &mut self.editor_state.editor_ui; ui.chat_model_picker_input.backspace(self.now_ms); ui.chat_model_picker_input.touch(self.now_ms); - ui.chat_model_picker_scroll = 0.0; - ui.chat_model_picker_hover = None; + ui.chat_model_picker.scroll.offset = 0.0; + ui.chat_model_picker.hover = None; + ui.chat_model_picker.pressed = None; self.mark_dirty(); true } pub fn apply_chat_model_picker_caret(&mut self, forward: bool) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open { + if !self.editor_state.editor_ui.chat_model_picker.open { return false; } let ui = &mut self.editor_state.editor_ui; diff --git a/crates/op-host-native/src/widget_host/chat_model_picker_caret_tests.rs b/crates/op-host-native/src/widget_host/chat_model_picker_caret_tests.rs index f96de14cd..aa1b4d821 100644 --- a/crates/op-host-native/src/widget_host/chat_model_picker_caret_tests.rs +++ b/crates/op-host-native/src/widget_host/chat_model_picker_caret_tests.rs @@ -6,7 +6,7 @@ fn chat_model_picker_arrows_move_caret_for_insert_and_backspace() { let mut host = WidgetHostNative::new(); { let ui = &mut host.editor_state_mut().editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("abcd"); } @@ -53,10 +53,10 @@ fn chat_model_picker_clear_button_empties_search() { host.set_now_ms(456); { let ui = &mut host.editor_state_mut().editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("231"); - ui.chat_model_picker_scroll = 10.0; - ui.chat_model_picker_hover = Some(0); + ui.chat_model_picker.scroll.offset = 10.0; + ui.chat_model_picker.hover = Some(0); } let chat_rect = host.ai_chat_rect(1200.0, 800.0).unwrap(); let panel = AIChatPlaceholder::from_editor_at(host.editor_state(), 456); @@ -69,8 +69,8 @@ fn chat_model_picker_clear_button_empties_search() { let ui = &host.editor_state().editor_ui; assert!(ui.chat_model_picker_input.text().is_empty()); assert_eq!(ui.chat_model_picker_input.caret(), 0); - assert_eq!(ui.chat_model_picker_scroll, 0.0); - assert_eq!(ui.chat_model_picker_hover, None); - assert!(ui.chat_model_picker_open); + assert_eq!(ui.chat_model_picker.scroll.offset, 0.0); + assert_eq!(ui.chat_model_picker.hover, None); + assert!(ui.chat_model_picker.open); assert_eq!(ui.chat_model_picker_input.next_blink_flip_ms(456), 956); } diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index 1cd67020b..b7c014f88 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -190,44 +190,24 @@ impl WidgetHostNative { AIChatHit::ToggleMaximize => { self.editor_state.chat.maximized = !self.editor_state.chat.maximized; self.editor_state.chat.collapsed = false; - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } AIChatHit::NewChat => { self.editor_state.chat.new_chat(); - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); - self.editor_state.editor_ui.chat_model_picker_hover = None; + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } AIChatHit::ToggleModelPicker => { - let opening = !self.editor_state.editor_ui.chat_model_picker_open; - self.editor_state.editor_ui.chat_model_picker_open = opening; - // Reopen the picker un-scrolled / un-hovered so - // a stale offset from a prior open never hides - // the top of the catalog. - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); + let opening = self.editor_state.editor_ui.toggle_chat_model_picker(); if opening { self.editor_state .editor_ui .chat_model_picker_input .touch(self.now_ms); } - self.editor_state.editor_ui.chat_model_picker_hover = None; self.mark_dirty(); return true; } @@ -244,8 +224,9 @@ impl WidgetHostNative { .editor_ui .chat_model_picker_input .set_text(""); - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state.editor_ui.chat_model_picker_hover = None; + self.editor_state.editor_ui.chat_model_picker.scroll.offset = 0.0; + self.editor_state.editor_ui.chat_model_picker.hover = None; + self.editor_state.editor_ui.chat_model_picker.pressed = None; self.editor_state .editor_ui .chat_model_picker_input diff --git a/crates/op-host-native/src/widget_host/geometry.rs b/crates/op-host-native/src/widget_host/geometry.rs index ffb24420f..8e01611e1 100644 --- a/crates/op-host-native/src/widget_host/geometry.rs +++ b/crates/op-host-native/src/widget_host/geometry.rs @@ -95,7 +95,7 @@ impl WidgetHostNative { changed |= ui.fill_type_picker.hover.take().is_some(); changed |= ui.toolbar_hover.take().is_some(); changed |= ui.align_toolbar_hover.take().is_some(); - changed |= ui.chat_model_picker_hover.take().is_some(); + changed |= ui.chat_model_picker.hover.take().is_some(); changed |= ui.chat_design_block_hover.take().is_some(); changed |= ui.chat_footer_hover.take().is_some(); changed |= ui.export_picker_hover.take().is_some(); diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index a57f5abae..5e06b1bc7 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -39,7 +39,7 @@ impl WidgetHostNative { || self.editor_state.editor_ui.effect_param_focus.is_some() || self.editor_state.editor_ui.agent_settings.focus.is_some() || self.editor_state.editor_ui.icon_picker_open - || self.editor_state.editor_ui.chat_model_picker_open + || self.editor_state.editor_ui.chat_model_picker.open || self.editor_state.editor_ui.component_browser_open || self.editor_state.chat.focused || self.git_commit_focus_active() @@ -649,11 +649,10 @@ impl WidgetHostNative { } } // Open chat model-picker — track the model row under the - // cursor so the dropdown paints a hover wash. `model_at` - // returns `None` off the rows (headers / padding / off the - // card), which clears any stale highlight. - if self.editor_state.editor_ui.chat_model_picker_open && !over_topmost { - use op_editor_ui::widgets::ai_chat_model_picker::model_at; + // cursor so the dropdown paints a hover wash. Non-row chrome + // clears any stale highlight. + if self.editor_state.editor_ui.chat_model_picker.open && !over_topmost { + use op_editor_ui::widgets::ai_chat_model_picker::{model_picker_hit, SelectHit}; use op_editor_ui::widgets::AIChatPlaceholder; let picker = self .ai_chat_rect(self.last_viewport_w, self.last_viewport_h) @@ -662,16 +661,18 @@ impl WidgetHostNative { .model_picker_bounds(chat_rect) }); if let Some(picker) = picker { - let scroll = self.editor_state.editor_ui.chat_model_picker_scroll; - let new_hover = model_at( + let new_hover = match model_picker_hit( + &self.editor_state.editor_ui.chat_model_picker, picker, Point2D::new(x, y), &self.editor_state.chat.available_models, - scroll, self.editor_state.editor_ui.chat_model_picker_input.text(), - ); - if new_hover != self.editor_state.editor_ui.chat_model_picker_hover { - self.editor_state.editor_ui.chat_model_picker_hover = new_hover; + ) { + SelectHit::Row(index) => Some(index), + SelectHit::Inside | SelectHit::Outside => None, + }; + if new_hover != self.editor_state.editor_ui.chat_model_picker.hover { + self.editor_state.editor_ui.chat_model_picker.hover = new_hover; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index b4b0a526a..c3f5e5f6c 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -1282,7 +1282,7 @@ fn delete_with_model_picker_open_does_not_delete_selected() { let mut host = WidgetHostNative::new(); host.editor_state_mut() .set_single_selection(NodeId::new("n10")); - host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().editor_ui.chat_model_picker.open = true; assert!(!host.apply_delete()); assert_eq!(host.editor_state().selection.anchor, NodeId::new("n10")); @@ -1295,7 +1295,7 @@ fn backspace_with_model_picker_open_edits_search_not_selection() { let mut host = WidgetHostNative::new(); host.editor_state_mut() .set_single_selection(NodeId::new("n10")); - host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().editor_ui.chat_model_picker.open = true; host.editor_state_mut() .editor_ui .chat_model_picker_input @@ -1316,7 +1316,7 @@ fn shortcuts_gated_while_model_picker_open() { let mut host = WidgetHostNative::new(); host.editor_state_mut() .set_single_selection(NodeId::new("n10")); - host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().editor_ui.chat_model_picker.open = true; assert!(!host.apply_nudge(1.0, 0.0)); assert!(!host.apply_duplicate()); @@ -1684,7 +1684,7 @@ fn component_browser_header_buttons_queue_kit_io_requests() { #[test] fn chat_model_picker_open_owns_keyboard_search() { let mut host = WidgetHostNative::new(); - host.editor_state_mut().editor_ui.chat_model_picker_open = true; + host.editor_state_mut().editor_ui.chat_model_picker.open = true; host.editor_state_mut().chat.focused = true; assert!(host.input_active_pub()); @@ -1701,7 +1701,7 @@ fn chat_model_picker_open_owns_keyboard_search() { "g" ); assert!(host.apply_escape()); - assert!(!host.editor_state().editor_ui.chat_model_picker_open); + assert!(!host.editor_state().editor_ui.chat_model_picker.open); } #[test] @@ -1779,7 +1779,7 @@ fn select_all_in_chat_model_picker_replaces_next_typed_text() { let mut host = WidgetHostNative::new(); { let ui = &mut host.editor_state_mut().editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("gpt"); } diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index e53f04c09..95ae1c8c7 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -350,7 +350,7 @@ impl WidgetHostNative { self.mark_dirty(); return true; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { return self.apply_chat_model_picker_text(c); } if self.editor_state.editor_ui.component_browser_open && !c.is_control() { @@ -663,7 +663,7 @@ impl WidgetHostNative { } return false; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { return self.apply_chat_model_picker_backspace(); } if self.editor_state.editor_ui.component_browser_open { @@ -860,7 +860,7 @@ impl WidgetHostNative { if self.editor_state.ui.property_focus.is_some() || self.editor_state.editor_ui.effect_param_focus.is_some() || self.editor_state.editor_ui.icon_picker_open - || self.editor_state.editor_ui.chat_model_picker_open + || self.editor_state.editor_ui.chat_model_picker.open || self.editor_state.editor_ui.component_browser_open || self.editor_state.chat.focused { @@ -1502,14 +1502,8 @@ impl WidgetHostNative { self.mark_dirty(); return true; } - if self.editor_state.editor_ui.chat_model_picker_open { - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); - self.editor_state.editor_ui.chat_model_picker_hover = None; + if self.editor_state.editor_ui.chat_model_picker.open { + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/scroll.rs b/crates/op-host-native/src/widget_host/scroll.rs index 29c9d3e12..5ed734583 100644 --- a/crates/op-host-native/src/widget_host/scroll.rs +++ b/crates/op-host-native/src/widget_host/scroll.rs @@ -296,7 +296,7 @@ impl WidgetHostNative { } // Open chat model-picker — a wheel over its dropdown scrolls // the model list instead of zooming the canvas. - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { use op_editor_ui::widgets::ai_chat_model_picker::max_picker_scroll; use op_editor_ui::widgets::AIChatPlaceholder; let picker = self @@ -311,9 +311,10 @@ impl WidgetHostNative { &self.editor_state.chat.available_models, self.editor_state.editor_ui.chat_model_picker_input.text(), ); - let next = (self.editor_state.editor_ui.chat_model_picker_scroll - delta_y) + let next = (self.editor_state.editor_ui.chat_model_picker.scroll.offset + - delta_y) .clamp(0.0, max); - self.editor_state.editor_ui.chat_model_picker_scroll = next; + self.editor_state.editor_ui.chat_model_picker.scroll.offset = next; self.mark_dirty(); return true; } @@ -439,7 +440,7 @@ impl WidgetHostNative { } // Open chat model-picker owns trackpad scroll over its // dropdown, same as the wheel path. - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { use op_editor_ui::widgets::ai_chat_model_picker::max_picker_scroll; use op_editor_ui::widgets::AIChatPlaceholder; let picker = self @@ -454,9 +455,9 @@ impl WidgetHostNative { &self.editor_state.chat.available_models, self.editor_state.editor_ui.chat_model_picker_input.text(), ); - let next = - (self.editor_state.editor_ui.chat_model_picker_scroll - dy).clamp(0.0, max); - self.editor_state.editor_ui.chat_model_picker_scroll = next; + let next = (self.editor_state.editor_ui.chat_model_picker.scroll.offset - dy) + .clamp(0.0, max); + self.editor_state.editor_ui.chat_model_picker.scroll.offset = next; self.mark_dirty(); return true; } diff --git a/crates/op-host-native/src/widget_host/shortcuts.rs b/crates/op-host-native/src/widget_host/shortcuts.rs index bd6de6275..d85f19983 100644 --- a/crates/op-host-native/src/widget_host/shortcuts.rs +++ b/crates/op-host-native/src/widget_host/shortcuts.rs @@ -210,7 +210,7 @@ impl WidgetHostNative { self.mark_dirty(); return true; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { let ui = &mut self.editor_state.editor_ui; ui.chat_model_picker_input.select_all(); ui.chat_model_picker_input.touch(self.now_ms); diff --git a/crates/op-host-web/src/a11y.rs b/crates/op-host-web/src/a11y.rs index 5ce1f3b3e..28f768871 100644 --- a/crates/op-host-web/src/a11y.rs +++ b/crates/op-host-web/src/a11y.rs @@ -542,7 +542,7 @@ fn focused_input(state: &EditorState) -> Option<(String, String, String)> { ui.icon_picker_search.clone(), )); } - if ui.chat_model_picker_open { + if ui.chat_model_picker.open { return Some(( "model-search".to_string(), "Model search".to_string(), diff --git a/crates/op-host-web/src/widget_host/blur_inputs.rs b/crates/op-host-web/src/widget_host/blur_inputs.rs index 446c501bd..3a406afc0 100644 --- a/crates/op-host-web/src/widget_host/blur_inputs.rs +++ b/crates/op-host-web/src/widget_host/blur_inputs.rs @@ -18,7 +18,7 @@ impl WidgetHost { || eui.variables_variant_rename_value.is_some() || self.variables_search_active() || eui.agent_settings.focus.is_some() - || eui.chat_model_picker_open + || eui.chat_model_picker.open || self.editor_state.chat.focused } @@ -50,11 +50,7 @@ impl WidgetHost { let _ = self.editor_state.editor_ui.git_panel.defocus_text_inputs(); // Chat input + its model-picker popover (same field set as // the picker-close branch in `apply_escape`). - let eui = &mut self.editor_state.editor_ui; - eui.chat_model_picker_open = false; - eui.chat_model_picker_scroll = 0.0; - eui.chat_model_picker_input.set_text(""); - eui.chat_model_picker_hover = None; + self.editor_state.editor_ui.close_chat_model_picker(); self.editor_state.chat.blur_input(self.now_ms); if was_focused { self.mark_dirty(); diff --git a/crates/op-host-web/src/widget_host/blur_inputs_tests.rs b/crates/op-host-web/src/widget_host/blur_inputs_tests.rs index 2de27d15e..a2b9964a5 100644 --- a/crates/op-host-web/src/widget_host/blur_inputs_tests.rs +++ b/crates/op-host-web/src/widget_host/blur_inputs_tests.rs @@ -12,7 +12,7 @@ const VH: f32 = 800.0; fn top_bar_gap_press_blurs_chat_and_model_picker() { let mut host = WidgetHost::new(); host.editor_state.chat.focused = true; - host.editor_state.editor_ui.chat_model_picker_open = true; + host.editor_state.editor_ui.chat_model_picker.open = true; host.editor_state .editor_ui .chat_model_picker_input @@ -26,7 +26,7 @@ fn top_bar_gap_press_blurs_chat_and_model_picker() { !host.editor_state.chat.focused, "top-bar gap press must blur the chat input" ); - assert!(!host.editor_state.editor_ui.chat_model_picker_open); + assert!(!host.editor_state.editor_ui.chat_model_picker.open); assert!(host .editor_state .editor_ui diff --git a/crates/op-host-web/src/widget_host/chat_model_picker_caret.rs b/crates/op-host-web/src/widget_host/chat_model_picker_caret.rs index 66d3ebc13..1b9cccb03 100644 --- a/crates/op-host-web/src/widget_host/chat_model_picker_caret.rs +++ b/crates/op-host-web/src/widget_host/chat_model_picker_caret.rs @@ -2,34 +2,36 @@ use super::WidgetHost; impl WidgetHost { pub(in crate::widget_host) fn apply_chat_model_picker_text(&mut self, c: char) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open || c.is_control() { + if !self.editor_state.editor_ui.chat_model_picker.open || c.is_control() { return false; } let ui = &mut self.editor_state.editor_ui; let mut s = [0u8; 4]; ui.chat_model_picker_input .insert_str(c.encode_utf8(&mut s), self.now_ms); - ui.chat_model_picker_scroll = 0.0; - ui.chat_model_picker_hover = None; + ui.chat_model_picker.scroll.offset = 0.0; + ui.chat_model_picker.hover = None; + ui.chat_model_picker.pressed = None; self.mark_dirty(); true } pub(in crate::widget_host) fn apply_chat_model_picker_backspace(&mut self) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open { + if !self.editor_state.editor_ui.chat_model_picker.open { return false; } let ui = &mut self.editor_state.editor_ui; ui.chat_model_picker_input.backspace(self.now_ms); ui.chat_model_picker_input.touch(self.now_ms); - ui.chat_model_picker_scroll = 0.0; - ui.chat_model_picker_hover = None; + ui.chat_model_picker.scroll.offset = 0.0; + ui.chat_model_picker.hover = None; + ui.chat_model_picker.pressed = None; self.mark_dirty(); true } pub fn apply_chat_model_picker_caret(&mut self, forward: bool) -> bool { - if !self.editor_state.editor_ui.chat_model_picker_open { + if !self.editor_state.editor_ui.chat_model_picker.open { return false; } let ui = &mut self.editor_state.editor_ui; diff --git a/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs b/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs index 9ebf95769..ca0a75902 100644 --- a/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs +++ b/crates/op-host-web/src/widget_host/chat_model_picker_caret_tests.rs @@ -6,7 +6,7 @@ fn chat_model_picker_arrows_move_caret_for_insert_and_backspace() { let mut host = WidgetHost::new(); { let ui = &mut host.editor_state.editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("abcd"); } @@ -44,10 +44,10 @@ fn chat_model_picker_clear_button_empties_search() { host.set_now_ms(456); { let ui = &mut host.editor_state.editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("231"); - ui.chat_model_picker_scroll = 10.0; - ui.chat_model_picker_hover = Some(0); + ui.chat_model_picker.scroll.offset = 10.0; + ui.chat_model_picker.hover = Some(0); } let chat_rect = host.ai_chat_rect(1200.0, 800.0).unwrap(); let panel = AIChatPlaceholder::from_editor_at(&host.editor_state, 456); @@ -60,8 +60,8 @@ fn chat_model_picker_clear_button_empties_search() { let ui = &host.editor_state.editor_ui; assert!(ui.chat_model_picker_input.text().is_empty()); assert_eq!(ui.chat_model_picker_input.caret(), 0); - assert_eq!(ui.chat_model_picker_scroll, 0.0); - assert_eq!(ui.chat_model_picker_hover, None); - assert!(ui.chat_model_picker_open); + assert_eq!(ui.chat_model_picker.scroll.offset, 0.0); + assert_eq!(ui.chat_model_picker.hover, None); + assert!(ui.chat_model_picker.open); assert_eq!(ui.chat_model_picker_input.next_blink_flip_ms(456), 956); } diff --git a/crates/op-host-web/src/widget_host/click.rs b/crates/op-host-web/src/widget_host/click.rs index dd58e0de0..1be3220a4 100644 --- a/crates/op-host-web/src/widget_host/click.rs +++ b/crates/op-host-web/src/widget_host/click.rs @@ -76,35 +76,18 @@ impl WidgetHost { AIChatHit::ToggleMaximize => { self.editor_state.chat.maximized = !self.editor_state.chat.maximized; self.editor_state.chat.collapsed = false; - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } AIChatHit::NewChat => { self.editor_state.chat.new_chat(); - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); - self.editor_state.editor_ui.chat_model_picker_hover = None; + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } AIChatHit::ToggleModelPicker => { - let opening = !self.editor_state.editor_ui.chat_model_picker_open; - self.editor_state.editor_ui.chat_model_picker_open = opening; - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); - self.editor_state.editor_ui.chat_model_picker_hover = None; + let opening = self.editor_state.editor_ui.toggle_chat_model_picker(); if opening { self.editor_state .editor_ui @@ -127,8 +110,9 @@ impl WidgetHost { .editor_ui .chat_model_picker_input .set_text(""); - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state.editor_ui.chat_model_picker_hover = None; + self.editor_state.editor_ui.chat_model_picker.scroll.offset = 0.0; + self.editor_state.editor_ui.chat_model_picker.hover = None; + self.editor_state.editor_ui.chat_model_picker.pressed = None; self.editor_state .editor_ui .chat_model_picker_input diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index c73020b89..69f3d5306 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -177,7 +177,7 @@ impl WidgetHost { if let Some(changed) = self.icon_picker_text(c) { return changed; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { return self.apply_chat_model_picker_text(c); } if let Some(changed) = self.component_browser_text(c) { @@ -327,7 +327,7 @@ impl WidgetHost { if let Some(changed) = self.icon_picker_backspace() { return changed; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { return self.apply_chat_model_picker_backspace(); } if let Some(changed) = self.component_browser_backspace() { @@ -457,7 +457,7 @@ impl WidgetHost { .editor_ui .variables_variant_rename_value .is_some() - || self.editor_state.editor_ui.chat_model_picker_open + || self.editor_state.editor_ui.chat_model_picker.open || self.editor_state.chat.focused { if self.editor_state.ui.property_focus.is_some() @@ -730,14 +730,8 @@ impl WidgetHost { self.mark_dirty(); return true; } - if self.editor_state.editor_ui.chat_model_picker_open { - self.editor_state.editor_ui.chat_model_picker_open = false; - self.editor_state.editor_ui.chat_model_picker_scroll = 0.0; - self.editor_state - .editor_ui - .chat_model_picker_input - .set_text(""); - self.editor_state.editor_ui.chat_model_picker_hover = None; + if self.editor_state.editor_ui.chat_model_picker.open { + self.editor_state.editor_ui.close_chat_model_picker(); self.mark_dirty(); return true; } 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 61486c708..a38c172e7 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 @@ -145,7 +145,7 @@ impl WidgetHost { self.mark_dirty(); return true; } - if self.editor_state.editor_ui.chat_model_picker_open { + if self.editor_state.editor_ui.chat_model_picker.open { let ui = &mut self.editor_state.editor_ui; ui.chat_model_picker_input.select_all(); ui.chat_model_picker_input.touch(self.now_ms); diff --git a/crates/op-host-web/src/widget_host/keyboard_text_inputs.rs b/crates/op-host-web/src/widget_host/keyboard_text_inputs.rs index 7d6562f06..2d0014085 100644 --- a/crates/op-host-web/src/widget_host/keyboard_text_inputs.rs +++ b/crates/op-host-web/src/widget_host/keyboard_text_inputs.rs @@ -26,7 +26,7 @@ impl WidgetHost { || self.variables_search_active() || self.editor_state.editor_ui.agent_settings.focus.is_some() || self.editor_state.editor_ui.icon_picker_open - || self.editor_state.editor_ui.chat_model_picker_open + || self.editor_state.editor_ui.chat_model_picker.open || self.editor_state.editor_ui.component_browser_open || self.editor_state.chat.focused } diff --git a/crates/op-host-web/src/widget_host/settings_caret_tests.rs b/crates/op-host-web/src/widget_host/settings_caret_tests.rs index 445177210..ce21505e2 100644 --- a/crates/op-host-web/src/widget_host/settings_caret_tests.rs +++ b/crates/op-host-web/src/widget_host/settings_caret_tests.rs @@ -54,7 +54,7 @@ fn select_all_in_chat_model_picker_replaces_next_typed_text() { let mut host = WidgetHost::new(); { let ui = &mut host.editor_state.editor_ui; - ui.chat_model_picker_open = true; + ui.chat_model_picker.open = true; ui.chat_model_picker_input.set_text("gpt"); }