fix(editor): improve shape controls and drag snapping
This commit is contained in:
parent
935d923927
commit
2766952b3e
162
Cargo.lock
generated
162
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 => {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
200
crates/op-editor-ui/src/widgets/property_panel_layer.rs
Normal file
200
crates/op-editor-ui/src/widgets/property_panel_layer.rs
Normal 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}")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"] }
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue