openpencil/crates/op-editor-ui/src/widgets/property_panel.rs
Kayshen-X b0b52a7842
Some checks failed
Rust check (native) / ubuntu-latest / 1.94 (push) Failing after 2s
Rust check (native) / cargo-deny (native) (push) Failing after 1s
Rust check (native) / diagnostics golden drift (push) Failing after 2s
Rust multi-platform build / linux-x86_64 (push) Failing after 1s
Rust multi-platform build / wasm32-unknown-unknown / op-host-web (compile guard) (push) Failing after 2s
Rust multi-platform build / android-aarch64 (cargo check only) (push) Failing after 2s
Rust multi-platform build / android-x86_64 (cargo check only) (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo check --target wasm32-unknown-unknown (push) Failing after 2s
WASM bundle check (kickoff §1.2) / cargo-deny --target wasm32-unknown-unknown check bans (push) Failing after 1s
Rust check (native) / macos-latest / 1.94 (push) Has been cancelled
Rust check (native) / windows-latest / 1.94 (push) Has been cancelled
Rust multi-platform build / linux-aarch64 (push) Has been cancelled
Rust multi-platform build / macos-aarch64 (push) Has been cancelled
Rust multi-platform build / windows-x86_64 (push) Has been cancelled
Rust multi-platform build / macos-x86_64 (push) Has been cancelled
Rust multi-platform build / windows-aarch64 (push) Has been cancelled
Rust multi-platform build / ios-aarch64 (cargo check only) (push) Has been cancelled
Rust multi-platform build / ios-aarch64-sim (cargo check only) (push) Has been cancelled
feat(panels): improve native property controls and icons
2026-05-24 23:30:00 +08:00

715 lines
26 KiB
Rust

//! `PropertyPanel` — right-rail node inspector (Step 6).
//!
//! Mirrors `apps/web/src/components/panels/right-panel.tsx` and the
//! per-section TS files (`*-section.tsx`). The bulk of the paint
//! logic lives in [`super::property_panel_sections`] — this file
//! holds the `PropertyPanel` struct, the `Widget` impl, and wiring
//! around snapshot extraction. Splitting the file keeps the pieces
//! under the openpencil 800-line ceiling.
//!
//! Sections (top → bottom, mirroring TS order):
//! 1. Tab strip (Design / Code)
//! 2. Header (kind label) + Create component button
//! 3. Position — X / Y / rotation / R
//! 4. Flex layout — 3 layout-mode buttons
//! 5. Size — W / H + 5 sizing checkboxes
//! 6. Layer — opacity row
//! 7. Fill — solid color rows + add affordance
//! 8. Stroke — color + width row
//! 9. Effects — empty list + add affordance
//! 10. Export — scale + format dropdowns
//!
//! Conditional rendering: TS app does `{hasSelection && <RightPanel/>}`.
//! Host calls [`PropertyPanel::for_selection`] which returns
//! `Option<Self>`; `None` = panel hidden entirely.
use crate::theme::Theme;
use crate::widgets::editor_state_ext::theme_for;
use crate::widgets::property_panel_sections as sections;
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
use crate::{Point2D, Rect};
use op_editor_core::PropertyFocus;
use op_editor_core::EditorState;
pub const PROPERTY_PANEL_WIDTH: f32 = 280.0;
// `PropertyPanelAction` lives in `property_panel_action.rs` (split
// out for the 800-line ceiling); re-exported so every existing
// `widgets::PropertyPanelAction` / `property_panel::PropertyPanelAction`
// path is unchanged.
pub use crate::widgets::property_panel_action::{
FontFamilyChoice, LayoutAlignValue, LayoutJustifyValue, PropertyPanelAction, TextAlignValue,
TextGrowthValue, TextVerticalAlignValue,
};
// `SectionCapabilities` lives in `property_panel_layout.rs`
// alongside `VisibleSections` (the section-visibility mask it
// feeds); re-exported so `property_panel::SectionCapabilities`
// resolves unchanged.
pub(crate) use crate::widgets::property_panel_layout::SectionCapabilities;
pub use crate::widgets::property_panel_snapshot::{
EffectKind, EffectSummary, EllipseArcSummary, GradientStopSummary, NodeSnapshot,
};
pub struct PropertyPanel {
pub id: WidgetId,
pub snapshot: NodeSnapshot,
pub theme: Theme,
/// Localised chrome strings — `Document::t` lookups resolved
/// once at construction time so every section paint hands
/// straight to the renderer without re-walking the i18n table.
pub labels: sections::PropertyLabels,
/// Which input row the user is editing. `None` when no input
/// is focused (panel paints all values from the snapshot).
pub focus: Option<PropertyFocus>,
/// Live edit-buffer for the focused input. Empty when nothing
/// is focused. The host fills this on click + mutates on
/// keystroke; the panel paints it as the field's value.
pub draft: String,
/// Caret byte-offset into `draft` (ASCII drafts → char index).
pub caret_pos: usize,
/// Caret-blink anchor (ms since host start) for the focused
/// input. Drives the same `jian_core::anim::blink_visible`
/// helper the chat caret uses.
pub caret_anchor_ms: u64,
/// Host clock ms; paired with `caret_anchor_ms` for caret blink.
pub now_ms: u64,
/// Active flex-layout button.
pub flex_layout: op_editor_core::FlexLayout,
/// 5 size checkboxes — fill / hug / clip.
pub size_flags: sections::SizeFlags,
/// Active fill type — drives the dropdown label + picker.
pub fill_type: op_editor_core::FillType,
pub fill_type_picker_open: bool,
pub image_fill_popover_open: bool,
pub font_family_picker_open: bool,
/// True for multi-select aggregate (inputs inert, "N items").
pub is_multi: bool,
/// Active header tab — toggled by Cmd+Shift+C.
pub tab: op_editor_core::PropertyTab,
/// Current export format + scale, shown on the Export section's
/// two dropdowns. Clicking a dropdown opens its inline select
/// popup (NOT the Export modal).
pub export_format: op_editor_core::ExportFormat,
pub export_scale: f32,
/// Whether the Export section's scale / format inline select
/// popups are open.
pub export_scale_picker_open: bool,
pub export_format_picker_open: bool,
/// Row index the cursor is over in the open Export select
/// popup — `None` when no popup is open or no row is hovered.
pub export_picker_hover: Option<usize>,
/// Vertical scroll offset (px, ≥ 0) — paint + hit-test shift the
/// section content up by this so a tall inspector stays usable.
pub scroll: f32,
/// Active UI locale — threaded into the Fill section so its
/// type label / picker / body sub-labels translate.
pub locale: op_editor_core::Locale,
/// Focused effect-parameter value, if any — drives the Effects
/// section's editable value boxes.
pub effect_param_focus: Option<op_editor_core::editor_ui_state::EffectParamFocus>,
}
impl PropertyPanel {
/// Capability mask that drives which sections paint for this
/// panel state. Multi-select uses a dedicated mask (`for_multi`)
/// that keeps Size + Position + Layer + Effects + Export and
/// hides Flex + Fill + Stroke; single-select falls back to the
/// snapshot's `kind_variant` (`for_kind`). Paint + hit-test
/// must call this rather than `SectionCapabilities::for_kind`
/// directly so the multi-select carve-out can't regress
/// silently.
pub(crate) fn capabilities(&self) -> SectionCapabilities {
if self.is_multi {
SectionCapabilities::for_multi()
} else {
SectionCapabilities::for_kind(&self.snapshot.kind_variant)
}
}
}
impl PropertyPanel {
/// Conditional builder — returns `Some` only when the editor
/// has an active selection. Mirrors TS `{hasSelection && ...}`.
pub fn for_selection(state: &EditorState) -> Option<Self> {
Self::for_selection_at(state, 0)
}
/// Same as [`for_selection`] but threads the host's monotonic
/// millisecond clock through so the focused-input caret can
/// blink off the same animation timer as the chat input.
pub fn for_selection_at(state: &EditorState, now_ms: u64) -> Option<Self> {
if state.selection_count() == 1 {
let node = state.selected_node()?;
let fill_type = op_editor_core::first_fill_type(node);
return Some(Self::build_from_snapshot(
state,
NodeSnapshot::from_node(node),
fill_type,
now_ms,
false,
));
}
if state.selection_count() >= 2 {
let snapshot = NodeSnapshot::from_multi_selection(state)?;
return Some(Self::build_from_snapshot(
state,
snapshot,
op_editor_core::FillType::Solid,
now_ms,
true,
));
}
None
}
fn build_from_snapshot(
state: &EditorState,
snapshot: NodeSnapshot,
fill_type: op_editor_core::FillType,
now_ms: u64,
is_multi: bool,
) -> Self {
let ui = &state.editor_ui;
let flex_layout = snapshot.flex_layout;
let size_flags = sections::SizeFlags {
fill_width: snapshot.size_fill_width,
fill_height: snapshot.size_fill_height,
hug_width: snapshot.size_hug_width,
hug_height: snapshot.size_hug_height,
clip_content: snapshot.size_clip_content,
};
Self {
id: WidgetId::new(2000),
snapshot,
theme: theme_for(ui),
labels: sections::PropertyLabels::for_editor_ui(ui),
// Multi-select inputs are inert in v1 — broadcast edits
// to all selected nodes lands later. Force focus to None
// so the panel paints all values muted and hit_test
// returns None (see `hit_test` is_multi short-circuit).
focus: if is_multi {
None
} else {
state.ui.property_focus
},
draft: if is_multi {
String::new()
} else {
state.ui.property_input_draft.clone()
},
caret_pos: if is_multi {
0
} else {
state.ui.property_caret_pos
},
caret_anchor_ms: state.ui.property_caret_anchor_ms,
now_ms,
flex_layout,
size_flags,
fill_type,
fill_type_picker_open: ui.fill_type_picker_open,
image_fill_popover_open: ui.image_fill_popover_open,
font_family_picker_open: ui.font_family_picker_open,
is_multi,
tab: ui.property_tab,
export_format: ui.export_format,
export_scale: ui.export_scale,
export_scale_picker_open: ui.export_scale_picker_open,
export_format_picker_open: ui.export_format_picker_open,
export_picker_hover: ui.export_picker_hover,
scroll: ui.property_panel_scroll.max(0.0),
locale: ui.locale,
// Inert in the multi-select aggregate view.
effect_param_focus: if is_multi {
None
} else {
ui.effect_param_focus
},
}
}
/// `self.scroll` clamped to the current content's scrollable
/// range. The host only re-clamps the stored offset on a wheel
/// event, so selecting a shorter node (fewer sections / effects)
/// could otherwise leave the panel scrolled past its end —
/// every paint / hit-test reads through this so the view
/// self-corrects on the very next frame.
fn effective_scroll(&self, panel_rect: Rect) -> f32 {
let max = (self.content_height(panel_rect) - panel_rect.size.y).max(0.0);
self.scroll.clamp(0.0, max)
}
/// `panel_rect` shifted up by the (clamped) scroll offset. Both
/// paint and every hit-test walker start their y-walk from this
/// rect, so the panel scrolls as one piece and clicks stay
/// aligned with what is drawn.
fn scrolled_rect(&self, panel_rect: Rect) -> Rect {
Rect {
origin: Point2D::new(
panel_rect.origin.x,
panel_rect.origin.y - self.effective_scroll(panel_rect),
),
size: panel_rect.size,
}
}
/// Whether `point` is inside the scrolling section viewport —
/// the panel below the pinned tab strip. A click in the tab-strip
/// band must not fall through to a section row scrolled up
/// under it (paint clips there; hit-test must agree).
fn point_in_section_viewport(&self, panel_rect: Rect, point: Point2D) -> bool {
point.y >= panel_rect.origin.y + crate::widgets::property_panel_inputs::TAB_HEIGHT
}
/// Total height (px) of the panel's section content — drives the
/// scroll clamp so the inspector can't scroll past its end.
pub fn content_height(&self, panel_rect: Rect) -> f32 {
sections::property_panel_content_height(
panel_rect,
self.visible_sections(),
&self.snapshot.effects,
)
}
/// Section-visibility mask for the current selection, threaded
/// into every layout walker so paint + hit-test stay aligned.
fn visible_sections(&self) -> sections::VisibleSections {
let caps = self.capabilities();
sections::VisibleSections {
create_component: caps.create_component && self.snapshot.can_create_component,
flex_layout: caps.flex_layout,
flex_layout_mode: self.snapshot.flex_layout,
layout_justify: self.snapshot.layout_justify,
layout_align: self.snapshot.layout_align,
size_options: caps.size_options,
clip_content: self.snapshot.can_clip_content,
text: caps.text && self.snapshot.text.is_some(),
icon: self.snapshot.icon.is_some(),
image: caps.image && self.snapshot.is_image_node,
opacity: caps.opacity,
corner_radius: self.snapshot.has_corner_radius,
polygon_sides: self.snapshot.polygon_sides.is_some(),
ellipse_arc: self.snapshot.ellipse_arc.is_some(),
fill: caps.fill,
stroke: caps.stroke,
effects: caps.effects,
export: caps.export,
fill_type: self.fill_type,
gradient_stop_count: self.snapshot.gradient_stops.len(),
}
}
/// Hit-test the flex / size buttons + checkboxes. Returns the
/// action the host should dispatch, or `None` if the cursor
/// missed every clickable shape. Called AFTER `hit_test` so
/// text inputs win over the action rects they overlap with.
pub fn hit_test_action(&self, panel_rect: Rect, point: Point2D) -> Option<PropertyPanelAction> {
if self.is_multi {
// Multi-select inputs / toggles are inert in v1.
return None;
}
if self.image_fill_popover_open {
if let Some(action) = sections::image_fill_popover_action_at(
self.scrolled_rect(panel_rect),
self.visible_sections(),
&self.snapshot,
point,
) {
return Some(action);
}
}
if !self.point_in_section_viewport(panel_rect, point) {
return None;
}
let rects = sections::action_button_rects_with_fill_picker(
self.scrolled_rect(panel_rect),
self.visible_sections(),
&self.snapshot.effects,
self.fill_type_picker_open,
self.font_family_picker_open,
self.export_scale_picker_open,
self.export_format_picker_open,
);
// Picker rows live in `rects` AFTER the dropdown rect, so
// a row hit takes priority — `rev()` makes the picker rows
// tested first and short-circuits before the dropdown
// toggle, otherwise clicking a row would just re-toggle.
for (action, rect) in rects.into_iter().rev() {
if rect_contains(rect, point) {
return Some(action);
}
}
None
}
/// Row index of the open Export select popup under `point`, or
/// `None` when no popup is open / the cursor is off every row.
/// The index counts only the option rows (`SetExportScale` /
/// `SetExportFormat`), matching `paint_select_popup`'s row walk,
/// so it can drive the popup's hover highlight.
pub fn export_picker_row_at(&self, panel_rect: Rect, point: Point2D) -> Option<usize> {
if !self.export_scale_picker_open && !self.export_format_picker_open {
return None;
}
if !self.point_in_section_viewport(panel_rect, point) {
return None;
}
sections::action_button_rects_with_fill_picker(
self.scrolled_rect(panel_rect),
self.visible_sections(),
&self.snapshot.effects,
self.fill_type_picker_open,
self.font_family_picker_open,
self.export_scale_picker_open,
self.export_format_picker_open,
)
.into_iter()
.filter(|(a, _)| {
matches!(
a,
PropertyPanelAction::SetExportScale(_) | PropertyPanelAction::SetExportFormat(_)
)
})
.position(|(_, rect)| rect_contains(rect, point))
}
pub fn image_adjustment_drag_action(
&self,
panel_rect: Rect,
field: op_editor_core::ImageAdjustmentField,
x: f32,
) -> Option<PropertyPanelAction> {
if self.is_multi || !self.image_fill_popover_open {
return None;
}
sections::image_fill_popover_adjustment_action_for_drag(
self.scrolled_rect(panel_rect),
self.visible_sections(),
field,
x,
)
}
pub fn image_fill_popover_contains(&self, panel_rect: Rect, point: Point2D) -> bool {
!self.is_multi
&& self.image_fill_popover_open
&& sections::image_fill_popover_contains(
self.scrolled_rect(panel_rect),
self.visible_sections(),
point,
)
}
/// Hit-test the panel at `point` and return which input row
/// (if any) contains the click. The layout walk mirrors the
/// per-kind section filtering applied in `paint`, so rects
/// after a skipped section don't drift out of alignment.
pub fn hit_test(&self, panel_rect: Rect, point: Point2D) -> Option<PropertyFocus> {
if self.is_multi {
// Inputs inert in v1 multi-select aggregate view.
return None;
}
if !self.point_in_section_viewport(panel_rect, point) {
return None;
}
for (focus, rect) in
sections::editable_input_rects(self.scrolled_rect(panel_rect), self.visible_sections())
{
if rect_contains(rect, point) {
return Some(focus);
}
}
None
}
}
fn rect_contains(r: Rect, p: Point2D) -> bool {
p.x >= r.origin.x
&& p.x <= r.origin.x + r.size.x
&& p.y >= r.origin.y
&& p.y <= r.origin.y + r.size.y
}
use crate::widgets::property_panel_code::paint_code_placeholder;
impl Widget for PropertyPanel {
fn id(&self) -> WidgetId {
self.id
}
fn layout(&self, cx: &LayoutCx) -> LayoutBox {
// Vertical extent is "as much as you give me" — the host
// clips at the rail rect. Reporting 800 here is just a
// placeholder for the abstract widget tree.
LayoutBox {
rect: Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(cx.available_width, 800.0),
},
}
}
fn paint(&self, cx: &mut PaintCx<'_>, rect: Rect) {
cx.backend.fill_rect(rect, self.theme.card);
cx.backend.fill_rect(
Rect {
origin: rect.origin,
size: Point2D::new(1.0, rect.size.y),
},
self.theme.border,
);
let x = rect.origin.x;
let w = rect.size.x;
// The Design / Code tab strip is pinned to the panel top —
// painted fixed, above (and never scrolled with) the section
// content.
let tab_bottom =
sections::paint_tab_strip(cx, &self.theme, &self.labels, self.tab, x, rect.origin.y, w);
let edit_ctx = sections::EditContext {
focus: self.focus,
draft: self.draft.as_str(),
caret: self.caret_pos,
caret_anchor_ms: self.caret_anchor_ms,
now_ms: self.now_ms,
};
let caps = self.capabilities();
if matches!(self.tab, op_editor_core::PropertyTab::Code) {
paint_code_placeholder(cx, &self.theme, &self.snapshot, x, tab_bottom, w);
return;
}
// Section content scrolls below the pinned tab strip; clip it
// so a scrolled-up section can't paint over the tabs or bleed
// onto the neighbouring rail. Overlays (fill / export pickers)
// anchor to `scrolled` — the same shifted rect the layout
// walker uses (it adds `TAB_HEIGHT`), so paint + hit-test of
// the sections agree.
cx.backend.save();
cx.backend.clip_rect(Rect {
origin: Point2D::new(x, tab_bottom),
size: Point2D::new(w, (rect.origin.y + rect.size.y - tab_bottom).max(0.0)),
});
let scroll = self.effective_scroll(rect);
let scrolled = Rect {
origin: Point2D::new(rect.origin.x, rect.origin.y - scroll),
size: rect.size,
};
// First section sits just below the pinned tab strip:
// `tab_bottom - scroll` == `scrolled.origin.y + TAB_HEIGHT`,
// matching the layout walker's `+= TAB_HEIGHT` step.
let mut y = tab_bottom - scroll;
y = sections::paint_node_header(cx, &self.theme, &self.snapshot, x, y, w);
if caps.create_component && self.snapshot.can_create_component {
y = sections::paint_create_component(cx, &self.theme, &self.labels, x, y, w);
}
y = sections::paint_position_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
&self.labels,
self.snapshot.has_corner_radius,
x,
y,
w,
);
if caps.flex_layout {
y = crate::widgets::property_panel_flex::paint_flex_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
&self.labels,
self.locale,
x,
y,
w,
);
}
if caps.size_options {
y = sections::paint_size_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
&self.labels,
self.size_flags,
self.snapshot.can_clip_content,
x,
y,
w,
);
}
if self.snapshot.icon.is_some() {
y = crate::widgets::property_panel_icon::paint_icon_section(
cx,
&self.theme,
&self.snapshot,
self.locale,
x,
y,
w,
);
}
if caps.text && self.snapshot.text.is_some() {
y = crate::widgets::property_panel_text::paint_text_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
self.locale,
x,
y,
w,
);
}
if caps.image && self.snapshot.is_image_node {
y = crate::widgets::property_panel_image_node::paint_image_node_section(
cx,
&self.theme,
&self.snapshot,
self.locale,
x,
y,
w,
);
}
if caps.opacity {
y = sections::paint_layer_section(
cx,
&self.theme,
&self.snapshot,
&self.labels,
&edit_ctx,
x,
y,
w,
);
}
if caps.fill {
y = sections::paint_fill_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
&self.labels,
self.fill_type,
self.fill_type_picker_open,
self.locale,
x,
y,
w,
);
}
if caps.stroke {
y = sections::paint_stroke_section(
cx,
&self.theme,
&self.snapshot,
&edit_ctx,
&self.labels,
x,
y,
w,
);
}
if caps.effects {
y = sections::paint_effects_section(
cx,
&self.theme,
&self.labels,
&self.snapshot.effects,
&edit_ctx,
self.effect_param_focus,
x,
y,
w,
);
}
if caps.export {
let _ = sections::paint_export_section(
cx,
&self.theme,
&self.labels,
self.export_format,
self.export_scale,
x,
y,
w,
);
}
// Fill-type picker overlay sits on top of everything below
// the Fill section so it can extend past the section divider.
if caps.fill && self.fill_type_picker_open {
sections::paint_fill_type_picker(
cx,
&self.theme,
scrolled,
self.visible_sections(),
self.fill_type,
self.locale,
);
}
if caps.text && self.font_family_picker_open {
if let Some(text) = self.snapshot.text.as_ref() {
crate::widgets::property_panel_text::paint_font_family_picker(
cx,
&self.theme,
scrolled,
self.visible_sections(),
&text.font_family,
);
}
}
// Export-section inline select popups — painted last so the
// scale / format dropdown overlays sit above every section.
if caps.export && (self.export_scale_picker_open || self.export_format_picker_open) {
sections::paint_export_picker(
cx,
&self.theme,
scrolled,
self.visible_sections(),
&self.snapshot.effects,
self.export_scale_picker_open,
self.export_format_picker_open,
self.export_scale,
self.export_format,
self.export_picker_hover,
);
}
cx.backend.restore();
}
fn access_node(&self) -> accesskit::Node {
let mut node = accesskit::Node::new(accesskit::Role::Group);
node.set_label(self.snapshot.kind.clone());
node
}
}
impl PropertyPanel {
/// Paint inspector overlays that are allowed to extend out of the
/// right rail. Hosts call this late in their composition pass so
/// the image-fill popover is above floating canvas controls.
pub fn paint_overlays(&self, cx: &mut PaintCx<'_>, rect: Rect) {
let caps = self.capabilities();
if !(caps.fill || caps.image) || !self.image_fill_popover_open {
return;
}
let scroll = self.effective_scroll(rect);
let scrolled = Rect {
origin: Point2D::new(rect.origin.x, rect.origin.y - scroll),
size: rect.size,
};
sections::paint_image_fill_popover(
cx,
&self.theme,
scrolled,
self.visible_sections(),
&self.snapshot,
self.locale,
);
}
}