diff --git a/crates/op-editor-core/src/button_press_state.rs b/crates/op-editor-core/src/button_press_state.rs index 5c36570fe..7154c62a9 100644 --- a/crates/op-editor-core/src/button_press_state.rs +++ b/crates/op-editor-core/src/button_press_state.rs @@ -13,6 +13,7 @@ pub enum ButtonPressTarget { ChatHeader(crate::chat_button_state::ChatHeaderButton), ChatFooter(crate::chat_button_state::ChatFooterButton), Git(crate::git_button_state::GitButton), + DesignMd(crate::design_md_button_state::DesignMdButton), ComponentBrowser(crate::component_browser_state::ComponentBrowserButton), ExportDialog(crate::export_dialog_state::ExportDialogButton), FigmaImport(crate::figma_import_state::FigmaImportButton), diff --git a/crates/op-editor-ui/src/widgets/design_md_panel.rs b/crates/op-editor-ui/src/widgets/design_md_panel.rs index 931cee671..54fe12b18 100644 --- a/crates/op-editor-ui/src/widgets/design_md_panel.rs +++ b/crates/op-editor-ui/src/widgets/design_md_panel.rs @@ -12,11 +12,12 @@ //! click to a [`DesignMdHit`], and owns import / export / drag. use crate::theme::Theme; +use crate::widgets::button::{paint_button_feedback_wash, paint_ghost_button_feedback}; use crate::widgets::design_md_markdown::{parse_blocks, parse_inline, wrap_runs, MdBlock, MdRun}; use crate::widgets::editor_state_ext::theme_for; use crate::widgets::{draw_icon, Icon, PaintCx}; use crate::{Color, Point2D, Rect, TextLayout}; -use op_editor_core::{DesignMdSpec, EditorState, Locale}; +use op_editor_core::{ButtonPressTarget, DesignMdButton, DesignMdSpec, EditorState, Locale}; /// Panel width in logical px. pub const DESIGN_MD_PANEL_W: f32 = 480.0; @@ -70,12 +71,14 @@ pub enum DesignMdHit { /// The floating Design-MD panel, built from an [`EditorState`]. pub struct DesignMdPanel<'a> { spec: Option<&'a DesignMdSpec>, - theme: Theme, + pub(in crate::widgets) theme: Theme, locale: Locale, /// Bitmask of expanded sections (bit 0 = theme … 5 = notes). expanded: u8, /// Which target the cursor is over — drives the hover wash. - hover: Option, + hover: Option, + /// Which Design-MD target is actively pressed. + pub(in crate::widgets) pressed: Option, } /// One rendered line within a section body. @@ -131,13 +134,17 @@ impl<'a> DesignMdPanel<'a> { locale: state.editor_ui.locale, expanded: state.editor_ui.design_md_expanded, hover: state.editor_ui.design_md_hover, + pressed: match state.editor_ui.pressed_button { + Some(ButtonPressTarget::DesignMd(button)) => Some(button), + _ => None, + }, }) } /// Resolve a pointer to a hoverable button. Reuses [`Self::hit_test`] /// and keeps only the button variants (drag-header / inside → None). - pub fn hover_at(&self, panel: Rect, point: Point2D) -> Option { - use op_editor_core::DesignMdButton as B; + pub fn hover_at(&self, panel: Rect, point: Point2D) -> Option { + use DesignMdButton as B; match self.hit_test(panel, point)? { DesignMdHit::Close => Some(B::Close), DesignMdHit::Import => Some(B::Import), @@ -153,6 +160,10 @@ impl<'a> DesignMdPanel<'a> { crate::i18n::translate(self.locale, key) } + fn is_pressed(&self, button: DesignMdButton) -> bool { + self.pressed == Some(button) + } + /// Whether the spec carries content worth a section view (a bare /// `raw` / `project_name` does not count — mirrors the TS check). fn has_content(&self) -> bool { @@ -366,10 +377,28 @@ impl<'a> DesignMdPanel<'a> { self.theme.foreground, ); let [import, export, close] = Self::header_buttons(rect); - use op_editor_core::DesignMdButton as B; - self.icon_button(cx, import, Icon::FolderOpen, self.hover == Some(B::Import)); - self.icon_button(cx, export, Icon::Download, self.hover == Some(B::Export)); - self.icon_button(cx, close, Icon::Close, self.hover == Some(B::Close)); + use DesignMdButton as B; + self.icon_button( + cx, + import, + Icon::FolderOpen, + self.hover == Some(B::Import), + self.is_pressed(B::Import), + ); + self.icon_button( + cx, + export, + Icon::Download, + self.hover == Some(B::Export), + self.is_pressed(B::Export), + ); + self.icon_button( + cx, + close, + Icon::Close, + self.hover == Some(B::Close), + self.is_pressed(B::Close), + ); cx.backend.fill_rect( Rect { origin: Point2D::new(rect.origin.x, rect.origin.y + HEADER_H), @@ -437,22 +466,21 @@ impl<'a> DesignMdPanel<'a> { } // Footer "remove" link. let remove = self.remove_rect(rect); - let remove_hovered = self.hover == Some(op_editor_core::DesignMdButton::Remove); - if remove_hovered { - cx.backend - .fill_round_rect(remove, 6.0, self.theme.button_hover); - } + let remove_hovered = self.hover == Some(DesignMdButton::Remove); + let remove_color = paint_ghost_button_feedback( + cx.backend, + &self.theme, + remove, + remove_hovered, + self.is_pressed(DesignMdButton::Remove), + ); self.text( cx, self.t("designMd.remove"), remove.origin.x, remove.origin.y + 13.0, 11.0, - if remove_hovered { - self.theme.foreground - } else { - self.theme.muted_foreground - }, + remove_color, ); } @@ -460,10 +488,15 @@ impl<'a> DesignMdPanel<'a> { fn paint_section_header(&self, cx: &mut PaintCx<'_>, sec: &SectionLayout) { cx.backend .fill_round_rect(sec.header, 7.0, self.theme.muted); - if self.hover == Some(op_editor_core::DesignMdButton::ToggleSection(sec.index)) { - cx.backend - .fill_round_rect(sec.header, 7.0, self.theme.button_hover); - } + let target = DesignMdButton::ToggleSection(sec.index); + paint_button_feedback_wash( + cx.backend, + &self.theme, + sec.header, + 7.0, + self.hover == Some(target), + self.is_pressed(target), + ); let chevron = if sec.expanded { "▾" } else { "▸" }; let baseline = sec.header.origin.y + SECTION_HEADER_H / 2.0 + 4.0; self.text( @@ -614,22 +647,23 @@ impl<'a> DesignMdPanel<'a> { } /// Paint one square header icon button. - fn icon_button(&self, cx: &mut PaintCx<'_>, rect: Rect, icon: Icon, hovered: bool) { + fn icon_button( + &self, + cx: &mut PaintCx<'_>, + rect: Rect, + icon: Icon, + hovered: bool, + pressed: bool, + ) { cx.backend.fill_round_rect(rect, 6.0, self.theme.muted); - if hovered { - cx.backend - .fill_round_rect(rect, 6.0, self.theme.button_hover); - } + let icon_color = + paint_ghost_button_feedback(cx.backend, &self.theme, rect, hovered, pressed); draw_icon( cx.backend, icon, Point2D::new(rect.origin.x + 5.0, rect.origin.y + 5.0), BTN - 10.0, - if hovered { - self.theme.foreground - } else { - self.theme.muted_foreground - }, + icon_color, 1.5, ); } diff --git a/crates/op-editor-ui/src/widgets/design_md_panel_tests.rs b/crates/op-editor-ui/src/widgets/design_md_panel_tests.rs new file mode 100644 index 000000000..5ee327ef3 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/design_md_panel_tests.rs @@ -0,0 +1,99 @@ +use crate::widgets::{DesignMdPanel, PaintCx}; +use crate::{Color, Point2D, Rect, RenderBackend, TextLayout}; +use op_editor_core::{ButtonPressTarget, DesignMdButton, EditorState}; + +#[derive(Default)] +struct CaptureBackend { + round_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.round_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 + } +} + +fn open_state() -> EditorState { + let mut state = EditorState::default(); + state.editor_ui.design_md_panel_open = true; + state.doc.design_md = Some(op_editor_core::parse_design_md( + "# Brief\n\n## Visual Theme\nWarm system", + )); + state +} + +#[test] +fn for_editor_picks_up_pressed_design_md_button() { + let mut state = open_state(); + state.editor_ui.pressed_button = Some(ButtonPressTarget::DesignMd(DesignMdButton::Import)); + + let panel = DesignMdPanel::for_editor(&state).expect("open"); + + assert_eq!(panel.pressed, Some(DesignMdButton::Import)); +} + +#[test] +fn pressed_section_header_paints_pressed_feedback() { + let mut state = open_state(); + state.editor_ui.pressed_button = Some(ButtonPressTarget::DesignMd( + DesignMdButton::ToggleSection(0), + )); + let panel = DesignMdPanel::for_editor(&state).expect("open"); + let rect = Rect::xywh(0.0, 0.0, 480.0, 560.0); + let expected = panel + .theme + .button_hover + .with_alpha(panel.theme.button_hover.a * 1.8); + let mut backend = CaptureBackend::default(); + let mut cx = PaintCx { + backend: &mut backend, + }; + + panel.paint(&mut cx, rect); + + assert!( + backend + .round_fills + .iter() + .any(|(_, radius, color)| *radius == 7.0 && *color == expected), + "pressed section header should paint shared pressed feedback" + ); +} diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index ceae59d3c..aa8353e7f 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -178,6 +178,8 @@ mod component_browser_kits; pub mod component_browser_panel; pub mod design_md_markdown; pub mod design_md_panel; +#[cfg(test)] +mod design_md_panel_tests; pub mod export_dialog; pub mod figma_import; pub mod figma_import_progress; diff --git a/crates/op-host-native/src/widget_host.rs b/crates/op-host-native/src/widget_host.rs index 4412e95f2..e756f4ee2 100644 --- a/crates/op-host-native/src/widget_host.rs +++ b/crates/op-host-native/src/widget_host.rs @@ -69,6 +69,8 @@ mod color_picker_press; mod component_browser_press; mod design_md_press; #[cfg(test)] +mod design_md_press_tests; +#[cfg(test)] mod figma_import_tests; mod font_picker_dispatch; mod frame_backend; diff --git a/crates/op-host-native/src/widget_host/design_md_press.rs b/crates/op-host-native/src/widget_host/design_md_press.rs index c3a8d192c..9bb44bb79 100644 --- a/crates/op-host-native/src/widget_host/design_md_press.rs +++ b/crates/op-host-native/src/widget_host/design_md_press.rs @@ -27,11 +27,21 @@ impl WidgetHostNative { let Some(panel_rect) = self.design_md_panel_rect(viewport_width, viewport_height) else { return false; }; - let hit = DesignMdPanel::for_editor(&self.editor_state) - .and_then(|p| p.hit_test(panel_rect, Point2D::new(x, y))); - let Some(hit) = hit else { + let point = Point2D::new(x, y); + let Some((hit, pressed_button)) = + DesignMdPanel::for_editor(&self.editor_state).and_then(|p| { + Some(( + p.hit_test(panel_rect, point)?, + p.hover_at(panel_rect, point), + )) + }) + else { return false; }; + if let Some(button) = pressed_button { + self.editor_state.editor_ui.pressed_button = + Some(op_editor_core::ButtonPressTarget::DesignMd(button)); + } match hit { DesignMdHit::Close => { self.editor_state.editor_ui.design_md_panel_open = false; diff --git a/crates/op-host-native/src/widget_host/design_md_press_tests.rs b/crates/op-host-native/src/widget_host/design_md_press_tests.rs new file mode 100644 index 000000000..b0f548555 --- /dev/null +++ b/crates/op-host-native/src/widget_host/design_md_press_tests.rs @@ -0,0 +1,43 @@ +use super::WidgetHostNative; + +#[test] +fn design_md_import_press_sets_and_release_clears_pressed_button() { + let mut host = WidgetHostNative::new(); + let (viewport_w, viewport_h) = (1440.0, 900.0); + host.editor_state_mut().editor_ui.design_md_panel_open = true; + + let panel_rect = host + .design_md_panel_rect(viewport_w, viewport_h) + .expect("design md panel rect"); + let panel = op_editor_ui::widgets::DesignMdPanel::for_editor(host.editor_state()) + .expect("open design md panel"); + let mut point = None; + let mut y = panel_rect.origin.y; + while y <= panel_rect.origin.y + panel_rect.size.y && point.is_none() { + let mut x = panel_rect.origin.x; + while x <= panel_rect.origin.x + panel_rect.size.x { + let p = op_editor_ui::Point2D::new(x, y); + if matches!( + panel.hit_test(panel_rect, p), + Some(op_editor_ui::widgets::DesignMdHit::Import) + ) { + point = Some(p); + break; + } + x += 4.0; + } + y += 4.0; + } + let point = point.expect("import button is hittable"); + + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert_eq!( + host.editor_state().editor_ui.pressed_button, + Some(op_editor_core::ButtonPressTarget::DesignMd( + op_editor_core::DesignMdButton::Import + )) + ); + + assert!(host.apply_release_with_viewport(viewport_w, viewport_h)); + assert_eq!(host.editor_state().editor_ui.pressed_button, None); +} diff --git a/crates/op-host-web/src/widget_host.rs b/crates/op-host-web/src/widget_host.rs index e1a85258d..8d9d46166 100644 --- a/crates/op-host-web/src/widget_host.rs +++ b/crates/op-host-web/src/widget_host.rs @@ -77,6 +77,8 @@ mod color_picker_press; mod component_browser_press; mod cursor_input; mod design_md_press; +#[cfg(test)] +mod design_md_press_tests; pub(crate) mod icon_ingest; // Browser file-IO ingestion (Open / Figma import / clipboard paste) // — needs the codegen-gated document-pipeline deps (jian-ops-schema). diff --git a/crates/op-host-web/src/widget_host/design_md_press.rs b/crates/op-host-web/src/widget_host/design_md_press.rs index 73cdb3ba9..7682baa2c 100644 --- a/crates/op-host-web/src/widget_host/design_md_press.rs +++ b/crates/op-host-web/src/widget_host/design_md_press.rs @@ -26,11 +26,21 @@ impl WidgetHost { let Some(panel_rect) = self.design_md_panel_rect(viewport_width, viewport_height) else { return false; }; - let hit = DesignMdPanel::for_editor(&self.editor_state) - .and_then(|p| p.hit_test(panel_rect, Point2D::new(x, y))); - let Some(hit) = hit else { + let point = Point2D::new(x, y); + let Some((hit, pressed_button)) = + DesignMdPanel::for_editor(&self.editor_state).and_then(|p| { + Some(( + p.hit_test(panel_rect, point)?, + p.hover_at(panel_rect, point), + )) + }) + else { return false; }; + if let Some(button) = pressed_button { + self.editor_state.editor_ui.pressed_button = + Some(op_editor_core::ButtonPressTarget::DesignMd(button)); + } match hit { DesignMdHit::Close => { self.editor_state.editor_ui.design_md_panel_open = false; diff --git a/crates/op-host-web/src/widget_host/design_md_press_tests.rs b/crates/op-host-web/src/widget_host/design_md_press_tests.rs new file mode 100644 index 000000000..761b9e477 --- /dev/null +++ b/crates/op-host-web/src/widget_host/design_md_press_tests.rs @@ -0,0 +1,43 @@ +use super::WidgetHost; + +#[test] +fn design_md_import_press_sets_and_release_clears_pressed_button() { + let mut host = WidgetHost::new(); + let (viewport_w, viewport_h) = (1200.0, 800.0); + host.editor_state.editor_ui.design_md_panel_open = true; + + let panel_rect = host + .design_md_panel_rect(viewport_w, viewport_h) + .expect("design md panel rect"); + let panel = op_editor_ui::widgets::DesignMdPanel::for_editor(&host.editor_state) + .expect("open design md panel"); + let mut point = None; + let mut y = panel_rect.origin.y; + while y <= panel_rect.origin.y + panel_rect.size.y && point.is_none() { + let mut x = panel_rect.origin.x; + while x <= panel_rect.origin.x + panel_rect.size.x { + let p = op_editor_ui::Point2D::new(x, y); + if matches!( + panel.hit_test(panel_rect, p), + Some(op_editor_ui::widgets::DesignMdHit::Import) + ) { + point = Some(p); + break; + } + x += 4.0; + } + y += 4.0; + } + let point = point.expect("import button is hittable"); + + assert!(host.apply_press(point.x, point.y, viewport_w, viewport_h)); + assert_eq!( + host.editor_state.editor_ui.pressed_button, + Some(op_editor_core::ButtonPressTarget::DesignMd( + op_editor_core::DesignMdButton::Import + )) + ); + + assert!(host.apply_release_with_viewport(viewport_w, viewport_h)); + assert_eq!(host.editor_state.editor_ui.pressed_button, None); +}