From 3adaad0ae627f9cdeb938b3747eeb71a675ec22b Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 14 May 2026 17:15:58 +0800 Subject: [PATCH] feat(panels): PropertyPanel Export section click opens ExportDialog (Task #52) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "P3. PropertyPanel Export section: real buttons" task that was left preview-only by the export chain (P1 raster export + P2 ExportDialog modal). The Export section paints two pill dropdowns (scale + format) but no clicks were wired — opening the dialog required Cmd+Shift+P or the File menu. `property_panel_layout.rs`: - `VisibleSections` grew `effects: bool` + `export: bool`, both required for the action-button walker to compute the y-position of the Export section. `ALL` const + every construction site updated. - `action_button_rects_with_fill_picker` now continues its y-walk past Fill through Stroke + Effects sections (consuming the paint-side heights so the rect math stays aligned), then emits one `OpenExportDialog` rect spanning the full Export row. Clicking anywhere on the row resolves to OpenExportDialog — splitting scale + format into separate hit zones isn't worth it because the ExportDialog modal owns both pickers. - Fill-section walker now explicitly consumes the head row + body + divider gap heights it had been implicit about. Without this any section after Fill (Stroke / Effects / Export) sat at the wrong y in the hit-test space. `property_panel.rs`: - The three `VisibleSections` construction sites (action hit-test, input hit-test, fill-picker overlay paint) now thread `caps.effects` + `caps.export`. Mechanical sed-based patch since the three sites are identical. The downstream wiring was already in place — `widget_host/ property_dispatch.rs` handles `A::OpenExportDialog` by queueing `FileAction::ExportImage` so the desktop binary's save-dialog + real raster export kicks in. P3 was the missing piece between user intent (click the section) and the existing pipeline. Tests (1 added): - `hit_test_action_export_section_returns_open_dialog` — selects the sample Frame (NodeId 10), walks `action_button_rects_with_ fill_picker` to find the OpenExportDialog rect, clicks its center, asserts hit_test_action returns OpenExportDialog. This is the rect-walker contract that paint + hit-test stay in sync even when sections after Fill are visible. Total shell-core tests: 251 (was 250). --- .../src/widgets/property_panel.rs | 58 +++++++++++++++++++ .../src/widgets/property_panel_layout.rs | 47 +++++++++++++++ 2 files changed, 105 insertions(+) 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 }