refactor(editor): migrate model picker to shared select

This commit is contained in:
Kayshen-X 2026-06-14 00:56:39 +08:00
parent 8b9e0a07a7
commit f6628e2b28
32 changed files with 258 additions and 204 deletions

View file

@ -918,18 +918,11 @@ pub struct EditorUiState {
pub icon_picker_load_more_request: Option<crate::icon_picker_state::IconifyLoadMoreRequest>,
// --- 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<usize>,
/// 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<usize> {
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<usize>,
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);

View file

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

View file

@ -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<usize>,
/// 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<Rect> {
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,

View file

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

View file

@ -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<op_editor_core::ChatFooterButton> {
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);

View file

@ -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<Rect> {
.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,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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