refactor(editor): add shared button press feedback
This commit is contained in:
parent
e4125fd75d
commit
45340f10de
15
crates/op-editor-core/src/button_press_state.rs
Normal file
15
crates/op-editor-core/src/button_press_state.rs
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
//! Shared pressed-state target for chrome buttons.
|
||||
//!
|
||||
//! Hover state stays per-family because cursor-move hit tests derive it
|
||||
//! independently. Pressed feedback is mutually exclusive for the primary
|
||||
//! pointer, so one enum on `EditorUiState` can cover button families without
|
||||
//! adding parallel `*_pressed` fields.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ButtonPressTarget {
|
||||
Toolbar(crate::toolbar_state::ToolbarHover),
|
||||
TopBar(crate::topbar_state::TopBarButton),
|
||||
StatusBar(crate::statusbar_state::StatusBarButton),
|
||||
ChatHeader(crate::chat_button_state::ChatHeaderButton),
|
||||
ChatFooter(crate::chat_button_state::ChatFooterButton),
|
||||
}
|
||||
|
|
@ -924,6 +924,11 @@ pub struct EditorUiState {
|
|||
/// Index into `AgentProvider::ALL` of the agent driving the chat.
|
||||
pub chat_selected_agent: usize,
|
||||
|
||||
/// Primary-pointer pressed button target. Button feedback is exclusive
|
||||
/// across chrome families, so one field covers toolbar / topbar /
|
||||
/// statusbar / chat buttons without duplicating every hover field.
|
||||
pub pressed_button: Option<crate::button_press_state::ButtonPressTarget>,
|
||||
|
||||
/// True after Cmd/Ctrl+A in the component-browser search box. The
|
||||
/// next edit replaces the whole search query.
|
||||
pub component_browser_select_all: bool,
|
||||
|
|
@ -1229,6 +1234,7 @@ impl Default for EditorUiState {
|
|||
chat_header_hover: None,
|
||||
chat_footer_hover: None,
|
||||
chat_selected_agent: 0,
|
||||
pressed_button: None,
|
||||
component_browser_select_all: false,
|
||||
topbar_traffic_hover: false,
|
||||
topbar_button_hover: None,
|
||||
|
|
@ -1317,6 +1323,14 @@ impl EditorUiState {
|
|||
Self::default()
|
||||
}
|
||||
|
||||
pub fn clear_button_press_target(&mut self) {
|
||||
self.pressed_button = None;
|
||||
}
|
||||
|
||||
pub fn button_pressed(&self, target: crate::button_press_state::ButtonPressTarget) -> bool {
|
||||
self.pressed_button == Some(target)
|
||||
}
|
||||
|
||||
pub fn toggle_fill_type_picker(&mut self) {
|
||||
let opening = !self.fill_type_picker.open;
|
||||
self.fill_type_picker.open = opening;
|
||||
|
|
@ -1574,6 +1588,20 @@ mod tests {
|
|||
assert_eq!(s.variables_scroll.offset, 72.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn button_press_target_clears_chrome_button_families() {
|
||||
let mut ui = EditorUiState {
|
||||
pressed_button: Some(crate::button_press_state::ButtonPressTarget::TopBar(
|
||||
crate::TopBarButton::ToggleTheme,
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
ui.clear_button_press_target();
|
||||
|
||||
assert_eq!(ui.pressed_button, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_mode_flips() {
|
||||
assert_eq!(ThemeMode::Dark.flipped(), ThemeMode::Light);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ pub mod agent_settings_builtin_presets;
|
|||
pub mod agent_settings_connection;
|
||||
pub mod align;
|
||||
pub mod align_guides;
|
||||
pub mod button_press_state;
|
||||
pub mod chat;
|
||||
pub mod chat_button_state;
|
||||
mod chat_design_apply;
|
||||
|
|
@ -140,6 +141,7 @@ pub use agent_settings_builtin_presets::{
|
|||
BuiltinAgentPreset, BuiltinAgentPresetKey, BUILTIN_AGENT_PRESETS,
|
||||
};
|
||||
pub use align::AlignAction;
|
||||
pub use button_press_state::ButtonPressTarget;
|
||||
pub use chat::{
|
||||
AgentProvider, ChatAnchor, ChatImage, ChatMessage, ChatRole, ChatState, ChatToolCall,
|
||||
ModelEntry,
|
||||
|
|
|
|||
|
|
@ -99,6 +99,8 @@ pub struct AIChatPlaceholder<'a> {
|
|||
pub header_hover: Option<op_editor_core::ChatHeaderButton>,
|
||||
/// Which bottom-toolbar chat control the cursor is over.
|
||||
pub footer_hover: Option<op_editor_core::ChatFooterButton>,
|
||||
pub header_pressed: Option<op_editor_core::ChatHeaderButton>,
|
||||
pub footer_pressed: Option<op_editor_core::ChatFooterButton>,
|
||||
/// Localised empty-state example cards.
|
||||
pub(crate) examples: [ExampleCard; 4],
|
||||
/// Active UI locale.
|
||||
|
|
@ -131,6 +133,14 @@ impl<'a> AIChatPlaceholder<'a> {
|
|||
example_hover: ui.chat_example_hover,
|
||||
header_hover: ui.chat_header_hover,
|
||||
footer_hover: ui.chat_footer_hover,
|
||||
header_pressed: match ui.pressed_button {
|
||||
Some(op_editor_core::ButtonPressTarget::ChatHeader(button)) => Some(button),
|
||||
_ => None,
|
||||
},
|
||||
footer_pressed: match ui.pressed_button {
|
||||
Some(op_editor_core::ButtonPressTarget::ChatFooter(button)) => Some(button),
|
||||
_ => None,
|
||||
},
|
||||
examples: example_cards(ui.locale),
|
||||
locale: ui.locale,
|
||||
}
|
||||
|
|
@ -316,11 +326,17 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
if self.state.collapsed {
|
||||
cx.backend
|
||||
.fill_round_rect(rect, COLLAPSED_RADIUS, self.theme.card);
|
||||
if self.header_hover == Some(op_editor_core::ChatHeaderButton::ToggleCollapse) {
|
||||
if self.header_hover == Some(op_editor_core::ChatHeaderButton::ToggleCollapse)
|
||||
|| self.header_pressed == Some(op_editor_core::ChatHeaderButton::ToggleCollapse)
|
||||
{
|
||||
cx.backend.fill_round_rect(
|
||||
rect,
|
||||
COLLAPSED_RADIUS,
|
||||
chat_neutral_hover_color(&self.theme),
|
||||
chat_neutral_feedback_color(
|
||||
&self.theme,
|
||||
self.header_pressed
|
||||
== Some(op_editor_core::ChatHeaderButton::ToggleCollapse),
|
||||
),
|
||||
);
|
||||
}
|
||||
cx.backend
|
||||
|
|
@ -379,14 +395,15 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
let header_y = rect.origin.y + 8.0;
|
||||
let chevron_x = rect.origin.x + PAD;
|
||||
let title_hovered = self.header_hover == Some(ChatHeaderButton::ToggleCollapse);
|
||||
if title_hovered {
|
||||
let title_pressed = self.header_pressed == Some(ChatHeaderButton::ToggleCollapse);
|
||||
if title_hovered || title_pressed {
|
||||
cx.backend.fill_round_rect(
|
||||
self.expanded_header_title_rect(rect),
|
||||
8.0,
|
||||
chat_neutral_hover_color(&self.theme),
|
||||
chat_neutral_feedback_color(&self.theme, title_pressed),
|
||||
);
|
||||
}
|
||||
let chevron_color = if title_hovered {
|
||||
let chevron_color = if title_hovered || title_pressed {
|
||||
self.theme.foreground
|
||||
} else {
|
||||
self.theme.muted_foreground
|
||||
|
|
@ -417,6 +434,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
maximize_x,
|
||||
header_y,
|
||||
self.header_hover == Some(ChatHeaderButton::ToggleMaximize),
|
||||
self.header_pressed == Some(ChatHeaderButton::ToggleMaximize),
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -433,6 +451,7 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
new_chat_x,
|
||||
header_y,
|
||||
self.header_hover == Some(ChatHeaderButton::NewChat),
|
||||
self.header_pressed == Some(ChatHeaderButton::NewChat),
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -517,9 +536,17 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
// Model chip — brand logo of the selected model's provider
|
||||
// + its display name + a chevron. Click toggles the picker.
|
||||
let mut model_x = rect.origin.x + PAD;
|
||||
if self.footer_hover == Some(ChatFooterButton::ModelPicker) {
|
||||
cx.backend
|
||||
.fill_round_rect(footer.model, 6.0, chat_neutral_hover_color(&self.theme));
|
||||
if self.footer_hover == Some(ChatFooterButton::ModelPicker)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::ModelPicker)
|
||||
{
|
||||
cx.backend.fill_round_rect(
|
||||
footer.model,
|
||||
6.0,
|
||||
chat_neutral_feedback_color(
|
||||
&self.theme,
|
||||
self.footer_pressed == Some(ChatFooterButton::ModelPicker),
|
||||
),
|
||||
);
|
||||
}
|
||||
let selected = self.state.selected_model_entry();
|
||||
let chip_color = self.theme.muted_foreground;
|
||||
|
|
@ -595,9 +622,13 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
if team_active {
|
||||
cx.backend.fill_round_rect(chip, 6.0, primary_wash);
|
||||
}
|
||||
if self.footer_hover == Some(ChatFooterButton::AgentTeam) {
|
||||
if self.footer_hover == Some(ChatFooterButton::AgentTeam)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::AgentTeam)
|
||||
{
|
||||
let wash = if team_active {
|
||||
primary_wash
|
||||
} else if self.footer_pressed == Some(ChatFooterButton::AgentTeam) {
|
||||
chat_neutral_feedback_color(&self.theme, true)
|
||||
} else {
|
||||
chat_neutral_hover_color(&self.theme)
|
||||
};
|
||||
|
|
@ -636,9 +667,17 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
// Right cluster — attach + send (TS ghost buttons: bare icons
|
||||
// that only get a wash while hovered).
|
||||
let attach_rect = footer.attach;
|
||||
if self.footer_hover == Some(ChatFooterButton::AddAttachment) {
|
||||
cx.backend
|
||||
.fill_round_rect(attach_rect, 6.0, chat_neutral_hover_color(&self.theme));
|
||||
if self.footer_hover == Some(ChatFooterButton::AddAttachment)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::AddAttachment)
|
||||
{
|
||||
cx.backend.fill_round_rect(
|
||||
attach_rect,
|
||||
6.0,
|
||||
chat_neutral_feedback_color(
|
||||
&self.theme,
|
||||
self.footer_pressed == Some(ChatFooterButton::AddAttachment),
|
||||
),
|
||||
);
|
||||
}
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -672,9 +711,18 @@ impl<'a> Widget for AIChatPlaceholder<'a> {
|
|||
};
|
||||
if self.footer_hover == Some(ChatFooterButton::Send)
|
||||
|| self.footer_hover == Some(ChatFooterButton::Stop)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::Send)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::Stop)
|
||||
{
|
||||
cx.backend
|
||||
.fill_round_rect(send_rect, 6.0, chat_neutral_hover_color(&self.theme));
|
||||
cx.backend.fill_round_rect(
|
||||
send_rect,
|
||||
6.0,
|
||||
chat_neutral_feedback_color(
|
||||
&self.theme,
|
||||
self.footer_pressed == Some(ChatFooterButton::Send)
|
||||
|| self.footer_pressed == Some(ChatFooterButton::Stop),
|
||||
),
|
||||
);
|
||||
}
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -722,11 +770,15 @@ fn draw_label(cx: &mut PaintCx<'_>, text: &str, size: f32, color: Color, x: f32,
|
|||
}
|
||||
|
||||
fn chat_neutral_hover_color(theme: &Theme) -> Color {
|
||||
chat_neutral_feedback_color(theme, false)
|
||||
}
|
||||
|
||||
fn chat_neutral_feedback_color(theme: &Theme, pressed: bool) -> Color {
|
||||
Color {
|
||||
r: theme.foreground.r,
|
||||
g: theme.foreground.g,
|
||||
b: theme.foreground.b,
|
||||
a: 0.12,
|
||||
a: if pressed { 0.18 } else { 0.12 },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -740,18 +792,14 @@ fn paint_header_btn_bg(
|
|||
icon_x: f32,
|
||||
header_y: f32,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) -> Color {
|
||||
if hovered {
|
||||
let center = Point2D::new(icon_x + 9.0, header_y + 9.0);
|
||||
let r = Rect {
|
||||
origin: Point2D::new(center.x - 12.0, center.y - 12.0),
|
||||
size: Point2D::new(24.0, 24.0),
|
||||
};
|
||||
cx.backend.fill_round_rect(r, 6.0, theme.button_hover);
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.muted_foreground
|
||||
}
|
||||
let center = Point2D::new(icon_x + 9.0, header_y + 9.0);
|
||||
let r = Rect {
|
||||
origin: Point2D::new(center.x - 12.0, center.y - 12.0),
|
||||
size: Point2D::new(24.0, 24.0),
|
||||
};
|
||||
crate::widgets::button::paint_ghost_button_feedback(cx.backend, theme, r, hovered, pressed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -139,6 +139,30 @@ fn paint_send_button_hover_adds_visible_feedback() {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_editor_picks_up_chat_button_press_targets() {
|
||||
let mut s = EditorState::new();
|
||||
s.editor_ui.pressed_button = Some(op_editor_core::ButtonPressTarget::ChatHeader(
|
||||
op_editor_core::ChatHeaderButton::NewChat,
|
||||
));
|
||||
let header_panel = AIChatPlaceholder::from_editor(&s);
|
||||
assert_eq!(
|
||||
header_panel.header_pressed,
|
||||
Some(op_editor_core::ChatHeaderButton::NewChat)
|
||||
);
|
||||
assert_eq!(header_panel.footer_pressed, None);
|
||||
|
||||
s.editor_ui.pressed_button = Some(op_editor_core::ButtonPressTarget::ChatFooter(
|
||||
op_editor_core::ChatFooterButton::Send,
|
||||
));
|
||||
let footer_panel = AIChatPlaceholder::from_editor(&s);
|
||||
assert_eq!(footer_panel.header_pressed, None);
|
||||
assert_eq!(
|
||||
footer_panel.footer_pressed,
|
||||
Some(op_editor_core::ChatFooterButton::Send)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paint_footer_neutral_hovers_use_visible_feedback() {
|
||||
let cases = [
|
||||
|
|
|
|||
149
crates/op-editor-ui/src/widgets/button.rs
Normal file
149
crates/op-editor-ui/src/widgets/button.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
use crate::{Color, Rect, RenderBackend, Theme};
|
||||
use jian_widgets::components::button::{Button, ButtonVariant};
|
||||
use jian_widgets::{Density, Tokens};
|
||||
|
||||
pub(crate) fn tokens_from_theme(theme: &Theme) -> Tokens {
|
||||
Tokens {
|
||||
background: theme.background,
|
||||
foreground: theme.foreground,
|
||||
card: theme.card,
|
||||
card_foreground: theme.card_foreground,
|
||||
popover: theme.popover,
|
||||
popover_foreground: theme.popover_foreground,
|
||||
primary: theme.primary,
|
||||
primary_foreground: theme.primary_foreground,
|
||||
muted: theme.muted,
|
||||
muted_foreground: theme.muted_foreground,
|
||||
border: theme.border,
|
||||
accent: theme.accent,
|
||||
accent_foreground: theme.accent_foreground,
|
||||
destructive: theme.destructive,
|
||||
button_hover: theme.button_hover,
|
||||
row_selected: theme.row_selected,
|
||||
row_selected_primary: theme.row_selected_primary,
|
||||
density: Density::Desktop,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn paint_ghost_button_feedback(
|
||||
backend: &mut dyn RenderBackend,
|
||||
theme: &Theme,
|
||||
rect: Rect,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) -> Color {
|
||||
Button {
|
||||
label: "",
|
||||
icon_d: None,
|
||||
variant: ButtonVariant::Ghost,
|
||||
enabled: true,
|
||||
hovered,
|
||||
pressed,
|
||||
font_size: 0.0,
|
||||
}
|
||||
.paint(backend, rect, &tokens_from_theme(theme));
|
||||
|
||||
if hovered || pressed {
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.muted_foreground
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Point2D, TextLayout};
|
||||
|
||||
#[derive(Default)]
|
||||
struct CaptureBackend {
|
||||
fills: Vec<(Rect, f32, Color)>,
|
||||
}
|
||||
|
||||
impl RenderBackend for CaptureBackend {
|
||||
fn begin_frame(&mut self) {}
|
||||
|
||||
fn end_frame(&mut self) {}
|
||||
|
||||
fn fill_rect(&mut self, _rect: Rect, _color: Color) {}
|
||||
|
||||
fn stroke_rect(&mut self, _rect: Rect, _color: Color, _width: f32) {}
|
||||
|
||||
fn draw_text(&mut self, _layout: &TextLayout, _origin: Point2D) {}
|
||||
|
||||
fn clip_rect(&mut self, _rect: Rect) {}
|
||||
|
||||
fn stroke_line(&mut self, _from: Point2D, _to: Point2D, _color: Color, _width: f32) {}
|
||||
|
||||
fn fill_round_rect(&mut self, rect: Rect, radius: f32, color: Color) {
|
||||
self.fills.push((rect, radius, color));
|
||||
}
|
||||
|
||||
fn stroke_round_rect(&mut self, _rect: Rect, _radius: f32, _color: Color, _width: f32) {}
|
||||
|
||||
fn stroke_svg_path(
|
||||
&mut self,
|
||||
_d: &str,
|
||||
_top_left: Point2D,
|
||||
_size: f32,
|
||||
_color: Color,
|
||||
_width: f32,
|
||||
) {
|
||||
}
|
||||
|
||||
fn save(&mut self) {}
|
||||
|
||||
fn restore(&mut self) {}
|
||||
|
||||
fn translate(&mut self, _offset: Point2D) {}
|
||||
|
||||
fn resize(&mut self, _width: u32, _height: u32) {}
|
||||
|
||||
fn dpi_scale(&self) -> f32 {
|
||||
1.0
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghost_feedback_paints_pressed_token() {
|
||||
let theme = Theme::dark();
|
||||
let mut backend = CaptureBackend::default();
|
||||
let rect = Rect::xywh(8.0, 10.0, 32.0, 32.0);
|
||||
|
||||
let icon_color = paint_ghost_button_feedback(&mut backend, &theme, rect, true, true);
|
||||
|
||||
assert_eq!(
|
||||
backend.fills,
|
||||
vec![(
|
||||
rect,
|
||||
6.0,
|
||||
theme.button_hover.with_alpha(theme.button_hover.a * 1.8)
|
||||
)]
|
||||
);
|
||||
assert_eq!(icon_color, theme.foreground);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghost_feedback_hover_only_paints_hover_token() {
|
||||
let theme = Theme::dark();
|
||||
let mut backend = CaptureBackend::default();
|
||||
let rect = Rect::xywh(8.0, 10.0, 32.0, 32.0);
|
||||
|
||||
let icon_color = paint_ghost_button_feedback(&mut backend, &theme, rect, true, false);
|
||||
|
||||
assert_eq!(backend.fills, vec![(rect, 6.0, theme.button_hover)]);
|
||||
assert_eq!(icon_color, theme.foreground);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghost_feedback_idle_paints_no_background() {
|
||||
let theme = Theme::dark();
|
||||
let mut backend = CaptureBackend::default();
|
||||
let rect = Rect::xywh(8.0, 10.0, 32.0, 32.0);
|
||||
|
||||
let icon_color = paint_ghost_button_feedback(&mut backend, &theme, rect, false, false);
|
||||
|
||||
assert!(backend.fills.is_empty());
|
||||
assert_eq!(icon_color, theme.muted_foreground);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,6 +27,7 @@ use crate::{Point2D, Rect, RenderBackend};
|
|||
pub const MIN_RAIL_WIDTH: f32 = 80.0;
|
||||
|
||||
// Phase B primitives.
|
||||
pub(crate) mod button;
|
||||
pub mod prop_row;
|
||||
pub mod text_input;
|
||||
pub mod tree;
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ pub struct StatusBar {
|
|||
/// Which control the cursor is over — drives the per-control
|
||||
/// `theme.button_hover` wash. `None` = no hover.
|
||||
pub hover: Option<StatusBarButton>,
|
||||
pub pressed: Option<StatusBarButton>,
|
||||
}
|
||||
|
||||
impl StatusBar {
|
||||
|
|
@ -48,6 +49,7 @@ impl StatusBar {
|
|||
zoom_percent: 100,
|
||||
theme: Theme::dark(),
|
||||
hover: None,
|
||||
pressed: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,6 +68,10 @@ impl StatusBar {
|
|||
zoom_percent: zoom.max(1),
|
||||
theme: crate::widgets::editor_state_ext::theme_for(&state.editor_ui),
|
||||
hover: state.editor_ui.statusbar_hover,
|
||||
pressed: match state.editor_ui.pressed_button {
|
||||
Some(op_editor_core::ButtonPressTarget::StatusBar(button)) => Some(button),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -148,6 +154,7 @@ impl Widget for StatusBar {
|
|||
center_y,
|
||||
StatusBarButton::Search,
|
||||
self.hover,
|
||||
self.pressed,
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -164,6 +171,7 @@ impl Widget for StatusBar {
|
|||
center_y,
|
||||
StatusBarButton::ZoomOut,
|
||||
self.hover,
|
||||
self.pressed,
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -191,6 +199,7 @@ impl Widget for StatusBar {
|
|||
center_y,
|
||||
StatusBarButton::ZoomIn,
|
||||
self.hover,
|
||||
self.pressed,
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -226,18 +235,20 @@ fn paint_control_bg(
|
|||
center_y: f32,
|
||||
control: StatusBarButton,
|
||||
hover: Option<StatusBarButton>,
|
||||
pressed: Option<StatusBarButton>,
|
||||
) -> Color {
|
||||
if hover == Some(control) {
|
||||
let cx_center = glyph_x + ICON_SIZE / 2.0;
|
||||
let r = Rect {
|
||||
origin: Point2D::new(cx_center - ZOOM_HIT_HALF, center_y - ZOOM_HIT_HALF),
|
||||
size: Point2D::new(ZOOM_HIT_HALF * 2.0, ZOOM_HIT_HALF * 2.0),
|
||||
};
|
||||
cx.backend.fill_round_rect(r, 6.0, theme.button_hover);
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.muted_foreground
|
||||
}
|
||||
let cx_center = glyph_x + ICON_SIZE / 2.0;
|
||||
let r = Rect {
|
||||
origin: Point2D::new(cx_center - ZOOM_HIT_HALF, center_y - ZOOM_HIT_HALF),
|
||||
size: Point2D::new(ZOOM_HIT_HALF * 2.0, ZOOM_HIT_HALF * 2.0),
|
||||
};
|
||||
crate::widgets::button::paint_ghost_button_feedback(
|
||||
cx.backend,
|
||||
theme,
|
||||
r,
|
||||
hover == Some(control),
|
||||
pressed == Some(control),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -288,6 +299,16 @@ mod tests {
|
|||
assert_eq!(bar.hover, Some(StatusBarButton::ZoomIn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_editor_picks_up_pressed_button() {
|
||||
let mut state = op_editor_core::EditorState::new();
|
||||
state.editor_ui.pressed_button = Some(op_editor_core::ButtonPressTarget::StatusBar(
|
||||
StatusBarButton::ZoomIn,
|
||||
));
|
||||
let bar = StatusBar::for_editor(&state);
|
||||
assert_eq!(bar.pressed, Some(StatusBarButton::ZoomIn));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_reports_pill_size() {
|
||||
let cx = LayoutCx {
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ pub struct Toolbar {
|
|||
/// wash. `None` = no hover (cursor off the bar or over an
|
||||
/// active item where the active fill already reads).
|
||||
pub hover: Option<op_editor_core::ToolbarHover>,
|
||||
pub pressed: Option<op_editor_core::ToolbarHover>,
|
||||
}
|
||||
|
||||
impl Toolbar {
|
||||
|
|
@ -117,6 +118,10 @@ impl Toolbar {
|
|||
theme: theme_for(&state.editor_ui),
|
||||
shape_tool: state.editor_ui.shape_tool,
|
||||
hover: state.editor_ui.toolbar_hover,
|
||||
pressed: match state.editor_ui.pressed_button {
|
||||
Some(op_editor_core::ButtonPressTarget::Toolbar(button)) => Some(button),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +147,24 @@ impl Toolbar {
|
|||
}
|
||||
}
|
||||
|
||||
fn item_pressed(&self, item: &ToolbarItem) -> bool {
|
||||
use op_editor_core::ToolbarHover as H;
|
||||
let Some(pressed) = self.pressed else {
|
||||
return false;
|
||||
};
|
||||
match item {
|
||||
ToolbarItem::Tool(tool, _) => {
|
||||
matches!(pressed, H::Tool(t) if t == *tool) && *tool != self.active
|
||||
}
|
||||
ToolbarItem::Action(action, _) => {
|
||||
use crate::widgets::editor_state_ext::toolbar_action;
|
||||
matches!(pressed, H::Action(a) if a == toolbar_action(*action))
|
||||
}
|
||||
ToolbarItem::ShapeSlot => matches!(pressed, H::ShapeSlot) && !self.active.is_shape(),
|
||||
ToolbarItem::Separator => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total intrinsic height = padding + each item's slot.
|
||||
fn intrinsic_height(&self) -> f32 {
|
||||
let mut h = PAD_TOP;
|
||||
|
|
@ -351,7 +374,17 @@ impl Widget for Toolbar {
|
|||
}
|
||||
let active = *tool == self.active;
|
||||
let hovered = self.item_hovered(item);
|
||||
paint_button(cx, &self.theme, button_x, y, *icon, active, hovered);
|
||||
let pressed = self.item_pressed(item);
|
||||
paint_button(
|
||||
cx,
|
||||
&self.theme,
|
||||
button_x,
|
||||
y,
|
||||
*icon,
|
||||
active,
|
||||
hovered,
|
||||
pressed,
|
||||
);
|
||||
y += BUTTON_SIZE;
|
||||
prev_was_item = true;
|
||||
}
|
||||
|
|
@ -360,7 +393,8 @@ impl Widget for Toolbar {
|
|||
y += BUTTON_GAP;
|
||||
}
|
||||
let hovered = self.item_hovered(item);
|
||||
paint_button(cx, &self.theme, button_x, y, *icon, false, hovered);
|
||||
let pressed = self.item_pressed(item);
|
||||
paint_button(cx, &self.theme, button_x, y, *icon, false, hovered, pressed);
|
||||
y += BUTTON_SIZE;
|
||||
prev_was_item = true;
|
||||
}
|
||||
|
|
@ -370,6 +404,7 @@ impl Widget for Toolbar {
|
|||
}
|
||||
let active = self.active.is_shape();
|
||||
let hovered = self.item_hovered(item);
|
||||
let pressed = self.item_pressed(item);
|
||||
paint_button(
|
||||
cx,
|
||||
&self.theme,
|
||||
|
|
@ -378,6 +413,7 @@ impl Widget for Toolbar {
|
|||
icon_for_shape(self.shape_tool),
|
||||
active,
|
||||
hovered,
|
||||
pressed,
|
||||
);
|
||||
// Chevron-down sits just BELOW the button,
|
||||
// horizontally centered — matches the TS
|
||||
|
|
@ -414,6 +450,7 @@ fn paint_button(
|
|||
icon: Icon,
|
||||
active: bool,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) {
|
||||
let button_rect = Rect {
|
||||
origin: Point2D::new(x, y),
|
||||
|
|
@ -423,12 +460,14 @@ fn paint_button(
|
|||
cx.backend
|
||||
.fill_round_rect(button_rect, BUTTON_RADIUS, theme.primary);
|
||||
theme.primary_foreground
|
||||
} else if hovered {
|
||||
cx.backend
|
||||
.fill_round_rect(button_rect, BUTTON_RADIUS, theme.button_hover);
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.muted_foreground
|
||||
crate::widgets::button::paint_ghost_button_feedback(
|
||||
cx.backend,
|
||||
theme,
|
||||
button_rect,
|
||||
hovered,
|
||||
pressed,
|
||||
)
|
||||
};
|
||||
let icon_origin = Point2D::new(
|
||||
x + (BUTTON_SIZE - ICON_SIZE) / 2.0,
|
||||
|
|
@ -521,6 +560,21 @@ mod tests {
|
|||
assert_eq!(toolbar.hit_test(rect, Point2D::new(1000.0, 1000.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_editor_picks_up_pressed_button() {
|
||||
let mut state = EditorState::new();
|
||||
state.editor_ui.pressed_button = Some(op_editor_core::ButtonPressTarget::Toolbar(
|
||||
op_editor_core::ToolbarHover::Action(op_editor_core::ToolbarAction::Undo),
|
||||
));
|
||||
let toolbar = Toolbar::for_editor(&state);
|
||||
assert_eq!(
|
||||
toolbar.pressed,
|
||||
Some(op_editor_core::ToolbarHover::Action(
|
||||
op_editor_core::ToolbarAction::Undo
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hit_test_resolves_action_button() {
|
||||
let toolbar = Toolbar::default_set();
|
||||
|
|
|
|||
|
|
@ -18,9 +18,6 @@ pub const TOP_BAR_HEIGHT: f32 = 40.0;
|
|||
// widgets keep their own `ICON_SIZE`.
|
||||
pub(super) const ICON_SIZE: f32 = 14.0;
|
||||
pub(super) const ICON_BUTTON: f32 = 28.0;
|
||||
/// Corner radius of a top-bar button's hover background. Smaller than
|
||||
/// the floating toolbar's 8 px to suit the 28 px-tall chrome buttons.
|
||||
pub(super) const BUTTON_RADIUS: f32 = 6.0;
|
||||
/// Globe locale-picker button — wider than a normal icon button so a
|
||||
/// chevron-down sits next to the globe glyph (signals the dropdown).
|
||||
pub(super) const GLOBE_BUTTON_WIDTH: f32 = 44.0;
|
||||
|
|
@ -134,6 +131,8 @@ pub struct TopBar {
|
|||
/// Which chrome button the cursor is over — drives the per-button
|
||||
/// `theme.button_hover` wash. `None` = no hover.
|
||||
pub hover: Option<op_editor_core::TopBarButton>,
|
||||
/// Which chrome button is held by the primary pointer.
|
||||
pub pressed: Option<op_editor_core::TopBarButton>,
|
||||
}
|
||||
|
||||
impl TopBar {
|
||||
|
|
@ -153,6 +152,7 @@ impl TopBar {
|
|||
theme_mode: op_editor_core::ThemeMode::Dark,
|
||||
git_branch: None,
|
||||
hover: None,
|
||||
pressed: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +191,10 @@ impl TopBar {
|
|||
theme_mode: ui.theme_mode,
|
||||
git_branch: ui.git_panel.branch.clone(),
|
||||
hover: ui.topbar_button_hover,
|
||||
pressed: match ui.pressed_button {
|
||||
Some(op_editor_core::ButtonPressTarget::TopBar(button)) => Some(button),
|
||||
_ => None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,6 +204,10 @@ impl TopBar {
|
|||
self.hover == Some(button)
|
||||
}
|
||||
|
||||
pub(super) fn is_pressed(&self, button: op_editor_core::TopBarButton) -> bool {
|
||||
self.pressed == Some(button)
|
||||
}
|
||||
|
||||
/// Left-edge reservation for the window controls. Collapses to
|
||||
/// `0` in fullscreen on macOS — the native traffic lights hide
|
||||
/// then, so the gap would be dead space. Other platforms keep
|
||||
|
|
@ -510,24 +518,16 @@ impl Widget for TopBar {
|
|||
}
|
||||
}
|
||||
|
||||
/// Paint the `theme.button_hover` background behind a chrome button
|
||||
/// when the cursor rests on it, and return the glyph color to use:
|
||||
/// `theme.foreground` while hovered (the wash lifts the icon), else
|
||||
/// `theme.muted_foreground`. Centralises the hover treatment so every
|
||||
/// top-bar button reads identically.
|
||||
/// Paint shared ghost-button feedback behind a chrome button and return
|
||||
/// the glyph color to use.
|
||||
pub(super) fn paint_hover_bg(
|
||||
cx: &mut PaintCx<'_>,
|
||||
theme: &Theme,
|
||||
rect: Rect,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) -> Color {
|
||||
if hovered {
|
||||
cx.backend
|
||||
.fill_round_rect(rect, BUTTON_RADIUS, theme.button_hover);
|
||||
theme.foreground
|
||||
} else {
|
||||
theme.muted_foreground
|
||||
}
|
||||
crate::widgets::button::paint_ghost_button_feedback(cx.backend, theme, rect, hovered, pressed)
|
||||
}
|
||||
|
||||
pub(super) fn paint_icon_button(
|
||||
|
|
@ -537,12 +537,13 @@ pub(super) fn paint_icon_button(
|
|||
center_y: f32,
|
||||
icon: Icon,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) {
|
||||
let button_rect = Rect {
|
||||
origin: Point2D::new(x, center_y - ICON_BUTTON / 2.0),
|
||||
size: Point2D::new(ICON_BUTTON, ICON_BUTTON),
|
||||
};
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered);
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered, pressed);
|
||||
let icon_origin = Point2D::new(
|
||||
x + (ICON_BUTTON - ICON_SIZE) / 2.0,
|
||||
center_y - ICON_SIZE / 2.0,
|
||||
|
|
@ -559,12 +560,13 @@ pub(super) fn paint_file_menu_button(
|
|||
x: f32,
|
||||
center_y: f32,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) {
|
||||
let button_rect = Rect {
|
||||
origin: Point2D::new(x, center_y - ICON_BUTTON / 2.0),
|
||||
size: Point2D::new(FILE_MENU_BUTTON_WIDTH, ICON_BUTTON),
|
||||
};
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered);
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered, pressed);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
Icon::FolderOpen,
|
||||
|
|
@ -589,12 +591,13 @@ pub(super) fn paint_figma_button(
|
|||
x: f32,
|
||||
center_y: f32,
|
||||
hovered: bool,
|
||||
pressed: bool,
|
||||
) {
|
||||
let button_rect = Rect {
|
||||
origin: Point2D::new(x, center_y - ICON_BUTTON / 2.0),
|
||||
size: Point2D::new(ICON_BUTTON, ICON_BUTTON),
|
||||
};
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered);
|
||||
let color = paint_hover_bg(cx, theme, button_rect, hovered, pressed);
|
||||
crate::widgets::brand_icons::paint_figma_logo(
|
||||
cx.backend,
|
||||
Point2D::new(
|
||||
|
|
@ -680,6 +683,19 @@ mod tests {
|
|||
assert!(!bar.is_hovered(op_editor_core::TopBarButton::ToggleSidebar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_editor_ui_picks_up_button_press() {
|
||||
let ui = EditorUiState {
|
||||
pressed_button: Some(op_editor_core::ButtonPressTarget::TopBar(
|
||||
op_editor_core::TopBarButton::ToggleTheme,
|
||||
)),
|
||||
..Default::default()
|
||||
};
|
||||
let bar = TopBar::for_editor_ui(&ui);
|
||||
assert!(bar.is_pressed(op_editor_core::TopBarButton::ToggleTheme));
|
||||
assert!(!bar.is_pressed(op_editor_core::TopBarButton::ToggleSidebar));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximize_button_hit_tests_to_toggle_fullscreen() {
|
||||
let bar = TopBar::untitled();
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ impl TopBar {
|
|||
center_y,
|
||||
Icon::PanelLeft,
|
||||
self.is_hovered(TopBarButton::ToggleSidebar),
|
||||
self.is_pressed(TopBarButton::ToggleSidebar),
|
||||
);
|
||||
// Divider between the sidebar toggle and the file-menu.
|
||||
let divider1_x = panel_left_x + ICON_BUTTON + DIVIDER_GAP;
|
||||
|
|
@ -139,6 +140,7 @@ impl TopBar {
|
|||
file_menu_x,
|
||||
center_y,
|
||||
self.is_hovered(TopBarButton::ToggleFileMenu),
|
||||
self.is_pressed(TopBarButton::ToggleFileMenu),
|
||||
);
|
||||
// Divider before the Figma import affordance.
|
||||
let divider2_x = file_menu_x + FILE_MENU_BUTTON_WIDTH + DIVIDER_GAP;
|
||||
|
|
@ -151,6 +153,7 @@ impl TopBar {
|
|||
figma_x,
|
||||
center_y,
|
||||
self.is_hovered(TopBarButton::OpenFigmaImport),
|
||||
self.is_pressed(TopBarButton::OpenFigmaImport),
|
||||
);
|
||||
|
||||
// ── Centered file name ─────────────────────────────────
|
||||
|
|
@ -184,6 +187,7 @@ impl TopBar {
|
|||
&self.theme,
|
||||
git_rect,
|
||||
self.is_hovered(TopBarButton::ToggleGitPanel),
|
||||
self.is_pressed(TopBarButton::ToggleGitPanel),
|
||||
);
|
||||
draw_icon(
|
||||
cx.backend,
|
||||
|
|
@ -227,6 +231,7 @@ impl TopBar {
|
|||
center_y,
|
||||
Icon::Maximize,
|
||||
self.is_hovered(TopBarButton::ToggleFullscreen),
|
||||
self.is_pressed(TopBarButton::ToggleFullscreen),
|
||||
);
|
||||
rx -= ICON_BUTTON;
|
||||
|
||||
|
|
@ -243,6 +248,7 @@ impl TopBar {
|
|||
center_y,
|
||||
theme_icon,
|
||||
self.is_hovered(TopBarButton::ToggleTheme),
|
||||
self.is_pressed(TopBarButton::ToggleTheme),
|
||||
);
|
||||
rx -= GLOBE_BUTTON_WIDTH;
|
||||
|
||||
|
|
@ -256,6 +262,7 @@ impl TopBar {
|
|||
&self.theme,
|
||||
globe_button,
|
||||
self.is_hovered(TopBarButton::ToggleLocale),
|
||||
self.is_pressed(TopBarButton::ToggleLocale),
|
||||
);
|
||||
let locale_glyph_x = Self::locale_glyph_left(globe_button);
|
||||
// Globe glyph at the left half.
|
||||
|
|
@ -304,10 +311,13 @@ impl TopBar {
|
|||
size: Point2D::new(chip_w, 26.0),
|
||||
};
|
||||
// Hover wash behind the whole chip (TS `hover:bg-accent`).
|
||||
if self.is_hovered(TopBarButton::OpenAgentSettings) {
|
||||
cx.backend
|
||||
.fill_round_rect(chip_rect, BUTTON_RADIUS, self.theme.button_hover);
|
||||
}
|
||||
let _ = crate::widgets::button::paint_ghost_button_feedback(
|
||||
cx.backend,
|
||||
&self.theme,
|
||||
chip_rect,
|
||||
self.is_hovered(TopBarButton::OpenAgentSettings),
|
||||
self.is_pressed(TopBarButton::OpenAgentSettings),
|
||||
);
|
||||
// Leading icons (no border ring — TS empty-state chip has no
|
||||
// outline). The empty state shows the single LayoutGrid
|
||||
// set-up affordance; the active chip stacks one brand logo
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ impl WidgetHostNative {
|
|||
if let Some(chat_rect) = self.ai_chat_rect(viewport_width, viewport_height) {
|
||||
let panel = AIChatPlaceholder::from_editor(&self.editor_state);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if let Some(target) = chat_button_press_target(&hit) {
|
||||
self.editor_state.editor_ui.pressed_button = Some(target);
|
||||
}
|
||||
match hit {
|
||||
AIChatHit::Inside => {
|
||||
// Panel chrome that hit no control — blank
|
||||
|
|
@ -339,6 +342,10 @@ impl WidgetHostNative {
|
|||
size: Point2D::new(TOOLBAR_WIDTH, toolbar_h),
|
||||
};
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::Toolbar(
|
||||
op_editor_ui::widgets::editor_state_ext::toolbar_hover(hit),
|
||||
));
|
||||
match hit {
|
||||
op_editor_ui::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.editor_state.tool = tool;
|
||||
|
|
@ -464,3 +471,18 @@ impl WidgetHostNative {
|
|||
was_focused
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_button_press_target(hit: &AIChatHit) -> Option<op_editor_core::ButtonPressTarget> {
|
||||
if let Some(header) = op_editor_ui::widgets::editor_state_ext::chat_header_hover(hit) {
|
||||
return Some(op_editor_core::ButtonPressTarget::ChatHeader(header));
|
||||
}
|
||||
let footer = match hit {
|
||||
AIChatHit::ToggleModelPicker => op_editor_core::ChatFooterButton::ModelPicker,
|
||||
AIChatHit::CycleAgentTeam => op_editor_core::ChatFooterButton::AgentTeam,
|
||||
AIChatHit::AddAttachment => op_editor_core::ChatFooterButton::AddAttachment,
|
||||
AIChatHit::Send => op_editor_core::ChatFooterButton::Send,
|
||||
AIChatHit::Stop => op_editor_core::ChatFooterButton::Stop,
|
||||
_ => return None,
|
||||
};
|
||||
Some(op_editor_core::ButtonPressTarget::ChatFooter(footer))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1100,6 +1100,10 @@ impl WidgetHostNative {
|
|||
|
||||
/// Mouse-release — ends active drag; chat-panel snaps corner.
|
||||
pub fn apply_release_with_viewport(&mut self, viewport_w: f32, viewport_h: f32) -> bool {
|
||||
let button_released = self.editor_state.editor_ui.pressed_button.take().is_some();
|
||||
if button_released {
|
||||
self.mark_dirty();
|
||||
}
|
||||
// Pen owns the release while authoring (TS onMouseUp).
|
||||
if self.apply_pen_release() {
|
||||
return true;
|
||||
|
|
@ -1203,7 +1207,7 @@ impl WidgetHostNative {
|
|||
}
|
||||
let was_dragging = self.drag.is_some();
|
||||
self.drag = None;
|
||||
was_dragging
|
||||
was_dragging || button_released
|
||||
}
|
||||
|
||||
/// Viewport-less release variant — drops viewport-bound drags.
|
||||
|
|
@ -1219,6 +1223,10 @@ impl WidgetHostNative {
|
|||
}
|
||||
|
||||
pub fn apply_release(&mut self) -> bool {
|
||||
let button_released = self.editor_state.editor_ui.pressed_button.take().is_some();
|
||||
if button_released {
|
||||
self.mark_dirty();
|
||||
}
|
||||
// Pen owns the release while authoring (TS onMouseUp).
|
||||
if self.apply_pen_release() {
|
||||
return true;
|
||||
|
|
@ -1302,7 +1310,7 @@ impl WidgetHostNative {
|
|||
}
|
||||
let was_dragging = self.drag.is_some();
|
||||
self.drag = None;
|
||||
was_dragging
|
||||
was_dragging || button_released
|
||||
}
|
||||
|
||||
// `arc_drag_command` (the `SetEllipseArc` builder) lives in the
|
||||
|
|
|
|||
|
|
@ -820,6 +820,28 @@ fn status_bar_search_click_frames_content_in_viewport() {
|
|||
assert!((v.pan_y - 180.0).abs() < 1e-2, "pan_y {}", v.pan_y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_press_sets_and_release_clears_pressed_button() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
let (vw, vh) = (1200.0, 800.0);
|
||||
let r = host
|
||||
.status_bar_rect(vw, vh)
|
||||
.expect("status bar visible at this size");
|
||||
let x = r.origin.x + 5.0;
|
||||
let y = r.origin.y + r.size.y / 2.0;
|
||||
|
||||
assert!(host.apply_press(x, y, vw, vh));
|
||||
assert_eq!(
|
||||
host.editor_state().editor_ui.pressed_button,
|
||||
Some(op_editor_core::ButtonPressTarget::StatusBar(
|
||||
op_editor_core::StatusBarButton::Search
|
||||
))
|
||||
);
|
||||
|
||||
assert!(host.apply_release_with_viewport(vw, vh));
|
||||
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pick_fill_image_keeps_image_popover_open_for_mode_selection() {
|
||||
let mut host = WidgetHostNative::new();
|
||||
|
|
|
|||
|
|
@ -283,6 +283,8 @@ impl WidgetHostNative {
|
|||
use op_editor_core::StatusBarButton;
|
||||
let bar = op_editor_ui::widgets::StatusBar::for_editor(&self.editor_state);
|
||||
if let Some(btn) = bar.control_at(r, Point2D::new(x, y)) {
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::StatusBar(btn));
|
||||
match btn {
|
||||
StatusBarButton::Search => self.zoom_to_fit(viewport_width, viewport_height),
|
||||
StatusBarButton::ZoomOut => {
|
||||
|
|
@ -292,6 +294,7 @@ impl WidgetHostNative {
|
|||
self.status_bar_zoom(true, viewport_width, viewport_height)
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -422,6 +425,9 @@ impl WidgetHostNative {
|
|||
};
|
||||
let top_bar = TopBar::for_editor_ui(&self.editor_state.editor_ui);
|
||||
if let Some(hit) = top_bar.hit_test(top_bar_rect, Point2D::new(x, y)) {
|
||||
let pressed = op_editor_ui::widgets::editor_state_ext::topbar_button_hover(hit);
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::TopBar(pressed));
|
||||
match hit {
|
||||
TopBarHit::ToggleSidebar => {
|
||||
let v = &mut self.editor_state.editor_ui.sidebar_open;
|
||||
|
|
|
|||
|
|
@ -18,6 +18,9 @@ impl WidgetHost {
|
|||
if let Some(chat_rect) = self.ai_chat_rect(viewport_w, viewport_h) {
|
||||
let panel = AIChatPlaceholder::from_editor(&self.editor_state);
|
||||
if let Some(hit) = panel.hit_test(chat_rect, Point2D::new(x, y)) {
|
||||
if let Some(target) = chat_button_press_target(&hit) {
|
||||
self.editor_state.editor_ui.pressed_button = Some(target);
|
||||
}
|
||||
match hit {
|
||||
AIChatHit::Inside => {
|
||||
// Panel chrome that hit no control — blank
|
||||
|
|
@ -213,6 +216,10 @@ impl WidgetHost {
|
|||
let toolbar_rect = self.toolbar_rect(viewport_w);
|
||||
let toolbar = Toolbar::for_editor(&self.editor_state);
|
||||
if let Some(hit) = toolbar.hit_test(toolbar_rect, Point2D::new(x, y)) {
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::Toolbar(
|
||||
op_editor_ui::widgets::editor_state_ext::toolbar_hover(hit),
|
||||
));
|
||||
match hit {
|
||||
op_editor_ui::widgets::ToolbarHit::Tool(tool) => {
|
||||
self.editor_state.tool = tool;
|
||||
|
|
@ -343,6 +350,21 @@ pub(crate) fn apply_offline_chat_error(chat: &mut op_editor_core::ChatState) ->
|
|||
true
|
||||
}
|
||||
|
||||
fn chat_button_press_target(hit: &AIChatHit) -> Option<op_editor_core::ButtonPressTarget> {
|
||||
if let Some(header) = op_editor_ui::widgets::editor_state_ext::chat_header_hover(hit) {
|
||||
return Some(op_editor_core::ButtonPressTarget::ChatHeader(header));
|
||||
}
|
||||
let footer = match hit {
|
||||
AIChatHit::ToggleModelPicker => op_editor_core::ChatFooterButton::ModelPicker,
|
||||
AIChatHit::CycleAgentTeam => op_editor_core::ChatFooterButton::AgentTeam,
|
||||
AIChatHit::AddAttachment => op_editor_core::ChatFooterButton::AddAttachment,
|
||||
AIChatHit::Send => op_editor_core::ChatFooterButton::Send,
|
||||
AIChatHit::Stop => op_editor_core::ChatFooterButton::Stop,
|
||||
_ => return None,
|
||||
};
|
||||
Some(op_editor_core::ButtonPressTarget::ChatFooter(footer))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -200,6 +200,8 @@ impl WidgetHost {
|
|||
use op_editor_core::StatusBarButton;
|
||||
let bar = op_editor_ui::widgets::StatusBar::for_editor(&self.editor_state);
|
||||
if let Some(btn) = bar.control_at(r, Point2D::new(x, y)) {
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::StatusBar(btn));
|
||||
match btn {
|
||||
StatusBarButton::Search => self.zoom_to_fit(viewport_width, viewport_height),
|
||||
StatusBarButton::ZoomOut => {
|
||||
|
|
@ -209,6 +211,7 @@ impl WidgetHost {
|
|||
self.status_bar_zoom(true, viewport_width, viewport_height)
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
|
@ -300,6 +303,9 @@ impl WidgetHost {
|
|||
let top_bar = TopBar::for_editor_ui(&self.editor_state.editor_ui);
|
||||
if let Some(hit) = top_bar.hit_test(top_bar_rect, Point2D::new(x, y)) {
|
||||
self.commit_property_family_focus_if_any();
|
||||
let pressed = op_editor_ui::widgets::editor_state_ext::topbar_button_hover(hit);
|
||||
self.editor_state.editor_ui.pressed_button =
|
||||
Some(op_editor_core::ButtonPressTarget::TopBar(pressed));
|
||||
match hit {
|
||||
TopBarHit::ToggleSidebar => {
|
||||
let v = &mut self.editor_state.editor_ui.sidebar_open;
|
||||
|
|
|
|||
|
|
@ -89,6 +89,30 @@ fn codegen_preview_drag_selects_code_text() {
|
|||
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_bar_press_sets_and_release_clears_pressed_button() {
|
||||
let mut host = WidgetHost::new();
|
||||
let (viewport_w, viewport_h) = (1200.0, 800.0);
|
||||
host.last_viewport_w = viewport_w;
|
||||
host.last_viewport_h = viewport_h;
|
||||
let r = host
|
||||
.status_bar_rect(viewport_w, viewport_h)
|
||||
.expect("status bar visible at this size");
|
||||
let x = r.origin.x + 5.0;
|
||||
let y = r.origin.y + r.size.y / 2.0;
|
||||
|
||||
assert!(host.apply_press(x, y, viewport_w, viewport_h));
|
||||
assert_eq!(
|
||||
host.editor_state.editor_ui.pressed_button,
|
||||
Some(op_editor_core::ButtonPressTarget::StatusBar(
|
||||
op_editor_core::StatusBarButton::Search
|
||||
))
|
||||
);
|
||||
|
||||
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
|
||||
assert_eq!(host.editor_state.editor_ui.pressed_button, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codegen_preview_wheel_scrolls_code_not_property_panel() {
|
||||
let mut host = WidgetHost::new();
|
||||
|
|
|
|||
|
|
@ -70,6 +70,10 @@ impl WidgetHost {
|
|||
pub fn apply_release_with_viewport(&mut self, viewport_w: f32, viewport_h: f32) -> bool {
|
||||
self.last_viewport_w = viewport_w;
|
||||
self.last_viewport_h = viewport_h;
|
||||
let button_released = self.editor_state.editor_ui.pressed_button.take().is_some();
|
||||
if button_released {
|
||||
self.mark_dirty();
|
||||
}
|
||||
// Colour-picker drag end (non-consuming) + floating-panel
|
||||
// header drags — see `widget_host/overlay_cursor.rs`.
|
||||
if self.release_overlay_drags() {
|
||||
|
|
@ -111,7 +115,7 @@ impl WidgetHost {
|
|||
}
|
||||
let was_dragging = self.drag.is_some();
|
||||
self.drag = None;
|
||||
was_dragging
|
||||
was_dragging || button_released
|
||||
}
|
||||
|
||||
/// Mouse-release handler — viewport-less variant. Public host
|
||||
|
|
@ -119,6 +123,10 @@ impl WidgetHost {
|
|||
/// the viewport-aware `apply_release_with_viewport` instead.
|
||||
#[allow(dead_code)]
|
||||
pub fn apply_release(&mut self) -> bool {
|
||||
let button_released = self.editor_state.editor_ui.pressed_button.take().is_some();
|
||||
if button_released {
|
||||
self.mark_dirty();
|
||||
}
|
||||
if self.release_overlay_drags() {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -153,7 +161,7 @@ impl WidgetHost {
|
|||
}
|
||||
let was_dragging = self.drag.is_some();
|
||||
self.drag = None;
|
||||
was_dragging
|
||||
was_dragging || button_released
|
||||
}
|
||||
|
||||
/// Resolve a layer drag-to-reorder gesture on release. Mirrors
|
||||
|
|
|
|||
Loading…
Reference in a new issue