fix(ai): edit image generation profiles

This commit is contained in:
Fini 2026-05-30 06:17:14 +08:00
parent 54aff64327
commit 9498e8d37d
11 changed files with 355 additions and 34 deletions

View file

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

View file

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

View file

@ -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::<f32>()
+ 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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