From 2766952b3e410b0f61d37d55ca00a42470c04cb6 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Sun, 24 May 2026 19:03:11 +0800 Subject: [PATCH] fix(editor): improve shape controls and drag snapping --- Cargo.lock | 162 -------- .../op-editor-core/src/command_node_attrs.rs | 18 + crates/op-editor-core/src/mutators.rs | 55 ++- crates/op-editor-core/src/ui_draft.rs | 11 + crates/op-editor-ui/src/layout_scene.rs | 19 + crates/op-editor-ui/src/layout_scene_hit.rs | 23 +- .../src/widgets/canvas_viewport_paint.rs | 16 +- crates/op-editor-ui/src/widgets/mod.rs | 1 + .../src/widgets/property_panel.rs | 15 +- .../widgets/property_panel_input_layout.rs | 31 +- .../src/widgets/property_panel_layer.rs | 200 ++++++++++ .../src/widgets/property_panel_layout.rs | 23 +- .../src/widgets/property_panel_sections.rs | 98 +---- .../src/widgets/property_panel_snapshot.rs | 33 ++ .../src/widgets/property_panel_tests.rs | 75 ++++ crates/op-host-desktop/src/export.rs | 22 +- .../op-host-desktop/src/export/export_svg.rs | 17 +- .../op-host-native/src/widget_host/input.rs | 37 +- .../src/widget_host/input_tests.rs | 149 +++++++- .../src/widget_host/press_helpers.rs | 30 ++ crates/op-pen-loader/src/adapter.rs | 2 + crates/op-pen-loader/src/layout_scene.rs | 1 + crates/op-pen-loader/src/payload.rs | 14 + crates/op-smoke/Cargo.toml | 28 -- crates/op-smoke/src/main.rs | 345 ------------------ 25 files changed, 731 insertions(+), 694 deletions(-) create mode 100644 crates/op-editor-ui/src/widgets/property_panel_layer.rs delete mode 100644 crates/op-smoke/Cargo.toml delete mode 100644 crates/op-smoke/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 6cce46e70..9a7ddc125 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,7 +37,6 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" name = "agent" version = "0.1.0" dependencies = [ - "async-openai", "async-trait", "bytes", "eventsource-stream", @@ -289,45 +288,6 @@ dependencies = [ "futures-lite", ] -[[package]] -name = "async-openai" -version = "0.36.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dafa6acfa9d5138539abe815de90b0a4b7127420e6846c71bb23cf68660641ba" -dependencies = [ - "async-openai-macros", - "backoff", - "base64", - "bytes", - "derive_builder", - "eventsource-stream", - "futures", - "getrandom 0.3.4", - "rand 0.9.4", - "reqwest 0.13.3", - "secrecy", - "serde", - "serde_json", - "serde_urlencoded", - "thiserror 2.0.18", - "tokio", - "tokio-stream", - "tokio-util", - "tracing", - "url", -] - -[[package]] -name = "async-openai-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81872a8e595e8ceceab71c6ba1f9078e313b452a1e31934e6763ef5d308705e4" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "async-process" version = "2.5.0" @@ -448,20 +408,6 @@ dependencies = [ "fs_extra", ] -[[package]] -name = "backoff" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1" -dependencies = [ - "futures-core", - "getrandom 0.2.17", - "instant", - "pin-project-lite", - "rand 0.8.6", - "tokio", -] - [[package]] name = "base64" version = "0.22.1" @@ -870,41 +816,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" -[[package]] -name = "darling" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" -dependencies = [ - "darling_core", - "darling_macro", -] - -[[package]] -name = "darling_core" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim", - "syn", -] - -[[package]] -name = "darling_macro" -version = "0.20.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" -dependencies = [ - "darling_core", - "quote", - "syn", -] - [[package]] name = "data-encoding" version = "2.11.0" @@ -922,37 +833,6 @@ dependencies = [ "syn", ] -[[package]] -name = "derive_builder" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" -dependencies = [ - "derive_builder_macro", -] - -[[package]] -name = "derive_builder_core" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" -dependencies = [ - "darling", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "derive_builder_macro" -version = "0.20.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" -dependencies = [ - "derive_builder_core", - "syn", -] - [[package]] name = "deunicode" version = "1.6.2" @@ -1860,12 +1740,6 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" -[[package]] -name = "ident_case" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" - [[package]] name = "idna" version = "1.1.0" @@ -1943,15 +1817,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "ipnet" version = "2.12.0" @@ -2990,17 +2855,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "op-smoke" -version = "0.1.0" -dependencies = [ - "agent", - "futures", - "op-editor-core", - "op-orchestrator", - "tokio", -] - [[package]] name = "openssl-probe" version = "0.2.1" @@ -3865,16 +3719,6 @@ dependencies = [ "tiny-skia", ] -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "serde", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -4166,12 +4010,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - [[package]] name = "subtle" version = "2.6.1" diff --git a/crates/op-editor-core/src/command_node_attrs.rs b/crates/op-editor-core/src/command_node_attrs.rs index 6d07cd6be..6980fadfc 100644 --- a/crates/op-editor-core/src/command_node_attrs.rs +++ b/crates/op-editor-core/src/command_node_attrs.rs @@ -133,6 +133,24 @@ impl EditorState { write_corner_radius(node, radius as f64) } + /// Set the side count on a Polygon node. The TS panel allows + /// `3..=100`; keep the same guard here so all callers converge. + pub(crate) fn cmd_set_polygon_count(&mut self, node_id: &NodeId, count: u32) -> bool { + if !node_id.is_real() || !self.is_editable(node_id) { + return false; + } + let Some(node) = find_node_mut(self.active_children_mut(), node_id) else { + return false; + }; + match node { + PenNode::Polygon(p) => { + p.polygon_count = count.clamp(3, 100); + true + } + _ => false, + } + } + /// `SetNodeFontSize` — set the font size on a Text node. Rejects /// non-Text kinds + non-positive sizes. pub(crate) fn cmd_set_node_font_size(&mut self, node_id: &NodeId, font_size: f32) -> bool { diff --git a/crates/op-editor-core/src/mutators.rs b/crates/op-editor-core/src/mutators.rs index a54acad10..7c227d974 100644 --- a/crates/op-editor-core/src/mutators.rs +++ b/crates/op-editor-core/src/mutators.rs @@ -436,6 +436,49 @@ impl EditorState { if !sel.is_real() || !self.is_editable(&sel) { return false; } + match focus { + PropertyFocus::PositionR => return self.cmd_set_node_corner_radius(&sel, value), + PropertyFocus::PolygonSides => { + if !value.is_finite() { + return false; + } + return self.cmd_set_polygon_count(&sel, value.round().clamp(3.0, 100.0) as u32); + } + PropertyFocus::EllipseStart => { + if !value.is_finite() { + return false; + } + return self.cmd_set_ellipse_arc( + &sel, + Some(value.clamp(0.0, 360.0) as f64), + None, + None, + ); + } + PropertyFocus::EllipseSweep => { + if !value.is_finite() { + return false; + } + return self.cmd_set_ellipse_arc( + &sel, + None, + Some(value.clamp(0.0, 360.0) as f64), + None, + ); + } + PropertyFocus::EllipseInnerRadius => { + if !value.is_finite() { + return false; + } + return self.cmd_set_ellipse_arc( + &sel, + None, + None, + Some((value.clamp(0.0, 99.0) / 100.0) as f64), + ); + } + _ => {} + } let Some(node) = find_node_mut(self.active_children_mut(), &sel) else { return false; }; @@ -449,10 +492,16 @@ impl EditorState { // Property panel ships degrees; schema stores degrees. node.base_mut().rotation = Some(v); } - // Corner-radius, hex + opacity edits route through + PropertyFocus::PositionR => unreachable!("corner radius handled before node borrow"), + PropertyFocus::PolygonSides + | PropertyFocus::EllipseStart + | PropertyFocus::EllipseSweep + | PropertyFocus::EllipseInnerRadius => { + unreachable!("shape-specific properties handled before node borrow") + } + // Hex + opacity edits route through // dedicated setters (not a single base-field write). - PropertyFocus::PositionR - | PropertyFocus::StrokeWidth + PropertyFocus::StrokeWidth | PropertyFocus::Opacity | PropertyFocus::FillHex | PropertyFocus::StrokeHex => {} diff --git a/crates/op-editor-core/src/ui_draft.rs b/crates/op-editor-core/src/ui_draft.rs index ebdd23b05..2e8ac730f 100644 --- a/crates/op-editor-core/src/ui_draft.rs +++ b/crates/op-editor-core/src/ui_draft.rs @@ -49,6 +49,14 @@ pub enum PropertyFocus { /// (stored as 0.0..=1.0 in the canonical schema). Same indexing /// + scope rules as [`GradientStopHex`]. GradientStopOffset(usize), + /// Polygon side count input. UI clamps to `3..=100`. + PolygonSides, + /// Ellipse arc start angle, in degrees. + EllipseStart, + /// Ellipse arc sweep angle, in degrees. + EllipseSweep, + /// Ellipse donut-hole radius, shown as a percentage. + EllipseInnerRadius, StrokeHex, StrokeWidth, } @@ -78,6 +86,9 @@ impl PropertyFocus { | PropertyFocus::StrokeWidth | PropertyFocus::GradientAngle | PropertyFocus::GradientStopOffset(_) + | PropertyFocus::EllipseStart + | PropertyFocus::EllipseSweep + | PropertyFocus::EllipseInnerRadius ) } } diff --git a/crates/op-editor-ui/src/layout_scene.rs b/crates/op-editor-ui/src/layout_scene.rs index 3b4998b3f..16e25b4f2 100644 --- a/crates/op-editor-ui/src/layout_scene.rs +++ b/crates/op-editor-ui/src/layout_scene.rs @@ -273,6 +273,8 @@ pub struct SceneNode { /// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 = /// solid. pub arc_inner_radius: Option, + /// Polygon side count. `3` is the canonical triangle default. + pub polygon_sides: u32, /// Image source for nodes that paint a bitmap (`PenNode::Image`). /// Carries the canonical schema's `src` field verbatim — usually /// a `data:image/...;base64,...` URL produced by the host's file @@ -364,6 +366,7 @@ impl SceneNode { arc_start_angle: None, arc_sweep_angle: None, arc_inner_radius: None, + polygon_sides: 3, image_src: None, image_fit: SceneImageFit::Fill, image_adjustments: crate::ImageAdjustments::default(), @@ -375,6 +378,22 @@ impl SceneNode { } } +/// Vertices for a regular polygon fitted inside `rect`. +pub fn regular_polygon_points(rect: Rect, sides: u32) -> Vec { + let n = sides.clamp(3, 100) as usize; + let cx = rect.origin.x + rect.size.x / 2.0; + let cy = rect.origin.y + rect.size.y / 2.0; + let rx = rect.size.x / 2.0; + let ry = rect.size.y / 2.0; + let start = -std::f32::consts::FRAC_PI_2; + (0..n) + .map(|i| { + let angle = start + i as f32 * std::f32::consts::TAU / n as f32; + Point2D::new(cx + rx * angle.cos(), cy + ry * angle.sin()) + }) + .collect() +} + /// Resolved stroke descriptor — colour already `$ref`-resolved. #[derive(Debug, Clone, Copy, PartialEq)] pub struct SceneStroke { diff --git a/crates/op-editor-ui/src/layout_scene_hit.rs b/crates/op-editor-ui/src/layout_scene_hit.rs index 4708265f6..f743a4d49 100644 --- a/crates/op-editor-ui/src/layout_scene_hit.rs +++ b/crates/op-editor-ui/src/layout_scene_hit.rs @@ -13,7 +13,7 @@ //! body-opts-out-children-stay rule, and the hidden-subtree skip. use crate::layout_scene::NodeKind; -use crate::layout_scene::{LayoutScene, SceneNode}; +use crate::layout_scene::{regular_polygon_points, LayoutScene, SceneNode}; use crate::{Point2D, Rect}; impl LayoutScene { @@ -226,31 +226,14 @@ fn point_in_node(node: &SceneNode, local: Point2D, bounds: Rect, zoom: f32) -> b r2 >= inner * inner } NodeKind::Polygon => { - // Triangle vertices: top-center, bottom-left, - // bottom-right — mirrors the polygon paint path. - let top = Point2D::new(cx, bounds.origin.y); - let bl = Point2D::new(bounds.origin.x, bounds.origin.y + bounds.size.y); - let br = Point2D::new( - bounds.origin.x + bounds.size.x, - bounds.origin.y + bounds.size.y, - ); - point_in_triangle(local, top, bl, br) + let points = regular_polygon_points(bounds, node.polygon_sides); + point_in_polygon(local, &points) } // Frame, Group, Rect, Text, Other, Path — bounds-only hit. _ => true, } } -/// Sign-of-cross-product point-in-triangle test. -fn point_in_triangle(p: Point2D, a: Point2D, b: Point2D, c: Point2D) -> bool { - let s1 = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); - let s2 = (c.x - b.x) * (p.y - b.y) - (c.y - b.y) * (p.x - b.x); - let s3 = (a.x - c.x) * (p.y - c.y) - (a.y - c.y) * (p.x - c.x); - let has_neg = s1 < 0.0 || s2 < 0.0 || s3 < 0.0; - let has_pos = s1 > 0.0 || s2 > 0.0 || s3 > 0.0; - !(has_neg && has_pos) -} - /// Even-odd ray-cast point-in-polygon test over a closed vertex ring. fn point_in_polygon(p: Point2D, poly: &[Point2D]) -> bool { if poly.len() < 3 { diff --git a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs index 92a25ee16..9c6cfe03d 100644 --- a/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs +++ b/crates/op-editor-ui/src/widgets/canvas_viewport_paint.rs @@ -13,7 +13,7 @@ //! only the viewport transform — no second layout pass, no variable //! lookup. -use crate::layout_scene::SceneNode; +use crate::layout_scene::{regular_polygon_points, SceneNode}; use crate::layout_scene::{Effect, NodeKind}; use crate::widgets::canvas_viewport::EditCaret; use crate::widgets::canvas_viewport_overlay::{paint_fill_then_stroke, wrap_text}; @@ -306,19 +306,9 @@ pub fn paint_node( } } NodeKind::Polygon => { - // Default triangle: top-centre, bottom-left, bottom-right. - let cx_pt = world_rect.origin.x + world_rect.size.x / 2.0; - let top_y = world_rect.origin.y; - let bottom_y = world_rect.origin.y + world_rect.size.y; - let left_x = world_rect.origin.x; - let right_x = world_rect.origin.x + world_rect.size.x; - let pts = [ - Point2D::new(cx_pt, top_y), - Point2D::new(left_x, bottom_y), - Point2D::new(right_x, bottom_y), - ]; + let pts = regular_polygon_points(world_rect, node.polygon_sides); // Image fills paint the bitmap in the AABB underneath the - // polygon outline; the triangle silhouette is then drawn + // polygon outline; the polygon silhouette is then drawn // by the stroke. A perfect clip-to-polygon path lands when // `RenderBackend` grows a polygon-clip primitive. if let Some(src) = node.image_src.as_deref() { diff --git a/crates/op-editor-ui/src/widgets/mod.rs b/crates/op-editor-ui/src/widgets/mod.rs index 173cdd1b8..69d478761 100644 --- a/crates/op-editor-ui/src/widgets/mod.rs +++ b/crates/op-editor-ui/src/widgets/mod.rs @@ -50,6 +50,7 @@ pub mod property_panel_image_fill; mod property_panel_image_preview; pub mod property_panel_input_layout; pub mod property_panel_inputs; +pub mod property_panel_layer; pub mod property_panel_layout; pub mod property_panel_sections; pub mod property_panel_snapshot; diff --git a/crates/op-editor-ui/src/widgets/property_panel.rs b/crates/op-editor-ui/src/widgets/property_panel.rs index 388d41aa8..7eb1482ff 100644 --- a/crates/op-editor-ui/src/widgets/property_panel.rs +++ b/crates/op-editor-ui/src/widgets/property_panel.rs @@ -46,7 +46,7 @@ pub use crate::widgets::property_panel_action::PropertyPanelAction; // resolves unchanged. pub(crate) use crate::widgets::property_panel_layout::SectionCapabilities; pub use crate::widgets::property_panel_snapshot::{ - EffectKind, EffectSummary, GradientStopSummary, NodeSnapshot, + EffectKind, EffectSummary, EllipseArcSummary, GradientStopSummary, NodeSnapshot, }; pub struct PropertyPanel { @@ -274,6 +274,8 @@ impl PropertyPanel { flex_layout: caps.flex_layout, size_options: caps.size_options, opacity: caps.opacity, + 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, @@ -517,7 +519,16 @@ impl Widget for PropertyPanel { ); } if caps.opacity { - y = sections::paint_layer_section(cx, &self.theme, &self.labels, &edit_ctx, x, y, w); + 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( diff --git a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs index 72de7fbca..353d7bee5 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_input_layout.rs @@ -119,7 +119,36 @@ pub fn editable_input_rects( size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT), }, )); - y += INPUT_HEIGHT + 12.0; + if visible.polygon_sides { + rects.push(( + PropertyFocus::PolygonSides, + Rect { + origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }, + )); + } + y += INPUT_HEIGHT; + if visible.ellipse_arc { + y += 6.0; + let col_w = (usable_w - 12.0) / 3.0; + let focuses = [ + PropertyFocus::EllipseStart, + PropertyFocus::EllipseSweep, + PropertyFocus::EllipseInnerRadius, + ]; + for (i, focus) in focuses.into_iter().enumerate() { + rects.push(( + focus, + Rect { + origin: Point2D::new(x0 + PAD_X + i as f32 * (col_w + 6.0), y), + size: Point2D::new(col_w, INPUT_HEIGHT), + }, + )); + } + y += INPUT_HEIGHT; + } + y += 12.0; y += SECTION_GAP; } if visible.fill { diff --git a/crates/op-editor-ui/src/widgets/property_panel_layer.rs b/crates/op-editor-ui/src/widgets/property_panel_layer.rs new file mode 100644 index 000000000..890ea5361 --- /dev/null +++ b/crates/op-editor-ui/src/widgets/property_panel_layer.rs @@ -0,0 +1,200 @@ +//! Layer-section paint for the right property panel. + +use crate::theme::Theme; +use crate::widgets::property_panel_inputs::{ + paint_section_divider, paint_section_label, to_jian_color, INPUT_HEIGHT, INPUT_RADIUS, PAD_X, + SECTION_GAP, +}; +use crate::widgets::property_panel_sections::{EditContext, PropertyLabels}; +use crate::widgets::property_panel_snapshot::{EllipseArcSummary, NodeSnapshot}; +use crate::widgets::PaintCx; +use crate::{Point2D, Rect, TextLayout}; +use op_editor_core::PropertyFocus; + +#[allow(clippy::too_many_arguments)] +pub fn paint_layer_section( + cx: &mut PaintCx<'_>, + theme: &Theme, + snapshot: &NodeSnapshot, + labels: &PropertyLabels, + edit: &EditContext<'_>, + x: f32, + y: f32, + width: f32, +) -> f32 { + let mut y = paint_section_label(cx, theme, labels.layer, x, y, width); + let usable_w = width - PAD_X * 2.0; + let half_w = (usable_w - 8.0) / 2.0; + let opacity_rect = Rect { + origin: Point2D::new(x + PAD_X, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }; + paint_labeled_input( + cx, + theme, + edit, + opacity_rect, + labels.opacity, + PropertyFocus::Opacity, + "100", + Some("%"), + ); + + if let Some(sides) = snapshot.polygon_sides { + let sides_rect = Rect { + origin: Point2D::new(x + PAD_X + half_w + 8.0, y), + size: Point2D::new(half_w, INPUT_HEIGHT), + }; + let value = sides.to_string(); + paint_labeled_input( + cx, + theme, + edit, + sides_rect, + labels.polygon_sides, + PropertyFocus::PolygonSides, + &value, + None, + ); + } + + y += INPUT_HEIGHT; + if let Some(arc) = snapshot.ellipse_arc { + y += 6.0; + paint_ellipse_arc_row(cx, theme, edit, labels, arc, x + PAD_X, y, usable_w); + y += INPUT_HEIGHT; + } + + y += 12.0; + paint_section_divider(cx, theme, x, y, width); + y + SECTION_GAP +} + +#[allow(clippy::too_many_arguments)] +fn paint_ellipse_arc_row( + cx: &mut PaintCx<'_>, + theme: &Theme, + edit: &EditContext<'_>, + labels: &PropertyLabels, + arc: EllipseArcSummary, + x: f32, + y: f32, + width: f32, +) { + let col_w = (width - 12.0) / 3.0; + let values = [ + ( + labels.ellipse_start, + PropertyFocus::EllipseStart, + format_number(arc.start_deg), + Some("°"), + ), + ( + labels.ellipse_sweep, + PropertyFocus::EllipseSweep, + format_number(arc.sweep_deg), + Some("°"), + ), + ( + labels.ellipse_inner_radius, + PropertyFocus::EllipseInnerRadius, + format_number(arc.inner_percent), + Some("%"), + ), + ]; + for (i, (label, focus, value, unit)) in values.iter().enumerate() { + paint_labeled_input( + cx, + theme, + edit, + Rect { + origin: Point2D::new(x + i as f32 * (col_w + 6.0), y), + size: Point2D::new(col_w, INPUT_HEIGHT), + }, + label, + *focus, + value, + *unit, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn paint_labeled_input( + cx: &mut PaintCx<'_>, + theme: &Theme, + edit: &EditContext<'_>, + rect: Rect, + prefix: &str, + focus: PropertyFocus, + fallback: &str, + suffix: Option<&str>, +) { + let value = edit.value_for(focus, fallback); + let focused = edit.focus == Some(focus); + cx.backend.fill_round_rect(rect, INPUT_RADIUS, theme.muted); + if focused { + cx.backend + .stroke_round_rect(rect, INPUT_RADIUS, theme.primary, 1.5); + } + cx.backend.save(); + cx.backend.clip_rect(rect); + + let baseline_y = rect.origin.y + 19.0; + let prefix_x = rect.origin.x + 10.0; + let prefix_layout = TextLayout::single_run( + prefix, + "system-ui", + 12.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend + .draw_text(&prefix_layout, Point2D::new(prefix_x, baseline_y)); + let prefix_w = cx.backend.measure_text(prefix, 12.0); + let value_x = prefix_x + prefix_w + 8.0; + let value_layout = TextLayout::single_run( + value, + "system-ui", + 12.0, + to_jian_color(theme.foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend + .draw_text(&value_layout, Point2D::new(value_x, baseline_y)); + if let Some(pos) = edit.caret_at(focus) { + let w = cx + .backend + .measure_text(&value[..pos.min(value.len())], 12.0); + cx.backend.fill_rect( + Rect { + origin: Point2D::new(value_x + w, rect.origin.y + 6.0), + size: Point2D::new(1.5, INPUT_HEIGHT - 12.0), + }, + theme.foreground, + ); + } + if let Some(unit) = suffix { + let value_w = cx.backend.measure_text(value, 12.0); + let unit_layout = TextLayout::single_run( + unit, + "system-ui", + 12.0, + to_jian_color(theme.muted_foreground), + Point2D::new(0.0, 0.0), + ); + cx.backend.draw_text( + &unit_layout, + Point2D::new(value_x + value_w + 6.0, baseline_y), + ); + } + cx.backend.restore(); +} + +fn format_number(value: f32) -> String { + if value.fract().abs() < f32::EPSILON { + format!("{}", value.round() as i32) + } else { + format!("{value:.2}") + } +} diff --git a/crates/op-editor-ui/src/widgets/property_panel_layout.rs b/crates/op-editor-ui/src/widgets/property_panel_layout.rs index 0a6481e26..3f65da812 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_layout.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_layout.rs @@ -117,6 +117,10 @@ pub struct VisibleSections { pub size_options: bool, /// `Opacity` from the Layer section. pub opacity: bool, + /// Polygon side-count input in the Layer section. + pub polygon_sides: bool, + /// Ellipse start/sweep/inner-radius inputs in the Layer section. + pub ellipse_arc: bool, /// `FillHex` from the Fill section. pub fill: bool, /// `StrokeHex` + `StrokeWidth` from the Stroke section. @@ -147,6 +151,8 @@ impl VisibleSections { flex_layout: true, size_options: true, opacity: true, + polygon_sides: false, + ellipse_arc: false, fill: true, stroke: true, effects: true, @@ -302,6 +308,20 @@ pub fn fill_body_height_with_stops(fill_type: FillType, stop_count: usize) -> f3 } } +/// Height consumed by the Layer section. Polygon adds a same-row +/// side-count field; ellipse adds a second row for arc controls. +pub fn layer_section_height(visible: VisibleSections) -> f32 { + if !visible.opacity { + return 0.0; + } + let extra_arc_row = if visible.ellipse_arc { + INPUT_HEIGHT + 6.0 + } else { + 0.0 + }; + SECTION_HEADER_HEIGHT + INPUT_HEIGHT + extra_arc_row + 12.0 +} + /// Rects of every clickable button / checkbox in the panel — /// flex-layout 3 buttons, size-options 5 checkboxes. Same y-walk /// math as `editable_input_rects` so paint + hit-test stay in @@ -446,8 +466,7 @@ pub fn action_button_rects_with_fill_picker( } if visible.opacity { - y += SECTION_HEADER_HEIGHT; - y += INPUT_HEIGHT + 12.0; + y += layer_section_height(visible); y += SECTION_GAP; } if visible.fill { diff --git a/crates/op-editor-ui/src/widgets/property_panel_sections.rs b/crates/op-editor-ui/src/widgets/property_panel_sections.rs index 074dd2ba8..c1d1eff5d 100644 --- a/crates/op-editor-ui/src/widgets/property_panel_sections.rs +++ b/crates/op-editor-ui/src/widgets/property_panel_sections.rs @@ -27,6 +27,7 @@ pub use crate::widgets::property_panel_image_fill::{ paint_image_fill_popover, }; pub use crate::widgets::property_panel_inputs::format_color_hex as _format_color_hex_compat; +pub use crate::widgets::property_panel_layer::paint_layer_section; /// Hit-result for a click on the property panel — payload is the /// input the click landed on (host stores in @@ -63,6 +64,10 @@ pub struct PropertyLabels { pub size: &'static str, pub layer: &'static str, pub opacity: &'static str, + pub polygon_sides: &'static str, + pub ellipse_start: &'static str, + pub ellipse_sweep: &'static str, + pub ellipse_inner_radius: &'static str, pub fill: &'static str, pub stroke: &'static str, pub effects: &'static str, @@ -99,6 +104,10 @@ impl PropertyLabels { size: pick("layout.dimensions", "Size"), layer: pick("appearance.layer", "Layer"), opacity: pick("appearance.opacity", "Opacity"), + polygon_sides: pick("polygon.sides", "Sides"), + ellipse_start: pick("ellipse.start", "Start"), + ellipse_sweep: pick("ellipse.sweep", "Sweep"), + ellipse_inner_radius: pick("ellipse.innerRadius", "Inner"), fill: pick("fill.title", "Fill"), stroke: pick("stroke.title", "Stroke"), effects: pick("effects.title", "Effects"), @@ -583,95 +592,6 @@ fn paint_check_row( cx.backend.draw_text(&lbl, Point2D::new(x + 22.0, y + 16.0)); } -// ── Layer (opacity) section ─────────────────────────────────────── - -pub fn paint_layer_section( - cx: &mut PaintCx<'_>, - theme: &Theme, - labels: &PropertyLabels, - edit: &EditContext<'_>, - x: f32, - y: f32, - width: f32, -) -> f32 { - let mut y = paint_section_label(cx, theme, labels.layer, x, y, width); - let usable_w = width - PAD_X * 2.0; - let row = Rect { - origin: Point2D::new(x + PAD_X, y), - // Half-width box — the empty right half avoids the over-wide - // look of a row spanning the full panel for a single value. - size: Point2D::new(usable_w / 2.0 - 4.0, INPUT_HEIGHT), - }; - let focused = edit.focus == Some(PropertyFocus::Opacity); - let value = edit.value_for(PropertyFocus::Opacity, "100"); - // Left-aligned compound `