feat(gui): implement selection mask state and vector editing functionality
- Add `selection/state.rs` to manage selection masks, including combining masks and calculating bounds. - Introduce `vector_edit.rs` for vector shape manipulation, handling drag sessions, and hit testing for handles. - Create `feature_scorecard.rs` to ensure stable feature identifiers and regression gates for GUI components. - Implement raster behavior tests in `raster_test.rs` to validate gradient application against masks.
This commit is contained in:
@@ -15,9 +15,21 @@ use iced::{Element, Length};
|
||||
pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Message> {
|
||||
// ── Provider selector row ──
|
||||
let provider_label = text("Provider:").size(10).color(colors.text_secondary);
|
||||
let ollama_btn = make_provider_btn("Ollama", state.config.provider == crate::ai_chat::AiProvider::Ollama, colors);
|
||||
let claude_btn = make_provider_btn("Claude", state.config.provider == crate::ai_chat::AiProvider::Claude, colors);
|
||||
let openai_btn = make_provider_btn("OpenAI", state.config.provider == crate::ai_chat::AiProvider::OpenAI, colors);
|
||||
let ollama_btn = make_provider_btn(
|
||||
"Ollama",
|
||||
state.config.provider == crate::ai_chat::AiProvider::Ollama,
|
||||
colors,
|
||||
);
|
||||
let claude_btn = make_provider_btn(
|
||||
"Claude",
|
||||
state.config.provider == crate::ai_chat::AiProvider::Claude,
|
||||
colors,
|
||||
);
|
||||
let openai_btn = make_provider_btn(
|
||||
"OpenAI",
|
||||
state.config.provider == crate::ai_chat::AiProvider::OpenAI,
|
||||
colors,
|
||||
);
|
||||
let provider_row = row![provider_label, ollama_btn, claude_btn, openai_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
@@ -35,28 +47,37 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.size(10)
|
||||
.width(Length::Fill);
|
||||
|
||||
let config_row = row![
|
||||
url_label,
|
||||
url_input,
|
||||
model_label,
|
||||
model_input,
|
||||
]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
let config_row = row![url_label, url_input, model_label, model_input,]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
// ── Toggles row ──
|
||||
let reasoning_label = text("Reasoning").size(9).color(colors.text_secondary);
|
||||
let reasoning_btn = button(
|
||||
text(if state.config.reasoning_enabled { "ON" } else { "OFF" }).size(9)
|
||||
text(if state.config.reasoning_enabled {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
})
|
||||
.size(9),
|
||||
)
|
||||
.on_press(Message::AiChatReasoningToggled(!state.config.reasoning_enabled))
|
||||
.on_press(Message::AiChatReasoningToggled(
|
||||
!state.config.reasoning_enabled,
|
||||
))
|
||||
.padding([2, 6]);
|
||||
|
||||
let canvas_label = text("Canvas ctx").size(9).color(colors.text_secondary);
|
||||
let canvas_btn = button(
|
||||
text(if state.config.canvas_context { "ON" } else { "OFF" }).size(9)
|
||||
text(if state.config.canvas_context {
|
||||
"ON"
|
||||
} else {
|
||||
"OFF"
|
||||
})
|
||||
.size(9),
|
||||
)
|
||||
.on_press(Message::AiChatCanvasContextToggled(!state.config.canvas_context))
|
||||
.on_press(Message::AiChatCanvasContextToggled(
|
||||
!state.config.canvas_context,
|
||||
))
|
||||
.padding([2, 6]);
|
||||
|
||||
let toggles_row = row![
|
||||
@@ -78,12 +99,18 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
column![
|
||||
text("AI Creative Assistant").size(13).color(colors.accent),
|
||||
text("").size(4),
|
||||
text("Hello! I am your AI Drawing Partner.").size(11).color(colors.text_secondary),
|
||||
text("Hello! I am your AI Drawing Partner.")
|
||||
.size(11)
|
||||
.color(colors.text_secondary),
|
||||
text("").size(4),
|
||||
text("Ask me to paint landscapes, add details,").size(10).color(colors.text_disabled),
|
||||
text("apply blur filters, or manipulate layers!").size(10).color(colors.text_disabled),
|
||||
text("Ask me to paint landscapes, add details,")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
text("apply blur filters, or manipulate layers!")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
]
|
||||
.spacing(2)
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(12)
|
||||
.width(Length::Fill)
|
||||
@@ -105,16 +132,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
if state.config.reasoning_enabled && !state.current_reasoning.is_empty() {
|
||||
let reasoning_bubble = container(
|
||||
column![
|
||||
text("Deep Thoughts:").size(10).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
text(state.current_reasoning.clone()).size(10).color(colors.text_secondary),
|
||||
text("Deep Thoughts:")
|
||||
.size(10)
|
||||
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
text(state.current_reasoning.clone())
|
||||
.size(10)
|
||||
.color(colors.text_secondary),
|
||||
]
|
||||
.spacing(2)
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.15, 0.12, 0.08, 1.0))),
|
||||
border: iced::Border::default().rounded(6).color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.15, 0.12, 0.08, 1.0,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.rounded(6)
|
||||
.color(iced::Color::from_rgb(0.85, 0.47, 0.02)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -124,13 +159,19 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
// Render streaming response if in progress
|
||||
if state.is_streaming && !state.current_response.is_empty() {
|
||||
let streaming_bubble = container(
|
||||
text(state.current_response.clone()).size(11).color(colors.text_primary)
|
||||
text(state.current_response.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
|
||||
border: iced::Border::default().rounded(6).color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.18, 0.22, 0.28, 1.0,
|
||||
))),
|
||||
border: iced::Border::default()
|
||||
.rounded(6)
|
||||
.color(iced::Color::from_rgba(0.29, 0.62, 1.0, 0.5)),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
@@ -141,7 +182,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
if state.is_streaming {
|
||||
let indicator = row![
|
||||
text("...").size(10).color(colors.text_disabled),
|
||||
text("AI is thinking...").size(10).color(colors.text_disabled),
|
||||
text("AI is thinking...")
|
||||
.size(10)
|
||||
.color(colors.text_disabled),
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
@@ -155,15 +198,24 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.size(11)
|
||||
.width(Length::Fill);
|
||||
|
||||
let send_btn = button(text("Send").size(10))
|
||||
.on_press(Message::AiChatSend)
|
||||
.padding([6, 10]);
|
||||
let send_btn = action_button(
|
||||
"Send",
|
||||
(!state.is_streaming && !state.input.trim().is_empty()).then_some(Message::AiChatSend),
|
||||
colors,
|
||||
);
|
||||
let stream_btn = action_button(
|
||||
"Cancel",
|
||||
state.is_streaming.then_some(Message::AiChatCancel),
|
||||
colors,
|
||||
);
|
||||
let clear_btn = action_button("Clear", Some(Message::AiChatClear), colors);
|
||||
let copy_btn = action_button(
|
||||
"Copy all",
|
||||
(!state.messages.is_empty() || state.is_streaming).then_some(Message::AiChatCopyTranscript),
|
||||
colors,
|
||||
);
|
||||
|
||||
let clear_btn = button(text("Clear").size(10))
|
||||
.on_press(Message::AiChatClear)
|
||||
.padding([6, 10]);
|
||||
|
||||
let input_row = row![input_field, send_btn, clear_btn]
|
||||
let input_row = row![input_field, send_btn, stream_btn, copy_btn, clear_btn]
|
||||
.spacing(4)
|
||||
.align_y(iced::Alignment::Center);
|
||||
|
||||
@@ -172,6 +224,9 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
text("AI Assistant").size(13).color(colors.text_primary),
|
||||
provider_row,
|
||||
config_row,
|
||||
text("Model discovery is unavailable; enter the exact provider model ID.")
|
||||
.size(9)
|
||||
.color(colors.text_disabled),
|
||||
toggles_row,
|
||||
horizontal_rule(1),
|
||||
scrollable(msg_list).height(Length::Fill),
|
||||
@@ -188,26 +243,82 @@ pub fn view(state: &AiChatState, colors: ThemeColors) -> Element<'static, Messag
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Build an action button whose disabled, hover, pressed, and focus-border states are explicit.
|
||||
fn action_button(
|
||||
label: &'static str,
|
||||
message: Option<Message>,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let mut control = button(text(label).size(10)).padding([6, 10]);
|
||||
if let Some(message) = message {
|
||||
control = control.on_press(message);
|
||||
}
|
||||
control
|
||||
.style(move |_theme, status| {
|
||||
let (background, text_color, border) = match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
(colors.bg_active, colors.text_primary, colors.border_high)
|
||||
}
|
||||
iced::widget::button::Status::Hovered => {
|
||||
(colors.accent_hover, iced::Color::WHITE, colors.accent)
|
||||
}
|
||||
iced::widget::button::Status::Pressed => {
|
||||
(colors.accent, iced::Color::WHITE, colors.text_primary)
|
||||
}
|
||||
iced::widget::button::Status::Disabled => {
|
||||
(colors.bg_app, colors.text_disabled, colors.border_low)
|
||||
}
|
||||
};
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(background)),
|
||||
text_color,
|
||||
border: iced::Border::default().rounded(3).color(border).width(1),
|
||||
..Default::default()
|
||||
}
|
||||
})
|
||||
.into()
|
||||
}
|
||||
|
||||
/// Render a single chat message as a styled bubble.
|
||||
fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'static, Message> {
|
||||
if msg.is_reasoning {
|
||||
return container(
|
||||
column![
|
||||
text("Reasoning").size(9).color(colors.text_secondary),
|
||||
text(msg.content.clone())
|
||||
.size(10)
|
||||
.color(colors.text_secondary),
|
||||
]
|
||||
.spacing(2),
|
||||
)
|
||||
.padding(8)
|
||||
.width(Length::Fill)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
border: iced::Border::default().rounded(6).color(colors.border_high),
|
||||
..Default::default()
|
||||
})
|
||||
.into();
|
||||
}
|
||||
match msg.role.as_str() {
|
||||
"user" => {
|
||||
// User bubble — right-aligned, blue tint
|
||||
let label = text("You").size(9).color(colors.accent);
|
||||
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
|
||||
let bubble = container(
|
||||
column![label, content].spacing(2)
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.16, 0.33, 0.58, 1.0))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary);
|
||||
let bubble = container(column![label, content].spacing(2))
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.16, 0.33, 0.58, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
row![iced::widget::Space::with_width(Length::Fill), bubble]
|
||||
.into()
|
||||
row![iced::widget::Space::with_width(Length::Fill), bubble].into()
|
||||
}
|
||||
"tool" => {
|
||||
// Tool result — monospace pill
|
||||
@@ -224,40 +335,45 @@ fn render_message_bubble(msg: &ChatMessage, colors: ThemeColors) -> Element<'sta
|
||||
_ => {
|
||||
// Assistant bubble — left-aligned, slate tint
|
||||
let label = text("AI").size(9).color(colors.accent_green);
|
||||
let content = text(msg.content.clone()).size(11).color(colors.text_primary);
|
||||
let bubble = container(
|
||||
column![label, content].spacing(2)
|
||||
)
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(0.18, 0.22, 0.28, 1.0))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
let content = text(msg.content.clone())
|
||||
.size(11)
|
||||
.color(colors.text_primary);
|
||||
let bubble = container(column![label, content].spacing(2))
|
||||
.padding(8)
|
||||
.max_width(400.0)
|
||||
.style(move |_theme| iced::widget::container::Style {
|
||||
background: Some(iced::Background::Color(iced::Color::from_rgba(
|
||||
0.18, 0.22, 0.28, 1.0,
|
||||
))),
|
||||
border: iced::Border::default().rounded(6),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
row![bubble, iced::widget::Space::with_width(Length::Fill)]
|
||||
.into()
|
||||
row![bubble, iced::widget::Space::with_width(Length::Fill)].into()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a provider selection button with active state highlighting.
|
||||
fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Element<'static, Message> {
|
||||
fn make_provider_btn(
|
||||
label: &str,
|
||||
is_active: bool,
|
||||
colors: ThemeColors,
|
||||
) -> Element<'static, Message> {
|
||||
let label_owned = label.to_string();
|
||||
let msg = match label {
|
||||
"Ollama" => Message::AiChatProviderChanged("Ollama".to_string()),
|
||||
"Claude" => Message::AiChatProviderChanged("Claude".to_string()),
|
||||
"OpenAI" => Message::AiChatProviderChanged("OpenAI".to_string()),
|
||||
_ => Message::NoOp,
|
||||
_ => unreachable!("provider buttons are built from known labels"),
|
||||
};
|
||||
|
||||
button(text(label_owned).size(10))
|
||||
.on_press(msg)
|
||||
.padding([4, 8])
|
||||
.style(move |_theme: &iced::Theme, status: iced::widget::button::Status| {
|
||||
match status {
|
||||
iced::widget::button::Status::Active | iced::widget::button::Status::Hovered => {
|
||||
.style(
|
||||
move |_theme: &iced::Theme, status: iced::widget::button::Status| match status {
|
||||
iced::widget::button::Status::Active => {
|
||||
if is_active {
|
||||
iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.accent)),
|
||||
@@ -274,13 +390,33 @@ fn make_provider_btn(label: &str, is_active: bool, colors: ThemeColors) -> Eleme
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_hover)),
|
||||
text_color: colors.text_secondary,
|
||||
iced::widget::button::Status::Hovered => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(if is_active {
|
||||
colors.accent_hover
|
||||
} else {
|
||||
colors.bg_active
|
||||
})),
|
||||
text_color: if is_active {
|
||||
iced::Color::WHITE
|
||||
} else {
|
||||
colors.text_primary
|
||||
},
|
||||
border: iced::Border::default().rounded(4).color(colors.border_high),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Pressed => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_active)),
|
||||
text_color: colors.text_primary,
|
||||
border: iced::Border::default().rounded(4).color(colors.accent),
|
||||
..Default::default()
|
||||
},
|
||||
iced::widget::button::Status::Disabled => iced::widget::button::Style {
|
||||
background: Some(iced::Background::Color(colors.bg_app)),
|
||||
text_color: colors.text_disabled,
|
||||
border: iced::Border::default().rounded(4),
|
||||
..Default::default()
|
||||
},
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
.into()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user