fix(editor): improve shape controls and drag snapping

This commit is contained in:
Kayshen-X 2026-05-24 19:03:11 +08:00
parent 935d923927
commit 2766952b3e
25 changed files with 731 additions and 694 deletions

162
Cargo.lock generated
View file

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

View file

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

View file

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

View file

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

View file

@ -273,6 +273,8 @@ pub struct SceneNode {
/// Ellipse donut-hole radius (0.0..=1.0 fraction). `None` / 0 =
/// solid.
pub arc_inner_radius: Option<f32>,
/// 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<Point2D> {
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 {

View file

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

View file

@ -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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 `<label> <value> <unit>` — measuring
// the label first so the value never collides with it, then
// packing the unit right after the value. Avoids the overlap
// a "value-centered" layout produces in narrow boxes when the
// label is a wide CJK string ("不透明度" alone is ~50 px).
cx.backend.fill_round_rect(row, INPUT_RADIUS, theme.muted);
if focused {
cx.backend
.stroke_round_rect(row, INPUT_RADIUS, theme.primary, 1.5);
}
// Clip the text paint to the row so a long localized label
// (e.g. ru "Непрозрачность") can't bleed past the half-width
// box into the neighbouring rail.
cx.backend.save();
cx.backend.clip_rect(row);
let prefix = labels.opacity;
let prefix_w = cx.backend.measure_text(prefix, 12.0);
let value_w = cx.backend.measure_text(value, 12.0);
let baseline_y = y + 19.0;
let prefix_x = row.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 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(PropertyFocus::Opacity) {
let w = cx
.backend
.measure_text(&value[..pos.min(value.len())], 12.0);
cx.backend.fill_rect(
Rect {
origin: Point2D::new(value_x + w, y + 6.0),
size: Point2D::new(1.5, INPUT_HEIGHT - 12.0),
},
theme.foreground,
);
}
let unit_layout = TextLayout::single_run(
"%",
"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();
y += INPUT_HEIGHT + 12.0;
paint_section_divider(cx, theme, x, y, width);
y + SECTION_GAP
}
// ── Stroke section ────────────────────────────────────────────────
// Paint-context + geometry args threaded through; a struct adds no gain.

View file

@ -52,6 +52,10 @@ pub struct NodeSnapshot {
pub rotation_deg: f32,
/// Uniform corner radius in doc-px.
pub corner_radius: f32,
/// Polygon side count, only present for Polygon selections.
pub polygon_sides: Option<u32>,
/// Ellipse arc controls, only present for Ellipse selections.
pub ellipse_arc: Option<EllipseArcSummary>,
pub fill: Option<Color>,
/// Primary solid-fill opacity in `[0.0, 1.0]` — the Fill
/// section's `100 %` paints `fill_opacity * 100`.
@ -78,6 +82,13 @@ pub struct NodeSnapshot {
pub kind_variant: crate::layout_scene::NodeKind,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EllipseArcSummary {
pub start_deg: f32,
pub sweep_deg: f32,
pub inner_percent: f32,
}
/// One gradient stop summary for the Fill section.
#[derive(Debug, Clone)]
pub struct GradientStopSummary {
@ -215,6 +226,8 @@ impl NodeSnapshot {
height: bounds.h.round() as i32,
rotation_deg: 0.0,
corner_radius: 0.0,
polygon_sides: None,
ellipse_arc: None,
fill: None,
fill_opacity: 1.0,
stroke: None,
@ -261,6 +274,8 @@ impl NodeSnapshot {
// schema; the snapshot's `rotation_deg` wants degrees.
rotation_deg: base.rotation.unwrap_or(0.0) as f32,
corner_radius,
polygon_sides: polygon_sides_of(node),
ellipse_arc: ellipse_arc_of(node),
fill,
fill_opacity: op_editor_core::first_solid_fill_opacity(node),
stroke,
@ -276,6 +291,24 @@ impl NodeSnapshot {
}
}
fn polygon_sides_of(node: &PenNode) -> Option<u32> {
match node {
PenNode::Polygon(n) => Some(n.polygon_count.clamp(3, 100)),
_ => None,
}
}
fn ellipse_arc_of(node: &PenNode) -> Option<EllipseArcSummary> {
match node {
PenNode::Ellipse(n) => Some(EllipseArcSummary {
start_deg: n.start_angle.unwrap_or(0.0) as f32,
sweep_deg: n.sweep_angle.unwrap_or(360.0) as f32,
inner_percent: (n.inner_radius.unwrap_or(0.0).clamp(0.0, 0.99) * 100.0) as f32,
}),
_ => None,
}
}
/// LinearGradient `angle` for the node's first fill, when it has
/// one. Falls back to `0.0` (canonical default, bottom→top) when
/// the body omits an explicit angle. `None` for non-linear primary

View file

@ -74,6 +74,8 @@ fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections {
flex_layout: caps.flex_layout,
size_options: caps.size_options,
opacity: caps.opacity,
polygon_sides: panel.snapshot.polygon_sides.is_some(),
ellipse_arc: panel.snapshot.ellipse_arc.is_some(),
fill: caps.fill,
stroke: caps.stroke,
effects: caps.effects,
@ -83,6 +85,79 @@ fn visible_for(panel: &PropertyPanel) -> sections::VisibleSections {
}
}
#[test]
fn polygon_selection_exposes_sides_layer_input() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"polygon","id":"poly","name":"Hex",
"x":40,"y":40,"width":120,"height":120,
"polygonCount":6}
]}"##,
);
state.set_single_selection(NodeId::new("poly"));
let panel = PropertyPanel::for_selection(&state).expect("polygon panel");
assert_eq!(panel.snapshot.polygon_sides, Some(6));
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let sides_rect = sections::editable_input_rects(rect, visible_for(&panel))
.into_iter()
.find(|(focus, _)| *focus == op_editor_core::PropertyFocus::PolygonSides)
.map(|(_, r)| r)
.expect("polygon side input rect");
let center = Point2D::new(
sides_rect.origin.x + sides_rect.size.x / 2.0,
sides_rect.origin.y + sides_rect.size.y / 2.0,
);
assert_eq!(
panel.hit_test(rect, center),
Some(op_editor_core::PropertyFocus::PolygonSides)
);
}
#[test]
fn ellipse_selection_exposes_arc_layer_inputs() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"ellipse","id":"ell","name":"Arc",
"x":40,"y":40,"width":120,"height":100,
"startAngle":30,"sweepAngle":270,"innerRadius":0.25}
]}"##,
);
state.set_single_selection(NodeId::new("ell"));
let panel = PropertyPanel::for_selection(&state).expect("ellipse panel");
let arc = panel.snapshot.ellipse_arc.expect("ellipse arc snapshot");
assert_eq!(arc.start_deg, 30.0);
assert_eq!(arc.sweep_deg, 270.0);
assert_eq!(arc.inner_percent, 25.0);
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let rects = sections::editable_input_rects(rect, visible_for(&panel));
for focus in [
op_editor_core::PropertyFocus::EllipseStart,
op_editor_core::PropertyFocus::EllipseSweep,
op_editor_core::PropertyFocus::EllipseInnerRadius,
] {
let target = rects
.iter()
.find(|(f, _)| *f == focus)
.map(|(_, r)| *r)
.expect("ellipse arc input rect");
let center = Point2D::new(
target.origin.x + target.size.x / 2.0,
target.origin.y + target.size.y / 2.0,
);
assert_eq!(panel.hit_test(rect, center), Some(focus));
}
}
#[test]
fn hit_test_action_export_section_returns_picker_toggles() {
// Single-frame selection paints every section + Export.

View file

@ -23,8 +23,8 @@
//! — JPEG has no alpha so a "transparent" JPEG would read as black).
//! Scale: caller picks @1x / @2x / @3x (TS export dialog parity).
use op_editor_ui::layout_scene::{regular_polygon_points, LayoutScene, SceneNode, ScenePage};
use op_editor_ui::layout_scene::{Effect, NodeKind};
use op_editor_ui::layout_scene::{LayoutScene, SceneNode, ScenePage};
use op_editor_ui::{Color, Point2D, Rect};
use skia_safe::{Canvas, EncodedImageFormat, Paint, PaintStyle, Path, PathBuilder};
use std::path::Path as StdPath;
@ -416,7 +416,7 @@ pub(crate) fn paint_node(canvas: &Canvas, node: &SceneNode) {
paint_oval(canvas, world_rect, node);
}
NodeKind::Polygon => {
paint_triangle(canvas, world_rect, node);
paint_polygon(canvas, world_rect, node);
}
NodeKind::Line => {
let (color, width) = match node.stroke {
@ -520,20 +520,20 @@ fn paint_oval(canvas: &Canvas, rect: Rect, node: &SceneNode) {
}
}
fn paint_triangle(canvas: &Canvas, rect: Rect, node: &SceneNode) {
fn paint_polygon(canvas: &Canvas, rect: Rect, node: &SceneNode) {
let rect = normalize_rect(rect);
if rect.size.x == 0.0 || rect.size.y == 0.0 {
return;
}
let cx = rect.origin.x + rect.size.x / 2.0;
let top = rect.origin.y;
let left = rect.origin.x;
let right = rect.origin.x + rect.size.x;
let bottom = rect.origin.y + rect.size.y;
let points = regular_polygon_points(rect, node.polygon_sides);
let mut builder = PathBuilder::new();
builder.move_to((cx, top));
builder.line_to((left, bottom));
builder.line_to((right, bottom));
let Some(first) = points.first() else {
return;
};
builder.move_to((first.x, first.y));
for p in points.iter().skip(1) {
builder.line_to((p.x, p.y));
}
builder.close();
let path: Path = builder.detach();
if let Some(fill) = node.fill {

View file

@ -8,7 +8,7 @@
//! raster path composes `Canvas::rotate`.
use op_editor_ui::layout_scene::NodeKind;
use op_editor_ui::layout_scene::{LayoutScene, SceneNode};
use op_editor_ui::layout_scene::{regular_polygon_points, LayoutScene, SceneNode};
use op_editor_ui::Color;
use std::fmt::Write as _;
use std::path::Path as StdPath;
@ -116,14 +116,17 @@ fn emit_polygon(out: &mut String, n: &SceneNode) {
if r.size.x == 0.0 || r.size.y == 0.0 {
return;
}
let cx = r.origin.x + r.size.x * 0.5;
let top = r.origin.y;
let left = r.origin.x;
let right = r.origin.x + r.size.x;
let bottom = r.origin.y + r.size.y;
let points = regular_polygon_points(r, n.polygon_sides);
let mut point_attr = String::new();
for (i, p) in points.iter().enumerate() {
if i > 0 {
point_attr.push(' ');
}
let _ = write!(point_attr, "{},{}", p.x, p.y);
}
let _ = write!(
out,
r#"<polygon points="{cx},{top} {left},{bottom} {right},{bottom}"{}/>"#,
r#"<polygon points="{point_attr}"{}/>"#,
fill_stroke_attrs(n),
);
}

View file

@ -65,7 +65,7 @@ impl WidgetHostNative {
/// against the other top-level nodes, snap the selection onto the
/// nearest edge/centre alignment, and store the guide lines for
/// the canvas painter. Cleared on drag release.
fn apply_smart_guides(&mut self) {
fn apply_smart_guides(&mut self) -> (f64, f64) {
use op_editor_core::align_guides::compute_alignment_guides;
/// Snap range in doc-px — an edge/centre this close to another
/// node's edge/centre locks on.
@ -100,16 +100,18 @@ impl WidgetHostNative {
};
let Some(m) = moving else {
self.editor_state.editor_ui.active_guides.clear();
return;
return (0.0, 0.0);
};
let others: Vec<(f64, f64, f64, f64)> =
others.iter().map(|a| (a[0], a[1], a[2], a[3])).collect();
let result = compute_alignment_guides((m[0], m[1], m[2], m[3]), &others, GUIDE_THRESHOLD);
let snap = (result.snap_dx, result.snap_dy);
if result.snap_dx != 0.0 || result.snap_dy != 0.0 {
self.editor_state
.translate_selected(result.snap_dx, result.snap_dy);
}
self.editor_state.editor_ui.active_guides = result.guides;
snap
}
pub fn apply_cursor_move(&mut self, x: f32, y: f32) -> bool {
@ -314,17 +316,32 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
if let Some(drag) = self.node_drag.as_mut() {
if let Some(drag) = self.node_drag {
let zoom = self.editor_state.viewport.zoom.max(0.0001);
let dx = (x - drag.last_screen_x) / zoom;
let dy = (y - drag.last_screen_y) / zoom;
drag.last_screen_x = x;
drag.last_screen_y = y;
let prev_screen_x = drag.last_screen_x;
let prev_screen_y = drag.last_screen_y;
let dx = (x - prev_screen_x) / zoom;
let dy = (y - prev_screen_y) / zoom;
if dx != 0.0 || dy != 0.0 {
if let Some(drag) = self.node_drag.as_mut() {
drag.last_screen_x = x;
drag.last_screen_y = y;
}
self.editor_state.translate_selected(dx as f64, dy as f64);
// `drag`'s last use was above — `self` is free to
// re-borrow for the smart-guide alignment pass.
self.apply_smart_guides();
let (snap_dx, snap_dy) = self.apply_smart_guides();
if let Some(drag) = self.node_drag.as_mut() {
// When smart guides pull the node back onto a guide,
// keep the cursor baseline on that axis at its previous
// value. Small subsequent cursor moves then accumulate
// until they exceed the snap threshold instead of being
// eaten one frame at a time.
if snap_dx != 0.0 {
drag.last_screen_x = prev_screen_x;
}
if snap_dy != 0.0 {
drag.last_screen_y = prev_screen_y;
}
}
self.mark_dirty();
return true;
}

View file

@ -5,7 +5,7 @@
//! `host.editor_state` from canonical-schema JSON and assert against
//! `editor_state` + the derived `LayoutScene` render scene.
use super::WidgetHostNative;
use super::{NodeDragState, WidgetHostNative};
use op_editor_core::ui_draft::PropertyFocus;
use op_editor_core::NodeId;
use op_editor_core::PenNodeExt;
@ -195,6 +195,124 @@ fn image_fill_actions_refresh_the_render_scene() {
assert_eq!(rendered.image_adjustments.exposure, 64.0);
}
#[test]
fn corner_radius_property_focus_updates_selected_rectangle() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"rectangle","id":"n62","name":"Rounded",
"x":40,"y":40,"width":180,"height":120,
"fill":[{"type":"solid","color":"#BDC7D9"}]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("n62"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PositionR);
host.editor_state_mut().ui.property_input_draft = "24".to_string();
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Rectangle(rect) => {
assert_eq!(
rect.container.corner_radius,
Some(jian_ops_schema::node::container::CornerRadius::Uniform(
24.0
)),
);
}
other => panic!("expected rectangle, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("n62")
.unwrap();
assert_eq!(rendered.corner_radius, 24.0);
}
#[test]
fn polygon_sides_property_focus_updates_selected_polygon() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"polygon","id":"poly","name":"Polygon",
"x":40,"y":40,"width":120,"height":120,
"polygonCount":3}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("poly"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::PolygonSides);
host.editor_state_mut().ui.property_input_draft = "7".to_string();
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Polygon(poly) => {
assert_eq!(poly.polygon_count, 7);
}
other => panic!("expected polygon, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("poly")
.unwrap();
assert_eq!(rendered.polygon_sides, 7);
}
#[test]
fn ellipse_arc_property_focus_updates_selected_ellipse() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"ellipse","id":"ell","name":"Ellipse",
"x":40,"y":40,"width":120,"height":100}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("ell"));
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseStart);
host.editor_state_mut().ui.property_input_draft = "45".to_string();
host.commit_property_focus_if_any();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseSweep);
host.editor_state_mut().ui.property_input_draft = "180".to_string();
host.commit_property_focus_if_any();
host.editor_state_mut().ui.property_focus = Some(PropertyFocus::EllipseInnerRadius);
host.editor_state_mut().ui.property_input_draft = "25".to_string();
host.commit_property_focus_if_any();
let node = host.editor_state().selected_node().unwrap();
match node {
jian_ops_schema::node::PenNode::Ellipse(ell) => {
assert_eq!(ell.start_angle, Some(45.0));
assert_eq!(ell.sweep_angle, Some(180.0));
assert_eq!(ell.inner_radius, Some(0.25));
}
other => panic!("expected ellipse, got {other:?}"),
}
let rendered = host
.layout_scene()
.active_page()
.unwrap()
.find("ell")
.unwrap();
assert_eq!(rendered.arc_start_angle, Some(45.0));
assert_eq!(rendered.arc_sweep_angle, Some(180.0));
assert_eq!(rendered.arc_inner_radius, Some(0.25));
}
#[test]
fn backspace_with_property_draft_does_not_delete_selected() {
// With a non-empty property draft buffer, Backspace must pop a
@ -776,6 +894,35 @@ fn node_drag_not_intercepted_by_align_toolbar_hover() {
);
}
#[test]
fn node_drag_snap_does_not_trap_incremental_cursor_motion() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[
{"type":"rectangle","id":"moving","name":"moving","x":90,"y":0,"width":10,"height":10},
{"type":"rectangle","id":"guide","name":"guide","x":105,"y":100,"width":100,"height":20}
]}"#,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("moving"));
host.editor_state_mut().viewport.zoom = 1.0;
host.node_drag = Some(NodeDragState {
last_screen_x: 500.0,
last_screen_y: 500.0,
});
for x in (502..=522).step_by(2) {
host.apply_cursor_move(x as f32, 500.0);
}
let moved = node_xy(&host, "moving");
assert!(
moved.0 > 110.0,
"small cursor moves must accumulate enough to leave a smart-guide snap; got {moved:?}"
);
}
#[test]
fn host_carries_editor_state_as_source_of_truth() {
// A fresh host opens with the demo sample seeded onto

View file

@ -142,6 +142,28 @@ pub(in crate::widget_host) fn property_focus_initial(
F::Rotation => (panel.snapshot.rotation_deg.round() as i32).to_string(),
F::PositionR => (panel.snapshot.corner_radius.round() as i32).to_string(),
F::Opacity => "100".to_string(),
F::PolygonSides => panel.snapshot.polygon_sides.unwrap_or(3).to_string(),
F::EllipseStart => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.start_deg)
.unwrap_or(0.0),
),
F::EllipseSweep => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.sweep_deg)
.unwrap_or(360.0),
),
F::EllipseInnerRadius => format_panel_number(
panel
.snapshot
.ellipse_arc
.map(|a| a.inner_percent)
.unwrap_or(0.0),
),
F::FillOpacity => ((panel.snapshot.fill_opacity * 100.0).round() as i32).to_string(),
F::FillHex => panel
.snapshot
@ -183,6 +205,14 @@ pub(in crate::widget_host) fn property_focus_initial(
}
}
fn format_panel_number(value: f32) -> String {
if value.fract().abs() < f32::EPSILON {
format!("{}", value.round() as i32)
} else {
format!("{value:.2}")
}
}
/// Translate a shell-core `ColorTarget` into op-editor-core's — used
/// by `press.rs`'s `OpenColorPicker` branch.
pub(in crate::widget_host) fn color_target(

View file

@ -521,6 +521,7 @@ fn polygon_to_payload(n: &PolygonNode) -> NodePayload {
assign_first_fill(&mut p, n.fill.as_deref());
p.stroke = stroke_to_payload(n.stroke.as_ref());
p.corner_radius = n.corner_radius.unwrap_or(0.0) as f32;
p.polygon_sides = n.polygon_count.clamp(3, 100);
p
}
@ -679,6 +680,7 @@ fn base_payload(base: &PenNodeBase, kind: &str) -> NodePayload {
arc_start_angle: None,
arc_sweep_angle: None,
arc_inner_radius: None,
polygon_sides: 3,
hidden: !base.visible.unwrap_or(true),
locked: base.locked.unwrap_or(false),
collapsed: false,

View file

@ -126,6 +126,7 @@ fn node_payload_to_scene(node: &NodePayload, var_table: &VariableTable) -> Scene
arc_start_angle: node.arc_start_angle,
arc_sweep_angle: node.arc_sweep_angle,
arc_inner_radius: node.arc_inner_radius,
polygon_sides: node.polygon_sides.clamp(3, 100),
image_src: node.image_src.clone(),
image_fit: image_fit_to_scene(node.image_fit.as_deref()),
image_adjustments: image_adjustments_to_scene(node.image_adjustments),

View file

@ -64,6 +64,12 @@ pub struct NodePayload {
/// Ellipse donut-hole radius, 0.0..=1.0 fraction of the radius.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub arc_inner_radius: Option<f32>,
/// Polygon side count. Defaults to the triangle parity value.
#[serde(
default = "default_polygon_sides",
skip_serializing_if = "is_default_polygon_sides"
)]
pub polygon_sides: u32,
#[serde(default)]
pub hidden: bool,
#[serde(default)]
@ -121,6 +127,14 @@ pub struct NodePayload {
pub children: Vec<NodePayload>,
}
fn default_polygon_sides() -> u32 {
3
}
fn is_default_polygon_sides(value: &u32) -> bool {
*value == 3
}
#[derive(Debug, Serialize, Deserialize)]
pub struct StrokePayload {
pub color: [f32; 4],

View file

@ -1,28 +0,0 @@
[package]
name = "op-smoke"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
description = "Headless smoke runner for op-orchestrator — drives one design turn against AnthropicProvider and dumps every event to stderr"
[[bin]]
name = "op-smoke"
path = "src/main.rs"
[dependencies]
# AnthropicProvider — the `anthropic` feature gates the real HTTP client.
agent = { path = "../../vendor/agent/crates/agent", default-features = false, features = ["anthropic", "openai"] }
# The orchestrator + its types. Smoke calls `Orchestrator::new().run(...)`
# directly on the current thread, bypassing the actor-channel model
# `DesignSession` uses for UI safety — we want the simplest possible
# trace so debugging the LLM/orchestrator side stays linear.
op-orchestrator = { path = "../op-orchestrator" }
# `EditorState` + `EditorCommand` — the smoke's own `InlineDocSink` owns
# one and applies commands on it.
op-editor-core = { path = "../op-editor-core" }
# `futures` for `BoxStream` + `mpsc` channels in the LlmClient impl.
futures = "0.3"
# `tokio` for the runtime that `Orchestrator::run` (async) runs on.
# `rt-multi-thread` so the LLM HTTP client can spawn its own tasks.
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }

View file

@ -1,345 +0,0 @@
//! Headless smoke runner for `op-orchestrator`.
//!
//! Drives one design turn against `AnthropicProvider` without the
//! desktop UI / `DesignSession` actor model — single-threaded
//! `block_on(Orchestrator::run)` against an inline `DocSink`, with every
//! progress event + every applied `EditorCommand` dumped to stderr.
//!
//! ## Usage
//!
//! ```sh
//! export OPENPENCIL_ANTHROPIC_API_KEY=sk-ant-... # or ANTHROPIC_API_KEY
//! cargo run -p op-smoke -- "design a login screen"
//! ```
//!
//! Optional env overrides:
//! - `OPENPENCIL_ORCHESTRATOR_MODEL` — default `claude-sonnet-4-6`.
//!
//! ## What this verifies vs the desktop GUI smoke
//!
//! - LLM client construction (`AnthropicProvider::new` + auth).
//! - `Orchestrator::run` reaching the network (200 OK / 401 / 429 etc.
//! surfaces as a `LlmError` in the streamed events).
//! - Planner → scaffold → subtask → cleanup transitions
//! (`Progress::*` enum, every variant rendered to stderr).
//! - `EditorCommand` applied to the in-memory state, including
//! `InsertSubtree` ID-remapping.
//! - Terminal `RunSummary` (subtask outcomes + total node count) or
//! `OrchestratorError`.
//!
//! What this does NOT verify (run the desktop binary for those):
//! - Canvas rendering / paint correctness.
//! - chat panel rendering of progress lines / streaming bubble.
//! - Cross-session abort (mid-turn switch to chat — covered by
//! `chat_session::launch_if_pending` host tests).
//! - Pre-validation fixes — smoke runs with `SkippedPreValidator` so
//! the trace stays focused on orchestrator behaviour. The host
//! binary uses `LintPreValidator`; smoke skips that layer.
use std::sync::Arc;
use agent::abort::AbortController;
use agent::provider::anthropic::AnthropicProvider;
use agent::provider::openai_compat::{OpenAiCompatConfig, OpenAiCompatProvider};
use agent::provider::Provider;
use agent::query::QueryEngine;
use agent::stream::Event;
use futures::channel::mpsc;
use futures::StreamExt;
use op_editor_core::{EditorCommand, EditorState};
use op_orchestrator::{
AbortFlag, CallRequest, DesignRequest, DocSink, LlmChunk, LlmClient, LlmError, Orchestrator,
Progress, SkippedPreValidator, SkippedScreenshotProvider, SkippedVisionLlmClient,
ValidationProviders,
};
/// `LlmClient` impl for the smoke runner — `AnthropicProvider` under a
/// `QueryEngine`, with every call spawned onto the current tokio runtime.
/// Mirrors `op-host-desktop::chat_orchestrator::DesktopLlmClient` but
/// uses `tokio::spawn` instead of a shared `Runtime::spawn` handle.
struct SmokeLlmClient {
provider: Arc<dyn Provider>,
default_model: String,
}
impl LlmClient for SmokeLlmClient {
fn call(
&self,
req: CallRequest,
) -> futures::stream::BoxStream<'static, Result<LlmChunk, LlmError>> {
let (tx, rx) = mpsc::unbounded::<Result<LlmChunk, LlmError>>();
if req.abort.is_set() {
let _ = tx.unbounded_send(Err(LlmError {
message: "aborted".into(),
aborted: true,
}));
return Box::pin(rx);
}
let provider = self.provider.clone();
let model = req
.model
.clone()
.unwrap_or_else(|| self.default_model.clone());
let system = req.system_prompt.clone();
let user = req.user_prompt.clone();
eprintln!(
"[LLM] call: model={model} system_len={} user_len={}",
system.len(),
user.len()
);
tokio::spawn(async move {
let engine = QueryEngine::new(provider, model).with_system(system);
let abort = AbortController::new();
let stream = match engine.run(user, abort).await {
Ok(s) => s,
Err(e) => {
eprintln!("[LLM] engine.run error: {e}");
let _ = tx.unbounded_send(Err(LlmError {
message: e.to_string(),
aborted: false,
}));
return;
}
};
let mut stream = stream;
while let Some(item) = stream.next().await {
let sent = match item {
Ok(Event::TextDelta { delta }) => tx.unbounded_send(Ok(LlmChunk::Text(delta))),
Ok(Event::Thinking { delta }) => {
tx.unbounded_send(Ok(LlmChunk::Thinking(delta)))
}
Ok(Event::Result { .. }) => break,
Ok(Event::Error { code, message }) => {
eprintln!("[LLM] event error: {code}: {message}");
tx.unbounded_send(Err(LlmError {
message: format!("{code}: {message}"),
aborted: false,
}))
}
Ok(_) => Ok(()),
Err(e) => {
eprintln!("[LLM] stream error: {e}");
tx.unbounded_send(Err(LlmError {
message: e.to_string(),
aborted: false,
}))
}
};
if sent.is_err() {
break;
}
}
});
Box::pin(rx)
}
}
/// Inline `DocSink` — owns the canonical state directly, no channel hop.
/// Every `apply` echoes the command kind + result so the smoke trace
/// shows the orchestrator's mutations linearly.
struct InlineDocSink {
state: EditorState,
}
impl DocSink for InlineDocSink {
fn state(&self) -> &EditorState {
&self.state
}
fn apply(&mut self, cmd: EditorCommand) -> bool {
let label = describe_cmd(&cmd);
let applied = self.state.apply(cmd);
eprintln!("[CMD] {label} → applied={applied}");
applied
}
fn begin_undo_batch(&mut self) {
eprintln!("[UNDO] begin");
}
fn end_undo_batch(&mut self) {
eprintln!("[UNDO] end");
}
}
/// One-line label for an `EditorCommand` variant. We don't dump the full
/// payload (often kilobytes of node JSON) — just the variant + its key
/// identifying field so the trace stays readable.
fn describe_cmd(cmd: &EditorCommand) -> String {
match cmd {
EditorCommand::InsertSubtree { nodes, parent_id } => {
format!("InsertSubtree(parent={parent_id:?}, nodes={})", nodes.len())
}
EditorCommand::UpdateNode { node_id, .. } => format!("UpdateNode({node_id:?})"),
EditorCommand::DeleteNode { node_id } => format!("DeleteNode({node_id:?})"),
EditorCommand::MoveNode { node_id, .. } => format!("MoveNode({node_id:?})"),
EditorCommand::SetNodeLayoutProp {
node_id, property, ..
} => format!("SetNodeLayoutProp({node_id:?}, prop={property:?})"),
EditorCommand::SetNodeStrokeHex { node_id, hex } => {
format!("SetNodeStrokeHex({node_id:?}, {hex})")
}
EditorCommand::SetNodeStrokeWidth { node_id, .. } => {
format!("SetNodeStrokeWidth({node_id:?})")
}
EditorCommand::SetNodeFillHex { node_id, hex } => {
format!("SetNodeFillHex({node_id:?}, {hex})")
}
EditorCommand::RemoveNodeEffect { node_id, index } => {
format!("RemoveNodeEffect({node_id:?}, [{index}])")
}
other => {
let dbg = format!("{other:?}");
// Truncate the Debug output so massive payloads don't blow up the trace.
if dbg.len() > 120 {
format!("{}...", &dbg[..117])
} else {
dbg
}
}
}
}
#[tokio::main(flavor = "multi_thread")]
async fn main() -> std::process::ExitCode {
let prompt = match std::env::args().nth(1) {
Some(p) if !p.is_empty() => p,
_ => {
eprintln!(
"usage: op-smoke <prompt>\n\nexport OPENPENCIL_ANTHROPIC_API_KEY=... (or ANTHROPIC_API_KEY)"
);
return std::process::ExitCode::from(2);
}
};
let provider_kind =
std::env::var("OPENPENCIL_LLM_PROVIDER").unwrap_or_else(|_| "anthropic".into());
let model = std::env::var("OPENPENCIL_ORCHESTRATOR_MODEL").unwrap_or_else(|_| {
match provider_kind.as_str() {
"anthropic" => "claude-sonnet-4-6".into(),
_ => "gpt-4o-mini".into(),
}
});
eprintln!("[SMOKE] provider={provider_kind} model={model}");
eprintln!("[SMOKE] prompt={prompt:?}");
let provider: Arc<dyn Provider> = match provider_kind.as_str() {
"anthropic" => {
let key = std::env::var("OPENPENCIL_ANTHROPIC_API_KEY")
.ok()
.or_else(|| std::env::var("ANTHROPIC_API_KEY").ok())
.filter(|k| !k.is_empty());
let Some(key) = key else {
eprintln!(
"error: neither OPENPENCIL_ANTHROPIC_API_KEY nor ANTHROPIC_API_KEY is set"
);
return std::process::ExitCode::from(3);
};
Arc::new(AnthropicProvider::new(key))
}
"openai" | "openai-compat" => {
let key = std::env::var("OPENPENCIL_LLM_API_KEY")
.ok()
.filter(|k| !k.is_empty());
let Some(key) = key else {
eprintln!("error: OPENPENCIL_LLM_API_KEY is not set");
return std::process::ExitCode::from(3);
};
let base_url = std::env::var("OPENPENCIL_LLM_BASE_URL")
.ok()
.filter(|u| !u.is_empty());
let Some(base_url) = base_url else {
eprintln!(
"error: OPENPENCIL_LLM_BASE_URL is not set (e.g. https://api.openai.com/v1)"
);
return std::process::ExitCode::from(3);
};
eprintln!("[SMOKE] base_url={base_url}");
Arc::new(OpenAiCompatProvider::new(OpenAiCompatConfig::new(
key, base_url,
)))
}
other => {
eprintln!(
"error: unknown OPENPENCIL_LLM_PROVIDER={other:?} (want anthropic|openai-compat)"
);
return std::process::ExitCode::from(3);
}
};
let llm = SmokeLlmClient {
provider,
default_model: model.clone(),
};
let mut sink = InlineDocSink {
state: EditorState::new(),
};
let request = DesignRequest {
prompt,
model: Some(model),
provider: None,
design_md: sink.state.doc.design_md.clone(),
append_context: None,
concurrency: 1,
validation_enabled: false,
visual_ref_enabled: false,
};
let abort = AbortFlag::new();
// Skip pre-validation in the smoke trace — keeps the orchestrator
// signal clean. The desktop binary swaps this for `LintPreValidator`.
let pre_validator = SkippedPreValidator;
let screenshot = SkippedScreenshotProvider;
let vision = SkippedVisionLlmClient;
let providers = ValidationProviders {
pre_validator: &pre_validator,
screenshot: &screenshot,
vision: &vision,
system_prompt: String::new(),
};
let mut on_progress = |p: Progress| {
eprintln!("[PROGRESS] {p:?}");
};
let started = std::time::Instant::now();
let result = Orchestrator::new()
.run(
request,
&mut sink,
&llm,
&mut on_progress,
&abort,
&providers,
)
.await;
let elapsed = started.elapsed();
match result {
Ok(summary) => {
eprintln!("[FINAL] Ok in {elapsed:?}");
eprintln!(" root_frame_id = {:?}", summary.root_frame_id);
eprintln!(" total_nodes = {}", summary.total_nodes);
eprintln!(" subtasks = {}", summary.subtasks.len());
for s in &summary.subtasks {
eprintln!(
" - {}: {} node(s){}",
s.id,
s.node_count,
s.error
.as_deref()
.map(|e| format!(" [error: {e}]"))
.unwrap_or_default()
);
}
std::process::ExitCode::SUCCESS
}
Err(e) => {
eprintln!("[FINAL] Err in {elapsed:?}: {e}");
std::process::ExitCode::from(1)
}
}
}