refactor(editor): route design md buttons through shared feedback
This commit is contained in:
parent
7d746a02dc
commit
ce444535fa
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
99
crates/op-editor-ui/src/widgets/design_md_panel_tests.rs
Normal file
99
crates/op-editor-ui/src/widgets/design_md_panel_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
43
crates/op-host-web/src/widget_host/design_md_press_tests.rs
Normal file
43
crates/op-host-web/src/widget_host/design_md_press_tests.rs
Normal 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);
|
||||
}
|
||||
Loading…
Reference in a new issue