diff --git a/crates/openpencil-shell-core/src/widgets/property_panel.rs b/crates/openpencil-shell-core/src/widgets/property_panel.rs index 4a027acad..485a293b2 100644 --- a/crates/openpencil-shell-core/src/widgets/property_panel.rs +++ b/crates/openpencil-shell-core/src/widgets/property_panel.rs @@ -381,6 +381,8 @@ impl PropertyPanel { opacity: caps.opacity, fill: caps.fill, stroke: caps.stroke, + effects: caps.effects, + export: caps.export, fill_type: self.fill_type, }; let rects = sections::action_button_rects_with_fill_picker( @@ -416,6 +418,8 @@ impl PropertyPanel { opacity: caps.opacity, fill: caps.fill, stroke: caps.stroke, + effects: caps.effects, + export: caps.export, fill_type: self.fill_type, }; for (focus, rect) in sections::editable_input_rects(panel_rect, visible) { @@ -563,6 +567,8 @@ impl Widget for PropertyPanel { opacity: caps.opacity, fill: caps.fill, stroke: caps.stroke, + effects: caps.effects, + export: caps.export, fill_type: self.fill_type, }; sections::paint_fill_type_picker(cx, &self.theme, rect, visible, self.fill_type); @@ -637,6 +643,58 @@ mod tests { assert!(panel.snapshot.height > 0); } + #[test] + fn hit_test_action_export_section_returns_open_dialog() { + // Single-frame selection paints every section + Export. + // The walker now extends through Stroke + Effects so the + // Export row's hit-test rect resolves to OpenExportDialog. + let mut doc = Document::sample(); + // NodeId 10 is the Frame at the root of the sample document + // (mutators.rs::sample). Frame paints every section including + // Stroke + Effects + Export so the walker has to advance past + // all of them to reach the Export row. + doc.set_single_selection(NodeId::new(10)); + let panel = PropertyPanel::for_selection(&doc).expect("frame panel"); + // Tall panel rect so every section fits without clipping. + let rect = Rect { + origin: Point2D::new(0.0, 0.0), + size: Point2D::new(280.0, 1600.0), + }; + // The Export section is the last section painted. Walk + // the action-button rects looking for the OpenExportDialog + // rect, then click its center and assert we get back the + // OpenExportDialog action. + let caps = SectionCapabilities::for_kind(&panel.snapshot.kind_variant); + let visible = sections::VisibleSections { + flex_layout: caps.flex_layout, + size_options: caps.size_options, + opacity: caps.opacity, + fill: caps.fill, + stroke: caps.stroke, + effects: caps.effects, + export: caps.export, + fill_type: panel.fill_type, + }; + let rects = sections::action_button_rects_with_fill_picker(rect, visible, false); + let export_rect = rects + .iter() + .find(|(action, _)| { + matches!(action, PropertyPanelAction::OpenExportDialog) + }) + .map(|(_, r)| *r) + .expect("export section must emit an OpenExportDialog rect"); + let center = Point2D::new( + export_rect.origin.x + export_rect.size.x / 2.0, + export_rect.origin.y + export_rect.size.y / 2.0, + ); + let hit = panel.hit_test_action(rect, center); + assert!( + matches!(hit, Some(PropertyPanelAction::OpenExportDialog)), + "click in Export section should resolve to OpenExportDialog, got {:?}", + hit + ); + } + #[test] fn format_color_hex_pads_to_six_chars() { use crate::widgets::property_panel_inputs::format_color_hex; diff --git a/crates/openpencil-shell-core/src/widgets/property_panel_layout.rs b/crates/openpencil-shell-core/src/widgets/property_panel_layout.rs index ec13a498e..6c34c4c5b 100644 --- a/crates/openpencil-shell-core/src/widgets/property_panel_layout.rs +++ b/crates/openpencil-shell-core/src/widgets/property_panel_layout.rs @@ -26,6 +26,14 @@ pub struct VisibleSections { pub fill: bool, /// `StrokeHex` + `StrokeWidth` from the Stroke section. pub stroke: bool, + /// Effects section paints (header + add chip, no inputs yet). + /// Tracked because the export-rect walker needs to know + /// whether it consumed vertical space ahead of the Export + /// section. + pub effects: bool, + /// Export section paints — `OpenExportDialog` action emits + /// only when this is true. + pub export: bool, /// Active fill type — affects fill-section body height so /// the walk past Fill stays aligned with paint when the user /// flips Solid / Gradient / Image. @@ -40,6 +48,8 @@ impl VisibleSections { opacity: true, fill: true, stroke: true, + effects: true, + export: true, fill_type: FillType::Solid, }; } @@ -216,7 +226,44 @@ pub fn action_button_rects_with_fill_picker( )); } } + // Consume the rest of the Fill section so subsequent + // sections' y math stays aligned with paint. Mirrors the + // y-walk in `paint_fill_section`: head row + body + divider. + y += INPUT_HEIGHT + 6.0; // head row (swatch + dropdown + opacity + X) + y += fill_body_height(visible.fill_type) - 6.0 + 12.0; // body + divider gap + y += SECTION_GAP; } + if visible.stroke { + // Mirrors paint_stroke_section: header + hex/width row. + y += SECTION_HEADER_HEIGHT; + y += INPUT_HEIGHT + 12.0; + y += SECTION_GAP; + } + if visible.effects { + // Mirrors paint_effects_section: header + 8 px filler. + y += SECTION_HEADER_HEIGHT; + y += 8.0; + y += SECTION_GAP; + } + if visible.export { + // Mirrors paint_export_section: header + a single dropdown + // row. We emit one `OpenExportDialog` rect covering both + // the scale + format pills + the full label strip so + // clicking anywhere in the section opens the dialog. The + // ExportDialog modal owns the format/scale picker UI, so + // segmenting the row into two separate pills isn't worth + // the extra hit-test complexity yet. + y += SECTION_HEADER_HEIGHT; + let row = Rect { + origin: Point2D::new(x0 + PAD_X, y), + size: Point2D::new(usable_w, INPUT_HEIGHT), + }; + out.push((PropertyPanelAction::OpenExportDialog, row)); + // No further sections after Export today, but maintain the + // y advance for symmetry / future additions. + y += INPUT_HEIGHT + 12.0; + } + let _ = y; // suppress unused-write lint if export is last out }