refactor(editor): route design md buttons through shared feedback

This commit is contained in:
Kayshen-X 2026-06-14 05:59:48 +08:00
parent 7d746a02dc
commit ce444535fa
10 changed files with 285 additions and 39 deletions

View file

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

View file

@ -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<op_editor_core::DesignMdButton>,
hover: Option<DesignMdButton>,
/// Which Design-MD target is actively pressed.
pub(in crate::widgets) pressed: Option<DesignMdButton>,
}
/// 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<op_editor_core::DesignMdButton> {
use op_editor_core::DesignMdButton as B;
pub fn hover_at(&self, panel: Rect, point: Point2D) -> Option<DesignMdButton> {
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,
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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