fix(settings): open image provider menu

This commit is contained in:
Fini 2026-06-01 06:10:37 +08:00
parent 0b72d14b8a
commit 3d1c0b6ae1
9 changed files with 330 additions and 192 deletions

View file

@ -437,6 +437,7 @@ pub struct AgentSettings {
pub openverse_client_secret: String,
pub image_gen_profiles: Vec<ImageGenProfile>,
pub active_image_gen_profile_id: Option<String>,
pub image_gen_provider_menu_open: Option<usize>,
pub next_image_gen_profile_id: u64,
/// Whether the desktop host should check GitHub releases on
/// startup. Manual "Check for Updates" stays available.
@ -477,6 +478,7 @@ impl Default for AgentSettings {
openverse_client_secret: String::new(),
image_gen_profiles: Vec::new(),
active_image_gen_profile_id: None,
image_gen_provider_menu_open: None,
next_image_gen_profile_id: 1,
auto_update_enabled: true,
focus: None,
@ -737,6 +739,7 @@ impl AgentSettings {
if self.image_gen_profiles.len() == before {
return false;
}
self.image_gen_provider_menu_open = None;
if self.active_image_gen_profile_id.as_deref() == Some(id) {
self.active_image_gen_profile_id =
self.image_gen_profiles.first().map(|p| p.id.clone());

View file

@ -14,6 +14,7 @@ fn default_settings_are_quiescent() {
assert!(s.acp_agent_draft.is_none());
assert!(s.image_gen_profiles.is_empty());
assert!(s.active_image_gen_profile_id.is_none());
assert!(s.image_gen_provider_menu_open.is_none());
assert!(!s.images_advanced_open);
assert!(s.openverse_client_id.is_empty());
assert!(s.openverse_client_secret.is_empty());

View file

@ -1,18 +1,17 @@
//! Images tab of the settings modal.
use crate::theme::Theme;
use crate::widgets::agent_settings_caret::{
caret_x_for_text, paint_caret, settings_caret_for_focus,
};
use crate::widgets::agent_settings_i18n::t as t_settings;
use crate::widgets::agent_settings_images_parts::{
ellipsize, paint_search_input_row, rect_contains,
ellipsize, paint_profile_field, paint_profile_test_button, paint_provider_field,
paint_provider_menu, paint_search_input_row, rect_contains,
};
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::agent_settings::{
AgentSettings, ImageGenField, ImageGenProfile, ImageSearchField, SettingsFocus,
AgentSettings, ImageGenField, ImageGenProfile, ImageGenProvider, ImageSearchField,
SettingsFocus,
};
use op_editor_core::editor_ui_state::EditorUiState;
@ -38,6 +37,7 @@ const PROFILE_FORM_TOP: f32 = 40.0;
const PROFILE_FIELD_H: f32 = 24.0;
const PROFILE_TEST_BTN_W: f32 = 56.0;
const PROFILE_TEST_GAP: f32 = 8.0;
const PROVIDER_OPTION_H: f32 = 24.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ImagesHit {
@ -49,8 +49,15 @@ pub enum ImagesHit {
SetActiveGenConfig(usize),
RemoveGenConfig(usize),
TestGenConfig(usize),
CycleGenProvider(usize),
FocusGenConfig { index: usize, field: ImageGenField },
ToggleGenProviderMenu(usize),
SelectGenProvider {
index: usize,
provider: ImageGenProvider,
},
FocusGenConfig {
index: usize,
field: ImageGenField,
},
None,
}
@ -235,6 +242,17 @@ fn profile_provider_rect(row: Rect) -> Rect {
profile_field_rect(row, 1)
}
fn profile_provider_option_rect(row: Rect, option_index: usize) -> Rect {
let provider = profile_provider_rect(row);
Rect {
origin: Point2D::new(
provider.origin.x,
provider.origin.y + provider.size.y + option_index as f32 * PROVIDER_OPTION_H,
),
size: Point2D::new(provider.size.x, PROVIDER_OPTION_H),
}
}
fn profile_field_index(field: ImageGenField) -> usize {
match field {
ImageGenField::Name => 0,
@ -266,6 +284,16 @@ pub fn hit_test(content: Rect, settings: &AgentSettings, scrolled: Point2D) -> I
}
for (index, profile) in settings.image_gen_profiles.iter().enumerate() {
let row = profile_row_rect(content, settings, index);
if settings.image_gen_provider_menu_open == Some(index) {
for (option_index, provider) in ImageGenProvider::ALL.iter().enumerate() {
if rect_contains(profile_provider_option_rect(row, option_index), scrolled) {
return ImagesHit::SelectGenProvider {
index,
provider: *provider,
};
}
}
}
if rect_contains(profile_active_rect(row), scrolled) {
return ImagesHit::SetActiveGenConfig(index);
}
@ -283,7 +311,7 @@ pub fn hit_test(content: Rect, settings: &AgentSettings, scrolled: Point2D) -> I
return ImagesHit::None;
}
if rect_contains(profile_provider_rect(row), scrolled) {
return ImagesHit::CycleGenProvider(index);
return ImagesHit::ToggleGenProviderMenu(index);
}
for field in image_gen_fields() {
if rect_contains(profile_input_rect(row, field), scrolled) {
@ -628,168 +656,28 @@ fn paint_profile_row(
if editing {
for field in image_gen_fields() {
paint_profile_field(cx, theme, settings, ui, profile, index, field, row, now_ms);
paint_profile_field(
cx,
theme,
settings,
ui,
profile,
index,
field,
profile_input_rect(row, field),
row.origin.x + 12.0,
now_ms,
);
}
paint_profile_test_button(cx, theme, ui, profile, row);
paint_provider_field(cx, theme, profile, row);
}
}
#[allow(clippy::too_many_arguments)]
fn paint_profile_field(
cx: &mut PaintCx<'_>,
theme: &Theme,
settings: &AgentSettings,
ui: &EditorUiState,
profile: &ImageGenProfile,
index: usize,
field: ImageGenField,
row: Rect,
now_ms: u64,
) {
let focus = SettingsFocus::ImageGenProfile { index, field };
let focused = settings.focus == Some(focus);
let value = if focused {
ui.settings_input_draft.as_str()
} else {
match field {
ImageGenField::Name => profile.name.as_str(),
ImageGenField::ApiKey if !profile.api_key.is_empty() => "********",
ImageGenField::ApiKey => "",
ImageGenField::Model => profile.model.as_str(),
ImageGenField::BaseUrl => profile.base_url.as_deref().unwrap_or(""),
paint_profile_test_button(cx, theme, ui, profile, profile_test_btn_rect(row));
let provider_rect = profile_provider_rect(row);
paint_provider_field(cx, theme, profile, provider_rect, row.origin.x + 12.0);
if settings.image_gen_provider_menu_open == Some(index) {
paint_provider_menu(cx, theme, provider_rect, profile.provider);
}
};
let label = match field {
ImageGenField::Name => "Name",
ImageGenField::ApiKey => "API Key",
ImageGenField::Model => "Model",
ImageGenField::BaseUrl => "Base URL",
};
let input = profile_input_rect(row, field);
let label_lay = TextLayout::single_run(
label,
"system-ui",
11.0,
to_jian(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label_lay,
Point2D::new(row.origin.x + 12.0, input.origin.y + 16.0),
);
cx.backend.fill_round_rect(
input,
6.0,
if focused {
theme.background
} else {
theme.card
},
);
cx.backend.stroke_round_rect(
input,
6.0,
if focused { theme.primary } else { theme.border },
1.0,
);
let clipped = ellipsize(cx, value, input.size.x - 12.0, 11.0);
let text_x = input.origin.x + 6.0;
let value_lay = TextLayout::single_run(
&clipped,
"system-ui",
11.0,
to_jian(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&value_lay, Point2D::new(text_x, input.origin.y + 16.0));
if focused {
let caret = settings_caret_for_focus(ui, focus);
let caret_x = caret_x_for_text(
cx,
value,
caret,
text_x,
input.origin.x + input.size.x - 8.0,
11.0,
);
let caret_y = input.origin.y + 4.5;
let anchor = ui.settings_input_caret_anchor_ms;
paint_caret(cx, theme, now_ms, anchor, caret_x, caret_y);
}
}
fn paint_profile_test_button(
cx: &mut PaintCx<'_>,
theme: &Theme,
ui: &EditorUiState,
profile: &ImageGenProfile,
row: Rect,
) {
let btn = profile_test_btn_rect(row);
let enabled = !profile.api_key.trim().is_empty();
cx.backend.fill_round_rect(btn, 6.0, theme.muted);
cx.backend.stroke_round_rect(btn, 6.0, theme.border, 1.0);
let label = t_settings(ui, "settings.images.test");
let label_w = cx.backend.measure_text(label, 11.0);
let layout = TextLayout::single_run(
label,
"system-ui",
11.0,
to_jian(if enabled {
theme.foreground
} else {
theme.muted_foreground
}),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&layout,
Point2D::new(
btn.origin.x + (btn.size.x - label_w) / 2.0,
btn.origin.y + btn.size.y / 2.0 + 4.0,
),
);
}
fn paint_provider_field(cx: &mut PaintCx<'_>, theme: &Theme, profile: &ImageGenProfile, row: Rect) {
let input = profile_provider_rect(row);
let label_lay = TextLayout::single_run(
"Provider",
"system-ui",
11.0,
to_jian(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label_lay,
Point2D::new(row.origin.x + 12.0, input.origin.y + 16.0),
);
cx.backend.fill_round_rect(input, 6.0, theme.card);
cx.backend.stroke_round_rect(input, 6.0, theme.border, 1.0);
let value = ellipsize(cx, profile.provider.label(), input.size.x - 28.0, 11.0);
let value_lay = TextLayout::single_run(
&value,
"system-ui",
11.0,
to_jian(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&value_lay,
Point2D::new(input.origin.x + 6.0, input.origin.y + 16.0),
);
draw_icon(
cx.backend,
Icon::ChevronDown,
Point2D::new(input.origin.x + input.size.x - 18.0, input.origin.y + 5.0),
14.0,
theme.muted_foreground,
1.5,
);
}
fn image_gen_fields() -> [ImageGenField; 4] {
use ImageGenField::*;
[Name, ApiKey, Model, BaseUrl]

View file

@ -4,13 +4,18 @@ use crate::theme::Theme;
use crate::widgets::agent_settings_caret::{
caret_x_for_text, paint_caret, settings_caret_for_focus,
};
use crate::widgets::icons::{draw_icon, Icon};
use crate::widgets::PaintCx;
use crate::{Color, Point2D, Rect, TextLayout};
use op_editor_core::agent_settings::{AgentSettings, ImageSearchField, SettingsFocus};
use op_editor_core::agent_settings::{
AgentSettings, ImageGenField, ImageGenProfile, ImageGenProvider, ImageSearchField,
SettingsFocus,
};
use op_editor_core::editor_ui_state::EditorUiState;
const ROW_H: f32 = 36.0;
const LABEL_W: f32 = 110.0;
const PROVIDER_OPTION_H: f32 = 24.0;
#[allow(clippy::too_many_arguments)]
pub(super) fn paint_search_input_row(
@ -98,6 +103,205 @@ pub(super) fn paint_search_input_row(
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn paint_profile_field(
cx: &mut PaintCx<'_>,
theme: &Theme,
settings: &AgentSettings,
ui: &EditorUiState,
profile: &ImageGenProfile,
index: usize,
field: ImageGenField,
input: Rect,
label_x: f32,
now_ms: u64,
) {
let focus = SettingsFocus::ImageGenProfile { index, field };
let focused = settings.focus == Some(focus);
let value = if focused {
ui.settings_input_draft.as_str()
} else {
match field {
ImageGenField::Name => profile.name.as_str(),
ImageGenField::ApiKey if !profile.api_key.is_empty() => "********",
ImageGenField::ApiKey => "",
ImageGenField::Model => profile.model.as_str(),
ImageGenField::BaseUrl => profile.base_url.as_deref().unwrap_or(""),
}
};
let label = match field {
ImageGenField::Name => "Name",
ImageGenField::ApiKey => "API Key",
ImageGenField::Model => "Model",
ImageGenField::BaseUrl => "Base URL",
};
let label_lay = TextLayout::single_run(
label,
"system-ui",
11.0,
to_jian(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label_lay, Point2D::new(label_x, input.origin.y + 16.0));
cx.backend.fill_round_rect(
input,
6.0,
if focused {
theme.background
} else {
theme.card
},
);
cx.backend.stroke_round_rect(
input,
6.0,
if focused { theme.primary } else { theme.border },
1.0,
);
let clipped = ellipsize(cx, value, input.size.x - 12.0, 11.0);
let text_x = input.origin.x + 6.0;
let value_lay = TextLayout::single_run(
&clipped,
"system-ui",
11.0,
to_jian(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&value_lay, Point2D::new(text_x, input.origin.y + 16.0));
if focused {
let caret = settings_caret_for_focus(ui, focus);
let caret_x = caret_x_for_text(
cx,
value,
caret,
text_x,
input.origin.x + input.size.x - 8.0,
11.0,
);
let caret_y = input.origin.y + 4.5;
let anchor = ui.settings_input_caret_anchor_ms;
paint_caret(cx, theme, now_ms, anchor, caret_x, caret_y);
}
}
pub(super) fn paint_profile_test_button(
cx: &mut PaintCx<'_>,
theme: &Theme,
ui: &EditorUiState,
profile: &ImageGenProfile,
btn: Rect,
) {
let enabled = !profile.api_key.trim().is_empty();
cx.backend.fill_round_rect(btn, 6.0, theme.muted);
cx.backend.stroke_round_rect(btn, 6.0, theme.border, 1.0);
let label = crate::widgets::agent_settings_i18n::t(ui, "settings.images.test");
let label_w = cx.backend.measure_text(label, 11.0);
let layout = TextLayout::single_run(
label,
"system-ui",
11.0,
to_jian(if enabled {
theme.foreground
} else {
theme.muted_foreground
}),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&layout,
Point2D::new(
btn.origin.x + (btn.size.x - label_w) / 2.0,
btn.origin.y + btn.size.y / 2.0 + 4.0,
),
);
}
pub(super) fn paint_provider_field(
cx: &mut PaintCx<'_>,
theme: &Theme,
profile: &ImageGenProfile,
input: Rect,
label_x: f32,
) {
let label_lay = TextLayout::single_run(
"Provider",
"system-ui",
11.0,
to_jian(theme.muted_foreground),
Point2D::new(0.0, 0.0),
);
cx.backend
.draw_text(&label_lay, Point2D::new(label_x, input.origin.y + 16.0));
cx.backend.fill_round_rect(input, 6.0, theme.card);
cx.backend.stroke_round_rect(input, 6.0, theme.border, 1.0);
let value = ellipsize(cx, profile.provider.label(), input.size.x - 28.0, 11.0);
let value_lay = TextLayout::single_run(
&value,
"system-ui",
11.0,
to_jian(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&value_lay,
Point2D::new(input.origin.x + 6.0, input.origin.y + 16.0),
);
draw_icon(
cx.backend,
Icon::ChevronDown,
Point2D::new(input.origin.x + input.size.x - 18.0, input.origin.y + 5.0),
14.0,
theme.muted_foreground,
1.5,
);
}
pub(super) fn paint_provider_menu(
cx: &mut PaintCx<'_>,
theme: &Theme,
input: Rect,
selected: ImageGenProvider,
) {
let menu = Rect {
origin: Point2D::new(input.origin.x, input.origin.y + input.size.y),
size: Point2D::new(
input.size.x,
PROVIDER_OPTION_H * ImageGenProvider::ALL.len() as f32,
),
};
cx.backend.fill_round_rect(menu, 6.0, theme.card);
cx.backend.stroke_round_rect(menu, 6.0, theme.border, 1.0);
for (index, provider) in ImageGenProvider::ALL.iter().enumerate() {
let option_y = menu.origin.y + index as f32 * PROVIDER_OPTION_H;
if *provider == selected {
cx.backend.fill_round_rect(
Rect {
origin: Point2D::new(menu.origin.x + 2.0, option_y + 2.0),
size: Point2D::new(menu.size.x - 4.0, PROVIDER_OPTION_H - 4.0),
},
4.0,
theme.muted,
);
}
let label = TextLayout::single_run(
provider.label(),
"system-ui",
11.0,
to_jian(theme.foreground),
Point2D::new(0.0, 0.0),
);
cx.backend.draw_text(
&label,
Point2D::new(
menu.origin.x + 8.0,
option_y + PROVIDER_OPTION_H / 2.0 + 4.0,
),
);
}
}
pub(super) fn ellipsize(cx: &mut PaintCx<'_>, value: &str, max_w: f32, size: f32) -> String {
if cx.backend.measure_text(value, size) <= max_w {
return value.to_string();

View file

@ -88,7 +88,11 @@ pub enum AgentSettingsHit {
SetActiveGenConfig(usize),
RemoveGenConfig(usize),
TestGenConfig(usize),
CycleGenProvider(usize),
ToggleGenProviderMenu(usize),
SelectGenProvider {
index: usize,
provider: op_editor_core::agent_settings::ImageGenProvider,
},
FocusGenConfig {
index: usize,
field: ImageGenField,
@ -257,8 +261,11 @@ impl<'a> AgentSettingsPanel<'a> {
ImagesHit::TestGenConfig(index) => {
return AgentSettingsHit::TestGenConfig(index);
}
ImagesHit::CycleGenProvider(index) => {
return AgentSettingsHit::CycleGenProvider(index);
ImagesHit::ToggleGenProviderMenu(index) => {
return AgentSettingsHit::ToggleGenProviderMenu(index);
}
ImagesHit::SelectGenProvider { index, provider } => {
return AgentSettingsHit::SelectGenProvider { index, provider };
}
ImagesHit::FocusGenConfig { index, field } => {
return AgentSettingsHit::FocusGenConfig { index, field };

View file

@ -891,6 +891,6 @@ fn images_tab_expanded_profile_provider_row_is_clickable() {
rect,
crate::Point2D::new(content_x + 110.0 + 20.0, provider_y + 12.0)
),
AgentSettingsHit::CycleGenProvider(0)
AgentSettingsHit::ToggleGenProviderMenu(0)
);
}

View file

@ -612,7 +612,7 @@ fn image_generation_profile_focus_accepts_text_and_commits() {
}
#[test]
fn image_generation_provider_click_cycles_provider_and_clears_model() {
fn image_generation_provider_click_opens_menu_without_changing_profile() {
let mut host = WidgetHostNative::new();
host.editor_state_mut().editor_ui.agent_settings.tab = AgentSettingsTab::Images;
host.editor_state_mut()
@ -649,9 +649,28 @@ fn image_generation_provider_click_cycles_provider_and_clears_model() {
.editor_ui
.agent_settings
.image_gen_profiles[0];
assert_eq!(profile.provider, ImageGenProvider::OpenAi);
assert_eq!(profile.model, "dall-e-3");
assert!(host.editor_state().editor_ui.agent_settings.focus.is_none());
assert_eq!(
host.editor_state()
.editor_ui
.agent_settings
.image_gen_provider_menu_open,
Some(0)
);
assert!(host.dispatch_agent_settings_press(
content_x + 110.0 + 20.0,
provider_y + 60.0,
1200.0,
800.0
));
let settings = &host.editor_state().editor_ui.agent_settings;
let profile = &settings.image_gen_profiles[0];
assert_eq!(profile.provider, ImageGenProvider::Gemini);
assert!(profile.model.is_empty());
assert!(host.editor_state().editor_ui.agent_settings.focus.is_none());
assert!(settings.image_gen_provider_menu_open.is_none());
}
#[test]

View file

@ -197,21 +197,29 @@ impl WidgetHostNative {
);
}
}
AgentSettingsHit::CycleGenProvider(index) => {
AgentSettingsHit::ToggleGenProviderMenu(index) => {
self.commit_settings_focus_if_any();
if let Some(profile) = self
.editor_state
.editor_ui
.agent_settings
.image_gen_profiles
.get_mut(index)
{
profile.provider = profile.provider.next();
profile.model.clear();
let settings = &mut self.editor_state.editor_ui.agent_settings;
settings.image_gen_provider_menu_open =
(settings.image_gen_provider_menu_open != Some(index)).then_some(index);
}
AgentSettingsHit::SelectGenProvider { index, provider } => {
self.commit_settings_focus_if_any();
let settings = &mut self.editor_state.editor_ui.agent_settings;
if let Some(profile) = settings.image_gen_profiles.get_mut(index) {
if profile.provider != provider {
profile.provider = provider;
profile.model.clear();
}
}
settings.image_gen_provider_menu_open = None;
}
AgentSettingsHit::FocusGenConfig { index, field } => {
self.commit_settings_focus_if_any();
self.editor_state
.editor_ui
.agent_settings
.image_gen_provider_menu_open = None;
self.focus_image_gen_profile(index, field);
}
AgentSettingsHit::ToggleAutoUpdate => {

View file

@ -181,21 +181,29 @@ impl WidgetHost {
self.editor_state.editor_ui.settings_input_caret_anchor_ms = self.now_ms;
}
}
AgentSettingsHit::CycleGenProvider(index) => {
AgentSettingsHit::ToggleGenProviderMenu(index) => {
self.commit_settings_focus();
if let Some(profile) = self
.editor_state
.editor_ui
.agent_settings
.image_gen_profiles
.get_mut(index)
{
profile.provider = profile.provider.next();
profile.model.clear();
let settings = &mut self.editor_state.editor_ui.agent_settings;
settings.image_gen_provider_menu_open =
(settings.image_gen_provider_menu_open != Some(index)).then_some(index);
}
AgentSettingsHit::SelectGenProvider { index, provider } => {
self.commit_settings_focus();
let settings = &mut self.editor_state.editor_ui.agent_settings;
if let Some(profile) = settings.image_gen_profiles.get_mut(index) {
if profile.provider != provider {
profile.provider = provider;
profile.model.clear();
}
}
settings.image_gen_provider_menu_open = None;
}
AgentSettingsHit::FocusGenConfig { index, field } => {
self.commit_settings_focus();
self.editor_state
.editor_ui
.agent_settings
.image_gen_provider_menu_open = None;
self.focus_image_gen_profile(index, field);
}
AgentSettingsHit::ToggleAutoUpdate => {