refactor(editor): add shared button press feedback

This commit is contained in:
Kayshen-X 2026-06-14 02:03:53 +08:00
parent e4125fd75d
commit 45340f10de
19 changed files with 556 additions and 70 deletions

View 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),
}

View file

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

View file

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

View file

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

View file

@ -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 = [

View 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);
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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::*;

View file

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

View file

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

View file

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