feat(panels): PropertyPanel Export section click opens ExportDialog (Task #52)

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).
This commit is contained in:
Kayshen-X 2026-05-14 17:15:58 +08:00
parent c3d0bb30cd
commit 3adaad0ae6
2 changed files with 105 additions and 0 deletions

View file

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

View file

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