diff --git a/crates/op-editor-core/src/button_press_state.rs b/crates/op-editor-core/src/button_press_state.rs new file mode 100644 index 000000000..8a083c58f --- /dev/null +++ b/crates/op-editor-core/src/button_press_state.rs @@ -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), +} diff --git a/crates/op-editor-core/src/editor_ui_state.rs b/crates/op-editor-core/src/editor_ui_state.rs index b967c0cc6..d522dc350 100644 --- a/crates/op-editor-core/src/editor_ui_state.rs +++ b/crates/op-editor-core/src/editor_ui_state.rs @@ -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, + /// 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); diff --git a/crates/op-editor-core/src/lib.rs b/crates/op-editor-core/src/lib.rs index 28d65906e..7d055ab92 100644 --- a/crates/op-editor-core/src/lib.rs +++ b/crates/op-editor-core/src/lib.rs @@ -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, diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs index 92363f452..94fbee93d 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel.rs @@ -99,6 +99,8 @@ pub struct AIChatPlaceholder<'a> { pub header_hover: Option, /// Which bottom-toolbar chat control the cursor is over. pub footer_hover: Option, + pub header_pressed: Option, + pub footer_pressed: Option, /// 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)] diff --git a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs index 1bfd76649..6e589d443 100644 --- a/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs +++ b/crates/op-editor-ui/src/widgets/ai_chat_panel/tests_paint.rs @@ -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 = [ diff --git a/crates/op-editor-ui/src/widgets/button.rs b/crates/op-editor-ui/src/widgets/button.rs new file mode 100644 index 000000000..cdd038da3 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/button.rs @@ -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); + } +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index f5d681156..4b089d104 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -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; diff --git a/crates/op-editor-ui/src/widgets/status_bar.rs b/crates/op-editor-ui/src/widgets/status_bar.rs index b3811b049..1a0eaaf5b 100644 --- a/crates/op-editor-ui/src/widgets/status_bar.rs +++ b/crates/op-editor-ui/src/widgets/status_bar.rs @@ -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, + pub pressed: Option, } 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, + pressed: Option, ) -> 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 { diff --git a/crates/op-editor-ui/src/widgets/toolbar.rs b/crates/op-editor-ui/src/widgets/toolbar.rs index 95f7209b3..539306c8d 100644 --- a/crates/op-editor-ui/src/widgets/toolbar.rs +++ b/crates/op-editor-ui/src/widgets/toolbar.rs @@ -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, + pub pressed: Option, } 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(); diff --git a/crates/op-editor-ui/src/widgets/top_bar.rs b/crates/op-editor-ui/src/widgets/top_bar.rs index 1de3c1dde..4775c3117 100644 --- a/crates/op-editor-ui/src/widgets/top_bar.rs +++ b/crates/op-editor-ui/src/widgets/top_bar.rs @@ -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, + /// Which chrome button is held by the primary pointer. + pub pressed: Option, } 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(); diff --git a/crates/op-editor-ui/src/widgets/top_bar_paint.rs b/crates/op-editor-ui/src/widgets/top_bar_paint.rs index 5091ee7ef..546eb0a48 100644 --- a/crates/op-editor-ui/src/widgets/top_bar_paint.rs +++ b/crates/op-editor-ui/src/widgets/top_bar_paint.rs @@ -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 diff --git a/crates/op-host-native/src/widget_host/click.rs b/crates/op-host-native/src/widget_host/click.rs index b7c014f88..ba34a61e9 100644 --- a/crates/op-host-native/src/widget_host/click.rs +++ b/crates/op-host-native/src/widget_host/click.rs @@ -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 { + 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)) +} diff --git a/crates/op-host-native/src/widget_host/input.rs b/crates/op-host-native/src/widget_host/input.rs index 274b5e814..3fb20958b 100644 --- a/crates/op-host-native/src/widget_host/input.rs +++ b/crates/op-host-native/src/widget_host/input.rs @@ -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 diff --git a/crates/op-host-native/src/widget_host/input_tests.rs b/crates/op-host-native/src/widget_host/input_tests.rs index bda42b769..84bc5a78b 100644 --- a/crates/op-host-native/src/widget_host/input_tests.rs +++ b/crates/op-host-native/src/widget_host/input_tests.rs @@ -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(); diff --git a/crates/op-host-native/src/widget_host/press.rs b/crates/op-host-native/src/widget_host/press.rs index a5966d43b..b4d0c743a 100644 --- a/crates/op-host-native/src/widget_host/press.rs +++ b/crates/op-host-native/src/widget_host/press.rs @@ -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; diff --git a/crates/op-host-web/src/widget_host/click.rs b/crates/op-host-web/src/widget_host/click.rs index 1be3220a4..03a7e667a 100644 --- a/crates/op-host-web/src/widget_host/click.rs +++ b/crates/op-host-web/src/widget_host/click.rs @@ -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 { + 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::*; diff --git a/crates/op-host-web/src/widget_host/press.rs b/crates/op-host-web/src/widget_host/press.rs index 17b78f344..913d80cba 100644 --- a/crates/op-host-web/src/widget_host/press.rs +++ b/crates/op-host-web/src/widget_host/press.rs @@ -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; diff --git a/crates/op-host-web/src/widget_host/property_hover_tests.rs b/crates/op-host-web/src/widget_host/property_hover_tests.rs index 771d32201..34ec77c0c 100644 --- a/crates/op-host-web/src/widget_host/property_hover_tests.rs +++ b/crates/op-host-web/src/widget_host/property_hover_tests.rs @@ -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(); diff --git a/crates/op-host-web/src/widget_host/release_input.rs b/crates/op-host-web/src/widget_host/release_input.rs index 10acf550a..bb84ef8c4 100644 --- a/crates/op-host-web/src/widget_host/release_input.rs +++ b/crates/op-host-web/src/widget_host/release_input.rs @@ -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