From 9498e8d37dc5f4d13d860cfb13b063ed2de93d2d Mon Sep 17 00:00:00 2001 From: Fini Date: Sat, 30 May 2026 06:17:14 +0800 Subject: [PATCH] fix(ai): edit image generation profiles --- crates/op-editor-core/src/agent_settings.rs | 12 ++ crates/op-editor-core/src/lib.rs | 3 +- .../src/widgets/agent_settings_images.rs | 177 +++++++++++++++++- .../src/widgets/agent_settings_panel.rs | 9 +- .../src/widgets/agent_settings_panel_tests.rs | 38 +++- .../src/widget_host/agent_settings_tests.rs | 36 +++- .../src/widget_host/keyboard.rs | 3 + .../src/widget_host/press_helpers.rs | 55 +++++- .../src/widget_host/settings_dispatch.rs | 30 ++- .../op-host-web/src/widget_host/keyboard.rs | 1 + crates/op-host-web/src/widget_host/press.rs | 25 +-- 11 files changed, 355 insertions(+), 34 deletions(-) diff --git a/crates/op-editor-core/src/agent_settings.rs b/crates/op-editor-core/src/agent_settings.rs index 64b855e00..ad4cf0401 100644 --- a/crates/op-editor-core/src/agent_settings.rs +++ b/crates/op-editor-core/src/agent_settings.rs @@ -100,6 +100,14 @@ pub enum BuiltinAgentField { BaseUrl, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageGenField { + Name, + ApiKey, + Model, + BaseUrl, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum SettingsFocus { McpPort, @@ -107,6 +115,10 @@ pub enum SettingsFocus { index: usize, field: BuiltinAgentField, }, + ImageGenProfile { + index: usize, + field: ImageGenField, + }, } /// Built-in provider backend configured directly in OpenPencil. diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 0890bb64f..191a2dd33 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -70,7 +70,8 @@ mod tests_pages; pub use agent_settings::{ AgentSettings, AgentSettingsDrag, AgentSettingsTab, BuiltinAgentConfig, BuiltinAgentField, - BuiltinAgentKind, ImageGenProfile, ImageGenProvider, McpCli, McpServer, SettingsFocus, + BuiltinAgentKind, ImageGenField, ImageGenProfile, ImageGenProvider, McpCli, McpServer, + SettingsFocus, }; pub use align::AlignAction; pub use chat::{ diff --git a/crates/op-editor-ui/src/widgets/agent_settings_images.rs b/crates/op-editor-ui/src/widgets/agent_settings_images.rs index 60cb7c97a..8aea3f10f 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_images.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_images.rs @@ -5,7 +5,9 @@ use crate::widgets::agent_settings_i18n::t as t_settings; use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::PaintCx; use crate::{Color, Point2D, Rect, TextLayout}; -use op_editor_core::agent_settings::{AgentSettings, ImageGenProfile}; +use op_editor_core::agent_settings::{ + AgentSettings, ImageGenField, ImageGenProfile, SettingsFocus, +}; use op_editor_core::editor_ui_state::EditorUiState; const TITLE_H: f32 = 36.0; @@ -25,6 +27,8 @@ const PROFILE_ROW_H: f32 = 32.0; const PROFILE_ROW_GAP: f32 = 6.0; const ACTIVE_DOT: f32 = 14.0; const DELETE_W: f32 = 24.0; +const PROFILE_FORM_TOP: f32 = 40.0; +const PROFILE_FIELD_H: f32 = 24.0; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ImagesHit { @@ -33,6 +37,7 @@ pub enum ImagesHit { AddGenConfig, SetActiveGenConfig(usize), RemoveGenConfig(usize), + FocusGenConfig { index: usize, field: ImageGenField }, None, } @@ -60,11 +65,24 @@ fn profile_list_h(settings: &AgentSettings) -> f32 { if settings.image_gen_profiles.is_empty() { 80.0 } else { - settings.image_gen_profiles.len() as f32 * PROFILE_ROW_H + settings + .image_gen_profiles + .iter() + .enumerate() + .map(|(index, _)| profile_row_h(settings, index)) + .sum::() + settings.image_gen_profiles.len().saturating_sub(1) as f32 * PROFILE_ROW_GAP } } +fn profile_row_h(settings: &AgentSettings, index: usize) -> f32 { + if is_editing_profile(settings, index) { + PROFILE_ROW_H + 8.0 + 4.0 * ROW_H + } else { + PROFILE_ROW_H + } +} + fn advanced_toggle_rect(content: Rect) -> Rect { Rect { origin: Point2D::new(content.origin.x, content.origin.y + TITLE_H), @@ -103,12 +121,17 @@ fn add_btn_rect(content: Rect, settings: &AgentSettings) -> Rect { fn profile_row_rect(content: Rect, settings: &AgentSettings, index: usize) -> Rect { let top = image_gen_section_top(content, settings) + SECTION_TITLE_H; + let y = settings + .image_gen_profiles + .iter() + .enumerate() + .take(index) + .fold(top, |acc, (i, _)| { + acc + profile_row_h(settings, i) + PROFILE_ROW_GAP + }); Rect { - origin: Point2D::new( - content.origin.x, - top + index as f32 * (PROFILE_ROW_H + PROFILE_ROW_GAP), - ), - size: Point2D::new(content.size.x, PROFILE_ROW_H), + origin: Point2D::new(content.origin.x, y), + size: Point2D::new(content.size.x, profile_row_h(settings, index)), } } @@ -132,6 +155,16 @@ fn profile_remove_rect(row: Rect) -> Rect { } } +fn profile_field_rect(row: Rect, field_index: usize) -> Rect { + Rect { + origin: Point2D::new( + row.origin.x + LABEL_W, + row.origin.y + PROFILE_FORM_TOP + field_index as f32 * ROW_H, + ), + size: Point2D::new(row.size.x - LABEL_W - 12.0, PROFILE_FIELD_H), + } +} + pub fn hit_test(content: Rect, settings: &AgentSettings, scrolled: Point2D) -> ImagesHit { if rect_contains(advanced_toggle_rect(content), scrolled) { return ImagesHit::ToggleAdvanced; @@ -150,6 +183,19 @@ pub fn hit_test(content: Rect, settings: &AgentSettings, scrolled: Point2D) -> I if rect_contains(profile_remove_rect(row), scrolled) { return ImagesHit::RemoveGenConfig(index); } + if is_editing_profile(settings, index) { + for (field_index, field) in image_gen_fields().into_iter().enumerate() { + if rect_contains(profile_field_rect(row, field_index), scrolled) { + return ImagesHit::FocusGenConfig { index, field }; + } + } + } + if rect_contains(row, scrolled) { + return ImagesHit::FocusGenConfig { + index, + field: ImageGenField::Name, + }; + } } ImagesHit::None } @@ -365,7 +411,7 @@ pub(super) fn paint_images_tab( } else { for (index, profile) in settings.image_gen_profiles.iter().enumerate() { let row = profile_row_rect(content, settings, index); - paint_profile_row(cx, theme, settings, profile, row); + paint_profile_row(cx, theme, settings, ui, profile, index, row); } } } @@ -374,13 +420,21 @@ fn paint_profile_row( cx: &mut PaintCx<'_>, theme: &Theme, settings: &AgentSettings, + ui: &EditorUiState, profile: &ImageGenProfile, + index: usize, row: Rect, ) { let active = settings.active_image_gen_profile_id.as_deref() == Some(profile.id.as_str()); - if active { + let editing = is_editing_profile(settings, index); + if active || editing { cx.backend.fill_round_rect(row, 6.0, theme.muted); - cx.backend.stroke_round_rect(row, 6.0, theme.primary, 1.0); + cx.backend.stroke_round_rect( + row, + 6.0, + if active { theme.primary } else { theme.border }, + 1.0, + ); } else { cx.backend.stroke_round_rect(row, 6.0, theme.border, 1.0); } @@ -445,6 +499,93 @@ fn paint_profile_row( theme.muted_foreground, 1.5, ); + + if editing { + for (field_index, field) in image_gen_fields().into_iter().enumerate() { + paint_profile_field( + cx, + theme, + settings, + ui, + profile, + index, + field, + field_index, + 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, + field_index: usize, + row: Rect, +) { + let focused = settings.focus == Some(SettingsFocus::ImageGenProfile { index, field }); + 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 input = profile_field_rect(row, field_index); + 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 value = ellipsize(cx, value, input.size.x - 12.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), + ); } fn paint_input_row( @@ -495,6 +636,22 @@ fn ellipsize(cx: &mut PaintCx<'_>, value: &str, max_w: f32, size: f32) -> String format!("{out}...") } +fn image_gen_fields() -> [ImageGenField; 4] { + [ + ImageGenField::Name, + ImageGenField::ApiKey, + ImageGenField::Model, + ImageGenField::BaseUrl, + ] +} + +fn is_editing_profile(settings: &AgentSettings, index: usize) -> bool { + matches!( + settings.focus, + Some(SettingsFocus::ImageGenProfile { index: i, .. }) if i == index + ) +} + fn rect_contains(r: Rect, p: Point2D) -> bool { p.x >= r.origin.x && p.y >= r.origin.y diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs index 1463ffbee..b2c21d9bd 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel.rs @@ -14,7 +14,7 @@ use crate::widgets::icons::{draw_icon, Icon}; use crate::widgets::{PaintCx, Widget, WidgetId}; use crate::{Color, Point2D, Rect, TextLayout}; use op_editor_core::agent_settings::{ - AgentProvider, AgentSettings, AgentSettingsTab, BuiltinAgentField, McpCli, + AgentProvider, AgentSettings, AgentSettingsTab, BuiltinAgentField, ImageGenField, McpCli, }; use op_editor_core::editor_ui_state::EditorUiState; use op_editor_core::EditorState; @@ -58,6 +58,10 @@ pub enum AgentSettingsHit { AddGenConfig, SetActiveGenConfig(usize), RemoveGenConfig(usize), + FocusGenConfig { + index: usize, + field: ImageGenField, + }, ToggleAutoUpdate, FocusMcpPort, Outside, @@ -170,6 +174,9 @@ impl<'a> AgentSettingsPanel<'a> { ImagesHit::RemoveGenConfig(index) => { return AgentSettingsHit::RemoveGenConfig(index); } + ImagesHit::FocusGenConfig { index, field } => { + return AgentSettingsHit::FocusGenConfig { index, field }; + } ImagesHit::None => {} } } diff --git a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs index cb1e998fe..3eb66f32d 100644 --- a/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/agent_settings_panel_tests.rs @@ -1,5 +1,7 @@ use crate::widgets::agent_settings_panel::{AgentSettingsHit, AgentSettingsPanel}; -use op_editor_core::agent_settings::{AgentSettingsTab, BuiltinAgentField, SettingsFocus}; +use op_editor_core::agent_settings::{ + AgentSettingsTab, BuiltinAgentField, ImageGenField, SettingsFocus, +}; use op_editor_core::EditorState; #[test] @@ -222,3 +224,37 @@ fn images_tab_content_height_includes_profile_rows() { "configured image generation profiles should replace the TS empty state with rows" ); } + +#[test] +fn images_tab_expanded_profile_fields_are_focusable() { + let mut state = EditorState::default(); + state.editor_ui.agent_settings.tab = AgentSettingsTab::Images; + state.editor_ui.agent_settings.add_image_gen_profile(); + state.editor_ui.agent_settings.focus = Some(SettingsFocus::ImageGenProfile { + index: 0, + field: ImageGenField::Name, + }); + let panel = AgentSettingsPanel::for_editor(&state); + let rect = panel.rect(1200.0, 800.0); + let content_x = rect.origin.x + 200.0 + 24.0; + let content_y = rect.origin.y + 24.0; + + let gen_top = content_y + 36.0 + 24.0 + 28.0; + let row_y = gen_top + 36.0; + let api_field_y = row_y + 32.0 + 8.0 + 36.0; + + assert_eq!( + panel.hit_test( + rect, + crate::Point2D::new(content_x + 110.0 + 20.0, api_field_y + 12.0) + ), + AgentSettingsHit::FocusGenConfig { + index: 0, + field: ImageGenField::ApiKey, + } + ); + assert!( + panel.content_total_height() > 180.0, + "focused image profile should expand to show editable fields" + ); +} diff --git a/crates/op-host-native/src/widget_host/agent_settings_tests.rs b/crates/op-host-native/src/widget_host/agent_settings_tests.rs index 2165ea320..de3abb0cf 100644 --- a/crates/op-host-native/src/widget_host/agent_settings_tests.rs +++ b/crates/op-host-native/src/widget_host/agent_settings_tests.rs @@ -1,5 +1,7 @@ use super::WidgetHostNative; -use op_editor_core::agent_settings::{AgentSettingsTab, BuiltinAgentField, SettingsFocus}; +use op_editor_core::agent_settings::{ + AgentSettingsTab, BuiltinAgentField, ImageGenField, SettingsFocus, +}; use op_editor_ui::widgets::agent_settings_panel::AgentSettingsPanel; #[test] @@ -248,3 +250,35 @@ fn image_generation_profile_buttons_add_activate_and_remove() { Some(first.as_str()) ); } + +#[test] +fn image_generation_profile_focus_accepts_text_and_commits() { + let mut host = WidgetHostNative::new(); + host.editor_state_mut().editor_ui.agent_settings.tab = AgentSettingsTab::Images; + host.editor_state_mut() + .editor_ui + .agent_settings + .add_image_gen_profile(); + host.editor_state_mut().editor_ui.agent_settings.focus = Some(SettingsFocus::ImageGenProfile { + index: 0, + field: ImageGenField::Name, + }); + host.editor_state_mut() + .editor_ui + .settings_input_draft + .clear(); + + for c in "Hero Images".chars() { + assert!(host.apply_text(c)); + } + assert!(host.apply_send()); + + let settings = &host.editor_state().editor_ui.agent_settings; + assert_eq!(settings.image_gen_profiles[0].name, "Hero Images"); + assert!(settings.focus.is_none()); + assert!(host + .editor_state() + .editor_ui + .settings_input_draft + .is_empty()); +} diff --git a/crates/op-host-native/src/widget_host/keyboard.rs b/crates/op-host-native/src/widget_host/keyboard.rs index 2db1c1f36..78352b87c 100644 --- a/crates/op-host-native/src/widget_host/keyboard.rs +++ b/crates/op-host-native/src/widget_host/keyboard.rs @@ -23,6 +23,9 @@ impl WidgetHostNative { op_editor_core::agent_settings::SettingsFocus::BuiltinAgent { .. } => { !c.is_control() && draft.len() < 512 } + op_editor_core::agent_settings::SettingsFocus::ImageGenProfile { .. } => { + !c.is_control() && draft.len() < 512 + } }; if accepts { draft.push(c); diff --git a/crates/op-host-native/src/widget_host/press_helpers.rs b/crates/op-host-native/src/widget_host/press_helpers.rs index 88eba4920..c1e4367b9 100644 --- a/crates/op-host-native/src/widget_host/press_helpers.rs +++ b/crates/op-host-native/src/widget_host/press_helpers.rs @@ -135,6 +135,35 @@ impl WidgetHostNative { .remove_image_gen_profile(&id); } } + AgentSettingsHit::FocusGenConfig { index, field } => { + self.commit_settings_focus_if_any(); + if let Some(profile) = self + .editor_state + .editor_ui + .agent_settings + .image_gen_profiles + .get(index) + { + self.editor_state.editor_ui.settings_input_draft = match field { + op_editor_core::agent_settings::ImageGenField::Name => profile.name.clone(), + op_editor_core::agent_settings::ImageGenField::ApiKey => { + profile.api_key.clone() + } + op_editor_core::agent_settings::ImageGenField::Model => { + profile.model.clone() + } + op_editor_core::agent_settings::ImageGenField::BaseUrl => { + profile.base_url.clone().unwrap_or_default() + } + }; + self.editor_state.editor_ui.agent_settings.focus = Some( + op_editor_core::agent_settings::SettingsFocus::ImageGenProfile { + index, + field, + }, + ); + } + } AgentSettingsHit::ToggleAutoUpdate => { let v = &mut self .editor_state @@ -282,10 +311,34 @@ impl WidgetHostNative { | AgentSettingsHit::Inside => {} AgentSettingsHit::AddGenConfig => { self.commit_settings_focus_if_any(); - self.editor_state + let id = self + .editor_state .editor_ui .agent_settings .add_image_gen_profile(); + let index = self + .editor_state + .editor_ui + .agent_settings + .image_gen_profiles + .iter() + .position(|profile| profile.id == id) + .unwrap_or(0); + if let Some(profile) = self + .editor_state + .editor_ui + .agent_settings + .image_gen_profiles + .get(index) + { + self.editor_state.editor_ui.settings_input_draft = profile.name.clone(); + self.editor_state.editor_ui.agent_settings.focus = Some( + op_editor_core::agent_settings::SettingsFocus::ImageGenProfile { + index, + field: op_editor_core::agent_settings::ImageGenField::Name, + }, + ); + } } } self.mark_dirty(); diff --git a/crates/op-host-native/src/widget_host/settings_dispatch.rs b/crates/op-host-native/src/widget_host/settings_dispatch.rs index 9fcf0cbbf..752ef4f3d 100644 --- a/crates/op-host-native/src/widget_host/settings_dispatch.rs +++ b/crates/op-host-native/src/widget_host/settings_dispatch.rs @@ -5,7 +5,7 @@ use super::WidgetHostNative; impl WidgetHostNative { /// Commit any focused settings-modal input. pub(in crate::widget_host) fn commit_settings_focus_if_any(&mut self) { - use op_editor_core::agent_settings::{BuiltinAgentField, SettingsFocus}; + use op_editor_core::agent_settings::{BuiltinAgentField, ImageGenField, SettingsFocus}; let Some(focus) = self.editor_state.editor_ui.agent_settings.focus.take() else { return; }; @@ -47,6 +47,34 @@ impl WidgetHostNative { self.editor_state.rebuild_chat_models(); } } + SettingsFocus::ImageGenProfile { index, field } => { + if let Some(profile) = self + .editor_state + .editor_ui + .agent_settings + .image_gen_profiles + .get_mut(index) + { + match field { + ImageGenField::Name => { + profile.name = draft.trim().to_string(); + } + ImageGenField::ApiKey => { + profile.api_key = draft.trim().to_string(); + } + ImageGenField::Model => { + profile.model = draft.trim().to_string(); + } + ImageGenField::BaseUrl => { + profile.base_url = if draft.trim().is_empty() { + None + } else { + Some(draft.trim().to_string()) + }; + } + } + } + } } self.mark_dirty(); } diff --git a/crates/op-host-web/src/widget_host/keyboard.rs b/crates/op-host-web/src/widget_host/keyboard.rs index fd8553af9..d5aca7677 100644 --- a/crates/op-host-web/src/widget_host/keyboard.rs +++ b/crates/op-host-web/src/widget_host/keyboard.rs @@ -404,6 +404,7 @@ impl WidgetHost { self.editor_state.editor_ui.agent_settings.mcp_server.port = port.max(1024); } } + SettingsFocus::BuiltinAgent { .. } | SettingsFocus::ImageGenProfile { .. } => {} } self.mark_dirty(); } diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 82436057c..1f3b4a818 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -1,15 +1,5 @@ -//! Web `apply_press` + `apply_click` + `apply_right_press` — -//! extracted from `widget_host.rs` so the spine stays under the -//! 800-line cap. Mirrors the native `widget_host/press.rs` + -//! `click.rs` split. -//! -//! `EditorState` is the host's source of truth. Every widget -//! the canvas hit-test runs against the layout-resolved `LayoutScene`, -//! refreshed at the top of each input handler); the shell-core hit -//! results (`NodeId` / hit enums) are translated into op-editor-core -//! widget hit-test results before feeding `EditorState` -//! mutators. - +//! Web press handlers split from `widget_host.rs`; mirrors the +//! native press/click split and keeps `EditorState` as source of truth. use op_editor_ui::widgets::{ AIChatHit, AIChatPlaceholder, LayerPanel, LayerPanelHit, LocalePicker, PropertyPanel, Toolbar, TopBar, TopBarHit, STATUS_BAR_HEIGHT, STATUS_BAR_WIDTH, TOP_BAR_HEIGHT, @@ -22,10 +12,7 @@ use super::{ }; impl WidgetHost { - /// `true` when `(x, y)` is over the StatusBar's search icon — the - /// left section of the bottom-right pill. Mirrors the native - /// host; `38 px` is a generous target that stops short of the - /// minus button. + /// `true` when `(x, y)` is over the StatusBar search icon. pub(in crate::widget_host) fn status_bar_search_hit( &self, x: f32, @@ -47,8 +34,7 @@ impl WidgetHost { && y <= origin_y + STATUS_BAR_HEIGHT } - /// Zoom + pan so the active page's content is framed within the - /// canvas region (the StatusBar search action). + /// Zoom + pan so the active page's content is framed within the canvas. pub(in crate::widget_host) fn zoom_to_fit(&mut self, viewport_w: f32, viewport_h: f32) { self.refresh_layout_scene(); let Some(content) = self.layout_scene.content_bounds() else { @@ -803,6 +789,9 @@ impl WidgetHost { | AgentSettingsHit::AddAcpAgent | AgentSettingsHit::TestImageSearch | AgentSettingsHit::AddGenConfig + | AgentSettingsHit::SetActiveGenConfig(_) + | AgentSettingsHit::RemoveGenConfig(_) + | AgentSettingsHit::FocusGenConfig { .. } | AgentSettingsHit::Inside => {} } self.mark_dirty();