feat(editor): align canvas layout and sizing behavior

This commit is contained in:
Fini 2026-07-14 00:27:18 +08:00
parent c7e7e45810
commit 44a4fe4e24
45 changed files with 2282 additions and 648 deletions

View file

@ -6,7 +6,10 @@ use crate::command::EditorCommand;
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::test_support::{frame, rect, state_with};
use crate::walkers::find_node;
use jian_ops_schema::node::PenNode;
use jian_ops_schema::page::PenPage;
use serde_json::json;
fn id(s: &str) -> NodeId {
NodeId::new(s)
@ -129,3 +132,59 @@ fn copy_node_applies_root_overrides_without_overriding_fresh_id() {
assert_eq!(clone.base().x, Some(42.0));
assert_eq!(clone.width_px(), Some(88.0));
}
#[test]
fn move_node_accepts_a_childless_container_but_rejects_a_leaf() {
let doc: jian_ops_schema::PenDocument = serde_json::from_value(json!({
"version": "1.0",
"children": [
{"type":"text","id":"source","content":"Move me"},
{"type":"frame","id":"empty-frame","name":"Empty Frame"},
{"type":"text","id":"leaf","content":"Not a parent"}
]
}))
.unwrap();
let mut state = crate::EditorState::from_document(doc);
assert!(state.apply(EditorCommand::MoveNode {
node_id: id("source"),
target_parent: id("empty-frame"),
page_id: None,
index: None,
}));
let frame = find_node(state.active_children(), &id("empty-frame")).unwrap();
assert!(frame
.children()
.is_some_and(|children| children.iter().any(|node| node.id_str() == "source")));
let before = serde_json::to_value(&state.doc).unwrap();
assert!(!state.apply(EditorCommand::MoveNode {
node_id: id("source"),
target_parent: id("leaf"),
page_id: None,
index: None,
}));
assert_eq!(serde_json::to_value(&state.doc).unwrap(), before);
}
#[test]
fn copy_node_accepts_a_childless_rectangle_container() {
let doc: jian_ops_schema::PenDocument = serde_json::from_value(json!({
"version": "1.0",
"children": [
{"type":"text","id":"source","content":"Copy me"},
{"type":"rectangle","id":"empty-rectangle","name":"Empty Rectangle"}
]
}))
.unwrap();
let mut state = crate::EditorState::from_document(doc);
assert!(state.apply(EditorCommand::CopyNode {
node_id: id("source"),
target_parent: id("empty-rectangle"),
overrides_json: None,
page_id: None,
}));
let rectangle = find_node(state.active_children(), &id("empty-rectangle")).unwrap();
assert!(rectangle.children().is_some_and(|children| {
children.len() == 1 && matches!(children[0], PenNode::Text(_))
}));
}

View file

@ -1,12 +1,13 @@
//! Canvas-drag mutators: handle-resize (with descendant scaling) +
//! drag-end auto-layout reorder / cross-container reparenting.
//! Canvas-drag mutators: handle-resize + drag-end auto-layout reorder /
//! cross-container reparenting.
//!
//! Ports the TS behavior from `skia-interaction.ts` (`handleResizeMove`
//! / `handleDragEnd`) and pen-core `tree-utils.ts::scaleChildrenInPlace`.
//! Resize follows Pencil's frame semantics: mutate the selected node's
//! authored bounds only, then let the layout engine reflow Fill/Hug/flex
//! descendants. It is deliberately not a subtree Scale operation.
//! Split out of `mutators.rs` to keep that file under the 800-line
//! ceiling.
use crate::geometry::{own_bounds, DocRect};
use crate::geometry::DocRect;
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState;
@ -22,6 +23,27 @@ pub enum FlexDirection {
Horizontal,
}
/// Authored dimensions affected by a selection-handle drag.
///
/// Edge handles freeze one axis to a number; corner handles freeze both.
/// The untouched axis keeps its existing Number / Fill / Hug mode.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeAxes {
Width,
Height,
Both,
}
impl ResizeAxes {
fn width(self) -> bool {
matches!(self, Self::Width | Self::Both)
}
fn height(self) -> bool {
matches!(self, Self::Height | Self::Both)
}
}
/// Canonical drop destination for canvas node dragging. The host
/// resolves hit-testing and absolute container bounds; core owns the
/// document-tree mutation and coordinate conversion.
@ -79,32 +101,21 @@ pub fn parent_of(children: &[PenNode], target: &NodeId) -> Option<NodeId> {
None
}
/// Recursively scale child relative positions and explicit pixel
/// sizes by `(sx, sy)` — a faithful port of pen-core
/// `tree-utils.ts::scaleChildrenInPlace`. Flex-flow children carry no
/// authored `x` / `y`, so only their sizes scale and the layout
/// engine reflows their positions, matching the TS semantics.
pub fn scale_children_in_place(children: &mut [PenNode], sx: f64, sy: f64) {
for child in children.iter_mut() {
{
let base = child.base_mut();
if let Some(x) = base.x {
base.x = Some(x * sx);
}
if let Some(y) = base.y {
base.y = Some(y * sy);
}
}
if let Some(w) = child.width_px() {
child.set_width_px(w * sx);
}
if let Some(h) = child.height_px() {
child.set_height_px(h * sy);
}
if let Some(grand) = child.children_mut() {
scale_children_in_place(grand, sx, sy);
}
}
/// A child participates in its parent's auto-layout flow only when the parent
/// is flex and the child has no explicit position. Jian treats either `x` or
/// `y` as an absolute-position contract, so overlays inside a flex container
/// must still accept parent-relative left/top resize writes.
fn selected_is_flow_child(children: &[PenNode], target: &NodeId) -> bool {
let Some((Some(parent_id), _)) = walkers::find_parent_and_index(children, target) else {
return false;
};
let Some(parent) = walkers::find_node(children, &parent_id) else {
return false;
};
let Some(node) = walkers::find_node(children, target) else {
return false;
};
parent.is_auto_layout_container() && node.base().x.is_none() && node.base().y.is_none()
}
impl EditorState {
@ -112,46 +123,55 @@ impl EditorState {
/// `x`/`y` + `width`/`height`). No-op when the node is locked /
/// hidden / missing.
///
/// TS parity (`skia-interaction.ts:626-744` + pen-core
/// `scaleChildrenInPlace`): resizing a container scales the whole
/// subtree proportionally, and resizing an auto-grow text node
/// pins it to `textGrowth: fixed-width` so the new width sticks.
/// Convenience for operations that author a complete new rectangle
/// (shape creation and direct geometry tests). Handle drags should call
/// [`Self::resize_selected_bounds`] so an untouched Fill/Hug axis stays
/// authored as Fill/Hug.
pub fn set_selected_bounds(&mut self, bounds: DocRect) {
self.resize_selected_bounds(bounds, ResizeAxes::Both, Some(bounds.x), Some(bounds.y));
}
/// Resize only the selected node. Descendant authored geometry is never
/// multiplied; normal layout re-resolution is solely responsible for
/// adapting Fill/Hug children, text wrapping, alignment, and flex gaps.
///
/// `new_x` / `new_y` are parent-relative authored coordinates supplied
/// only when the dragged left/top edge moves. Flow children ignore them
/// and keep `x/y: None`; explicit overlays inside auto-layout are not flow
/// children and therefore retain correct relative positioning.
pub fn resize_selected_bounds(
&mut self,
bounds: DocRect,
axes: ResizeAxes,
new_x: Option<f64>,
new_y: Option<f64>,
) {
let sel = self.selection.anchor.clone();
if !sel.is_real() || !self.is_editable(&sel) {
return;
}
let is_flow_child = walkers::is_flow_child_of_flex(self.active_children(), &sel);
let is_flow_child = selected_is_flow_child(self.active_children(), &sel);
if let Some(node) = find_node_mut(self.active_children_mut(), &sel) {
// Only write a real rect — container nodes that derive
// their size from children keep deriving it.
let own = own_bounds(node);
if own.w > 0.0 || own.h > 0.0 {
if !is_flow_child {
node.base_mut().x = Some(bounds.x);
node.base_mut().y = Some(bounds.y);
if !is_flow_child {
if let Some(x) = new_x.filter(|value| value.is_finite()) {
node.base_mut().x = Some(x);
}
if let Some(y) = new_y.filter(|value| value.is_finite()) {
node.base_mut().y = Some(y);
}
}
if axes.width() && bounds.w.is_finite() && bounds.w > 0.0 {
node.set_width_px(bounds.w);
}
if axes.height() && bounds.h.is_finite() && bounds.h > 0.0 {
node.set_height_px(bounds.h);
}
if axes.width() {
if let PenNode::Text(text) = &mut *node {
if text.text_growth.is_none() {
text.text_growth = Some(TextGrowth::FixedWidth);
}
}
// Container resize carries the subtree with it. Each
// incremental drag step scales current → next, so the
// composition over the whole drag equals the total
// scale TS commits once at drag end.
let sx = if own.w > 0.0 { bounds.w / own.w } else { 1.0 };
let sy = if own.h > 0.0 { bounds.h / own.h } else { 1.0 };
if sx != 1.0 || sy != 1.0 {
if let Some(children) = node.children_mut() {
scale_children_in_place(children, sx, sy);
}
}
} else if !is_flow_child {
node.base_mut().x = Some(bounds.x);
node.base_mut().y = Some(bounds.y);
}
}
}

View file

@ -1279,8 +1279,10 @@ pub struct EditorUiState {
pub collapsed_layers: HashSet<NodeId>,
/// Last LayerPanel click target + ms; 400 ms re-press → rename.
pub last_layer_click: Option<(LayerContextTarget, u64)>,
/// Last canvas left-click target + ms; 400 ms same-node re-press
/// on a Text node promotes to inline text edit.
/// Deepest canvas hit + ms for the first half of a possible
/// double-click. A same-hit re-press within 400 ms drills exactly
/// one hierarchy level (or enters inline edit when no child level
/// remains on a Text node).
pub last_canvas_click: Option<(NodeId, u64)>,
/// Last VariablesPanel name-cell click + ms; 400 ms same-row
/// re-press promotes to variable rename.
@ -1340,14 +1342,15 @@ pub struct EditorUiState {
// --- Component browser ------------------------------------------
/// Whether the floating Component-Browser panel is shown.
pub component_browser_open: bool,
/// Canvas node under the cursor (Select tool, no drag) — drives
/// the dashed hover outline (TS `hoveredNodeId`).
/// Current hierarchy focus under the cursor (Select tool, no
/// drag). Canvas paint outlines the focus solid and all of its
/// direct visible children dashed.
pub canvas_hover_node: Option<NodeId>,
/// Frame/group the user "entered" by double-clicking it while
/// selected (TS `enteredFrameId`). While set, canvas-click parent
/// promotion stops at this container's children instead of
/// promoting to the page-root level. Escape and selecting outside
/// the container exit it. Transient: never serialized.
/// Sibling scope entered by a one-level canvas double-click. While
/// set, the scope's direct children are the current single-click
/// targets and their children are the next drill candidates.
/// Escape and selecting outside the scope exit it. Transient:
/// never serialized.
pub entered_container: Option<NodeId>,
/// Top-left corner of the Component-Browser panel in logical px;
/// `None` until first opened — the host then centres it.

View file

@ -321,9 +321,9 @@ impl EditorState {
}
}
// `set_selected_bounds` (handle-drag resize, incl. descendant
// scaling) lives in `drag_mutators.rs` — split out to keep this
// file under the 800-line ceiling.
// Selection-handle resize lives in `drag_mutators.rs` — split out to
// keep this file under the 800-line ceiling. It changes only the selected
// node; descendants adapt through normal layout resolution.
/// Translate every node in the selection set by `(dx, dy)` doc
/// px. Containers carry their subtree (child coords are parent-

View file

@ -1,38 +1,57 @@
//! Canvas click selection semantics: parent promotion + enter-group.
//! Canvas hover and click depth resolution.
//!
//! Ports the TS behavior from `skia-interaction.ts:368-405` (click
//! promotion / click-child-of-selected) generalized to the audit's
//! agreed semantics: a click on a nested node promotes to its
//! outermost frame/group ancestor below the page root (unit
//! selection), UNLESS the user has "entered" that container via
//! double-click — then promotion stops at the entered container's
//! child. The entered container lives on
//! `EditorUiState::entered_container`; Escape and selecting outside
//! the container exit it.
//!
//! DELIBERATE DIVERGENCE from TS: promotion climbs transitively to
//! the highest contiguous frame/group ancestor below the page root
//! (Figma-style unit selection). TS's literal code promotes only one
//! level under a grandparent condition — the climb is the intended
//! UX per the parity audit. Shift+click toggles the RESOLVED target
//! (deselects an already-selected hit); TS's shift branch no-ops on
//! selected hits, which reads as a quirk rather than intent.
//! A scene hit supplies a path ordered from the design root to the
//! deepest painted node under the pointer. The design root is an
//! implicit scope: a normal interaction targets its direct child,
//! while an entered container becomes the scope and targets its
//! direct child. The next path element is exposed separately for
//! double-click selection and dashed secondary hover feedback.
use crate::node_id::NodeId;
use crate::pen_node_ext::PenNodeExt;
use crate::state::EditorState;
use crate::walkers::{descendant_contains, find_node};
use jian_ops_schema::node::PenNode;
use jian_ops_schema::style::PenFill;
/// Outcome of resolving a canvas hit against the current selection.
/// The two node depths relevant to one pointer hit.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CanvasDepthTargets {
/// Direct child of the active scope, or the scope itself when the
/// path has no deeper node.
pub primary: NodeId,
/// Direct child of `primary` under the pointer, when present.
pub secondary_under_pointer: Option<NodeId>,
}
/// Resolve one root-to-deepest scene hit path without skipping levels.
///
/// With no entered container, the first path item is the implicit
/// design scope. When `entered` occurs in the path, it replaces that
/// scope. An entered container outside this hit path (for example a
/// sibling) has no effect on the result.
pub fn resolve_canvas_depth_targets(
path: &[NodeId],
entered: Option<&NodeId>,
) -> Option<CanvasDepthTargets> {
let last_index = path.len().checked_sub(1)?;
let scope_index = entered
.and_then(|entered| path.iter().position(|id| id == entered))
.unwrap_or(0);
let primary_index = (scope_index + 1).min(last_index);
Some(CanvasDepthTargets {
primary: path[primary_index].clone(),
secondary_under_pointer: path.get(primary_index + 1).cloned(),
})
}
/// Compatibility outcome for the older id-only selection entry point.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SelectionResolution {
/// The hit is a child of an already-selected node — keep the
/// selection unchanged (TS "click child of selected" rule); the
/// press still drags the existing set.
/// Legacy no-op outcome retained for downstream API compatibility.
/// Depth-based resolution no longer emits it.
Keep,
/// Select this node (the promoted ancestor, or the hit itself).
/// Select the resolved primary depth.
Select(NodeId),
}
@ -48,18 +67,6 @@ pub fn is_strict_descendant(children: &[PenNode], ancestor: &NodeId, node: &Node
descendant_contains(anc, node)
}
/// TS `hasImageVisual`: an Image node, or any node carrying an image
/// fill. Such nodes select directly — promotion would make images
/// inside frames impossible to grab.
pub fn node_has_image_visual(node: &PenNode) -> bool {
if matches!(node, PenNode::Image(_)) {
return true;
}
crate::fills::node_fills(node)
.map(|fills| fills.iter().any(|f| matches!(f, PenFill::Image(_))))
.unwrap_or(false)
}
/// Ancestor chain from the top-level node down to `target`
/// (inclusive): `[top, …, parent, target]`.
fn ancestor_path(children: &[PenNode], target: &NodeId) -> Option<Vec<NodeId>> {
@ -79,63 +86,24 @@ fn ancestor_path(children: &[PenNode], target: &NodeId) -> Option<Vec<NodeId>> {
None
}
/// Resolve the node a canvas press should select, given the deepest
/// hit, the current selection, and the entered container.
/// Resolve the primary selection target for callers that only have a
/// document-tree hit id rather than a scene hit path.
///
/// Rules (TS `skia-interaction.ts:368-405`, generalized):
/// 1. A hit that is already selected re-selects itself (the host
/// keeps multi-set membership on plain click).
/// 2. A hit that is a strict descendant of any selected node keeps
/// the selection unchanged — clicking a child of a selected
/// container drags the container.
/// 3. Image-visual hits select directly (TS `hasImageVisual` guard).
/// 4. Otherwise the hit promotes to its highest contiguous
/// frame/group ancestor below the page root. Climbing stops at
/// the entered container, so inside an entered container the
/// promotion lands on that container's child.
/// This compatibility entry point reconstructs the root-to-deepest
/// path and applies [`resolve_canvas_depth_targets`]. Node type,
/// image fills, and the current selection never skip a path level.
pub fn resolve_canvas_selection_target(
children: &[PenNode],
deepest: &NodeId,
entered: Option<&NodeId>,
selection: &[NodeId],
_selection: &[NodeId],
) -> SelectionResolution {
if selection.iter().any(|s| s == deepest) {
return SelectionResolution::Select(deepest.clone());
}
if selection
.iter()
.any(|sel| is_strict_descendant(children, sel, deepest))
{
return SelectionResolution::Keep;
}
// Clicking the entered container's own surface selects it as-is.
if entered == Some(deepest) {
return SelectionResolution::Select(deepest.clone());
}
if let Some(node) = find_node(children, deepest) {
if node_has_image_visual(node) {
return SelectionResolution::Select(deepest.clone());
}
}
let Some(path) = ancestor_path(children, deepest) else {
return SelectionResolution::Select(deepest.clone());
};
// Climb from the nearest ancestor towards the page root while the
// ancestors stay frame/group containers; never climb up to (or
// past) the entered container.
let mut target = deepest.clone();
for anc in path.iter().rev().skip(1) {
if entered == Some(anc) {
break;
}
let Some(anc_node) = find_node(children, anc) else {
break;
};
if !matches!(anc_node, PenNode::Frame(_) | PenNode::Group(_)) {
break;
}
target = anc.clone();
}
let target = resolve_canvas_depth_targets(&path, entered)
.map(|targets| targets.primary)
.unwrap_or_else(|| deepest.clone());
SelectionResolution::Select(target)
}
@ -160,3 +128,85 @@ impl EditorState {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ids(values: &[&str]) -> Vec<NodeId> {
values.iter().map(|value| NodeId::new(*value)).collect()
}
#[test]
fn four_level_path_exposes_only_the_next_two_depths() {
let path = ids(&["root", "level-1", "level-2", "level-3"]);
assert_eq!(
resolve_canvas_depth_targets(&path, None),
Some(CanvasDepthTargets {
primary: NodeId::new("level-1"),
secondary_under_pointer: Some(NodeId::new("level-2")),
})
);
}
#[test]
fn entered_scope_advances_exactly_one_level_without_skipping() {
let path = ids(&["root", "level-1", "level-2", "level-3"]);
let entered = NodeId::new("level-1");
assert_eq!(
resolve_canvas_depth_targets(&path, Some(&entered)),
Some(CanvasDepthTargets {
primary: NodeId::new("level-2"),
secondary_under_pointer: Some(NodeId::new("level-3")),
})
);
}
#[test]
fn deepest_entered_scope_resolves_to_itself() {
let path = ids(&["root", "level-1", "level-2"]);
let entered = NodeId::new("level-2");
assert_eq!(
resolve_canvas_depth_targets(&path, Some(&entered)),
Some(CanvasDepthTargets {
primary: NodeId::new("level-2"),
secondary_under_pointer: None,
})
);
}
#[test]
fn entered_sibling_does_not_change_the_implicit_root_scope() {
let path = ids(&["root", "level-1", "level-2"]);
let sibling = NodeId::new("other-branch");
assert_eq!(
resolve_canvas_depth_targets(&path, Some(&sibling)),
Some(CanvasDepthTargets {
primary: NodeId::new("level-1"),
secondary_under_pointer: Some(NodeId::new("level-2")),
})
);
}
#[test]
fn root_surface_resolves_to_root_without_a_secondary() {
let path = ids(&["root"]);
assert_eq!(
resolve_canvas_depth_targets(&path, None),
Some(CanvasDepthTargets {
primary: NodeId::new("root"),
secondary_under_pointer: None,
})
);
}
#[test]
fn empty_path_has_no_depth_targets() {
assert_eq!(resolve_canvas_depth_targets(&[], None), None);
}
}

View file

@ -1,12 +1,12 @@
//! Tests for the canvas-drag P1 gaps: selection resolution (parent
//! promotion + enter-group), container-resize descendant scaling, and
//! promotion + enter-group), Pencil-like container resize, and
//! drag-end reorder / reparent mutators.
#![cfg(test)]
use crate::drag_mutators::{
auto_layout_direction, parent_of, should_auto_reparent_outside_parent, DragDropTarget,
FlexDirection,
FlexDirection, ResizeAxes,
};
use crate::geometry::DocRect;
use crate::node_id::NodeId;
@ -29,7 +29,7 @@ fn nested_state() -> crate::EditorState {
// --- Selection resolution (GAP A) -------------------------------------
#[test]
fn click_on_nested_child_promotes_to_top_level_container() {
fn click_on_nested_child_selects_the_roots_direct_child() {
let s = nested_state();
let resolved = resolve_canvas_selection_target(
s.active_children(),
@ -37,11 +37,11 @@ fn click_on_nested_child_promotes_to_top_level_container() {
None,
&s.selection.set,
);
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("f1")));
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1")));
}
#[test]
fn click_on_child_of_selected_container_keeps_selection() {
fn click_on_child_of_selected_root_advances_to_the_next_depth() {
let mut s = nested_state();
s.set_single_selection(NodeId::new("f1"));
let resolved = resolve_canvas_selection_target(
@ -50,11 +50,11 @@ fn click_on_child_of_selected_container_keeps_selection() {
None,
&s.selection.set,
);
assert_eq!(resolved, SelectionResolution::Keep);
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1")));
}
#[test]
fn click_on_already_selected_node_reselects_it() {
fn current_deep_selection_does_not_bypass_primary_depth() {
let mut s = nested_state();
s.set_single_selection(NodeId::new("r1"));
let resolved = resolve_canvas_selection_target(
@ -63,7 +63,7 @@ fn click_on_already_selected_node_reselects_it() {
None,
&s.selection.set,
);
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("r1")));
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1")));
}
#[test]
@ -107,12 +107,11 @@ fn top_level_hit_resolves_to_itself() {
}
#[test]
fn image_visual_hit_skips_promotion() {
// TS `hasImageVisual` guard: an image-filled child selects
// directly instead of promoting to its frame.
fn image_visual_hit_obeys_the_same_depth_rule() {
let mut img = rect("img", "Photo", 10.0, 10.0, 50.0, 50.0);
crate::fills::set_primary_fill_type(&mut img, crate::FillType::Image);
let f1 = frame("f1", "Card", 0.0, 0.0, 200.0, 200.0, vec![img]);
let g1 = group("g1", "Media", vec![img]);
let f1 = frame("f1", "Card", 0.0, 0.0, 200.0, 200.0, vec![g1]);
let s = state_with(vec![f1]);
let resolved = resolve_canvas_selection_target(
s.active_children(),
@ -120,7 +119,7 @@ fn image_visual_hit_skips_promotion() {
None,
&s.selection.set,
);
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("img")));
assert_eq!(resolved, SelectionResolution::Select(NodeId::new("g1")));
}
#[test]
@ -486,36 +485,38 @@ fn parent_of_and_direction_helpers_walk_the_tree() {
assert_eq!(auto_layout_direction(&free), None);
}
// --- Container resize scales descendants (GAP C) -----------------------
// --- Container resize preserves descendants (Pencil parity) ------------
#[test]
fn container_resize_scales_free_descendants_recursively() {
fn container_resize_keeps_free_descendant_authored_geometry() {
let leaf = rect("leaf", "Leaf", 5.0, 5.0, 10.0, 10.0);
let inner = frame("inner", "Inner", 20.0, 40.0, 100.0, 50.0, vec![leaf]);
let root = frame("root", "Root", 100.0, 100.0, 200.0, 100.0, vec![inner]);
let mut s = state_with(vec![root]);
s.set_single_selection(NodeId::new("root"));
// 200×100 → 400×300: sx = 2, sy = 3.
s.set_selected_bounds(DocRect {
x: 100.0,
y: 100.0,
w: 400.0,
h: 300.0,
});
let root = find_node(s.active_children(), &NodeId::new("root")).unwrap();
assert_eq!(root.width_px(), Some(400.0));
assert_eq!(root.height_px(), Some(300.0));
let inner = find_node(s.active_children(), &NodeId::new("inner")).unwrap();
assert_eq!(inner.base().x, Some(40.0));
assert_eq!(inner.base().y, Some(120.0));
assert_eq!(inner.width_px(), Some(200.0));
assert_eq!(inner.height_px(), Some(150.0));
assert_eq!(inner.base().x, Some(20.0));
assert_eq!(inner.base().y, Some(40.0));
assert_eq!(inner.width_px(), Some(100.0));
assert_eq!(inner.height_px(), Some(50.0));
let leaf = find_node(s.active_children(), &NodeId::new("leaf")).unwrap();
assert_eq!(leaf.base().x, Some(10.0));
assert_eq!(leaf.base().y, Some(15.0));
assert_eq!(leaf.width_px(), Some(20.0));
assert_eq!(leaf.height_px(), Some(30.0));
assert_eq!(leaf.base().x, Some(5.0));
assert_eq!(leaf.base().y, Some(5.0));
assert_eq!(leaf.width_px(), Some(10.0));
assert_eq!(leaf.height_px(), Some(10.0));
}
#[test]
fn container_resize_scales_flex_children_sizes_only() {
fn container_resize_keeps_fixed_flex_child_size() {
let f = flex_frame(
"f1",
"Stack",
@ -534,21 +535,18 @@ fn container_resize_scales_flex_children_sizes_only() {
h: 200.0,
});
let a = find_node(s.active_children(), &NodeId::new("a")).unwrap();
// Flow children keep layout-engine positions (no materialized x/y)…
assert_eq!(a.base().x, None);
assert_eq!(a.base().y, None);
// …but their explicit sizes scale with the container.
assert_eq!(a.width_px(), Some(200.0));
assert_eq!(a.height_px(), Some(80.0));
assert_eq!(a.width_px(), Some(100.0));
assert_eq!(a.height_px(), Some(40.0));
}
#[test]
fn incremental_resize_steps_compose_to_the_total_scale() {
fn incremental_container_resize_never_accumulates_descendant_changes() {
let leaf = rect("leaf", "Leaf", 10.0, 10.0, 10.0, 10.0);
let root = frame("root", "Root", 0.0, 0.0, 100.0, 100.0, vec![leaf]);
let mut s = state_with(vec![root]);
s.set_single_selection(NodeId::new("root"));
// Two drag steps: 100 → 150 → 200 must equal one 100 → 200 step.
for w in [150.0, 200.0] {
s.set_selected_bounds(DocRect {
x: 0.0,
@ -558,12 +556,103 @@ fn incremental_resize_steps_compose_to_the_total_scale() {
});
}
let leaf = find_node(s.active_children(), &NodeId::new("leaf")).unwrap();
assert_eq!(leaf.base().x, Some(20.0));
assert_eq!(leaf.width_px(), Some(20.0));
assert_eq!(leaf.base().x, Some(10.0));
assert_eq!(leaf.width_px(), Some(10.0));
assert_eq!(leaf.base().y, Some(10.0));
assert_eq!(leaf.height_px(), Some(10.0));
}
#[test]
fn single_axis_resize_freezes_only_that_axis_and_preserves_child_keywords() {
let doc = jian_ops_schema::load_str(
r#"{"version":"0.8.0","children":[{"type":"frame","id":"root","width":"fill_container","height":"fit_content","layout":"vertical","children":[{"type":"rectangle","id":"child","width":"fill_container","height":40}]}]}"#,
)
.expect("keyword fixture parses")
.value;
let mut s = crate::EditorState::from_document(doc);
s.set_single_selection(NodeId::new("root"));
s.resize_selected_bounds(
DocRect {
x: 0.0,
y: 0.0,
w: 360.0,
h: 240.0,
},
ResizeAxes::Width,
None,
None,
);
let root = find_node(s.active_children(), &NodeId::new("root")).unwrap();
let root_json = serde_json::to_value(root).unwrap();
assert_eq!(root_json["width"], serde_json::json!(360.0));
assert_eq!(root_json["height"], serde_json::json!("fit_content"));
let child = find_node(s.active_children(), &NodeId::new("child")).unwrap();
let child_json = serde_json::to_value(child).unwrap();
assert_eq!(child_json["width"], serde_json::json!("fill_container"));
assert_eq!(child_json["height"], serde_json::json!(40.0));
}
#[test]
fn nested_free_resize_accepts_parent_relative_left_and_top() {
let child = rect("child", "Child", 20.0, 30.0, 80.0, 60.0);
let root = frame("root", "Root", 100.0, 200.0, 300.0, 300.0, vec![child]);
let mut s = state_with(vec![root]);
s.set_single_selection(NodeId::new("child"));
s.resize_selected_bounds(
DocRect {
x: 130.0,
y: 240.0,
w: 70.0,
h: 50.0,
},
ResizeAxes::Both,
Some(30.0),
Some(40.0),
);
let child = find_node(s.active_children(), &NodeId::new("child")).unwrap();
assert_eq!(child.base().x, Some(30.0));
assert_eq!(child.base().y, Some(40.0));
assert_eq!(child.width_px(), Some(70.0));
assert_eq!(child.height_px(), Some(50.0));
}
#[test]
fn flow_child_resize_never_materializes_position() {
let f = flex_frame(
"root",
"Stack",
0.0,
0.0,
200.0,
200.0,
vec![flow_rect("child", "Child", 80.0, 40.0)],
);
let mut s = state_with(vec![f]);
s.set_single_selection(NodeId::new("child"));
s.resize_selected_bounds(
DocRect {
x: 100.0,
y: 100.0,
w: 120.0,
h: 999.0,
},
ResizeAxes::Width,
Some(100.0),
Some(100.0),
);
let child = find_node(s.active_children(), &NodeId::new("child")).unwrap();
assert_eq!(child.base().x, None);
assert_eq!(child.base().y, None);
assert_eq!(child.width_px(), Some(120.0));
assert_eq!(child.height_px(), Some(40.0));
}
#[test]
fn resizing_auto_grow_text_pins_fixed_width_growth() {
let t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello");
@ -582,6 +671,31 @@ fn resizing_auto_grow_text_pins_fixed_width_growth() {
}
}
#[test]
fn height_only_text_resize_does_not_change_text_growth() {
let t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello");
let mut s = state_with(vec![t]);
s.set_single_selection(NodeId::new("t1"));
s.resize_selected_bounds(
DocRect {
x: 0.0,
y: 0.0,
w: 999.0,
h: 40.0,
},
ResizeAxes::Height,
None,
None,
);
let n = find_node(s.active_children(), &NodeId::new("t1")).unwrap();
assert_eq!(n.width_px(), Some(100.0));
assert_eq!(n.height_px(), Some(40.0));
match n {
PenNode::Text(t) => assert_eq!(t.text_growth, None),
other => panic!("expected text node, got {other:?}"),
}
}
#[test]
fn resizing_text_with_explicit_growth_keeps_it() {
let mut t = text("t1", "Title", 0.0, 0.0, 100.0, 20.0, "Hello");

View file

@ -26,20 +26,20 @@ fn examples_grid_has_four_cards() {
}
#[test]
fn second_example_is_music_streaming_home_prompt() {
fn second_example_uses_short_title_and_full_music_prompt() {
let en = example_cards(op_editor_core::Locale::EnUs);
assert_eq!(en[1].title, "Dark music streaming mobile app");
assert_eq!(
en[1].title,
en[1].prompt,
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
);
assert_eq!(en[1].prompt, en[1].title);
let zh = example_cards(op_editor_core::Locale::ZhCn);
assert_eq!(zh[1].title, "暗色音乐流媒体 App 首页");
assert_eq!(
zh[1].title,
zh[1].prompt,
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片封面和歌单名\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
);
assert_eq!(zh[1].prompt, zh[1].title);
}
#[test]

View file

@ -209,15 +209,6 @@ pub(crate) fn silhouette_for(style: op_editor_core::PencilCursorStyle) -> Silhou
/// Fallback for reveals not owned by any tagged agent (the same red the
/// retired dashed-reveal border used as its untagged default).
/// O4 outline halo — near-black slate, drawn wide and translucent
/// OUTSIDE the white rim.
const PENCIL_HALO: Color = Color {
r: 0.067,
g: 0.094,
b: 0.153,
a: 1.0,
};
/// Chubby variant's eraser butt.
const PENCIL_ERASER_PINK: Color = Color {
r: 1.0,
@ -643,7 +634,7 @@ pub(crate) fn paint_cursor_swatch(
.collect()
};
let body = at(silhouette.body);
paint_soft_halo(cx, &body, 1.0);
paint_soft_shadow(cx, &body, 1.0);
paint_rim(cx, &body, 0.95);
cx.backend.fill_polygon(&body, color);
if let Some(eraser) = silhouette.eraser {
@ -662,11 +653,11 @@ pub(crate) fn paint_cursor_swatch(
}
/// Uniformly outset a silhouette by `offset` px about its centroid. Used for
/// both the halo layers and the white rim: a filled outset paints the rim as
/// both the shadow layers and the white rim: a filled outset paints the rim as
/// GEOMETRY, so its width is exact everywhere and no stroke joins can notch it
/// (the trait's fallback polygon stroke drew each edge as its own capped
/// segment — every vertex of the densely-sampled arc showed a jaggy).
fn outset(body: &[Point2D], offset: f32, drop: f32) -> Vec<Point2D> {
fn outset(body: &[Point2D], offset: f32, shift_x: f32, shift_y: f32) -> Vec<Point2D> {
let n = body.len() as f32;
let (mut sum_x, mut sum_y) = (0.0f32, 0.0f32);
for p in body {
@ -680,30 +671,27 @@ fn outset(body: &[Point2D], offset: f32, drop: f32) -> Vec<Point2D> {
.fold(1.0f32, f32::max);
let k = 1.0 + offset / radius;
body.iter()
.map(|p| Point2D::new(cx + (p.x - cx) * k, cy + (p.y - cy) * k + drop))
.map(|p| Point2D::new(cx + (p.x - cx) * k + shift_x, cy + (p.y - cy) * k + shift_y))
.collect()
}
/// Width of the white rim (px of outset beyond the body silhouette).
const RIM: f32 = 1.6;
/// Soft halo as concentric FILLED expansions of the silhouette, largest
/// first with per-layer alpha stacking smoothly toward the body. Filled
/// polygons have no stroke joins, so sharp silhouette corners cannot spike
/// (three concentric STROKES read as a dirty banded ring - user feedback
/// 2026-07-12, twice). The slight downward bias doubles as the contact
/// shadow, replacing the old hard-edged offset copy.
fn paint_soft_halo(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) {
// The halo sits OUTSIDE the white rim, so every layer clears it.
/// Pencil-style contact shadow: narrow neutral-black feather, shifted a
/// half-pixel left/down. Filled expansions keep the fallback painter soft
/// without introducing the jagged polygon joins produced by strokes.
fn paint_soft_shadow(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) {
// The shadow sits outside the white rim; largest/faintest paints first.
for (offset, alpha) in [
(RIM + 3.2, 0.030),
(RIM + 2.4, 0.040),
(RIM + 1.6, 0.050),
(RIM + 0.8, 0.060),
(RIM + 1.6, 0.030),
(RIM + 1.2, 0.040),
(RIM + 0.8, 0.050),
(RIM + 0.4, 0.060),
] {
let ring = outset(body, offset, 0.7);
let ring = outset(body, offset, -0.5, 0.5);
cx.backend
.fill_polygon(&ring, PENCIL_HALO.with_alpha(alpha * alpha_scale));
.fill_polygon(&ring, Color::BLACK.with_alpha(alpha * alpha_scale));
}
}
@ -711,7 +699,7 @@ fn paint_soft_halo(cx: &mut PaintCx<'_>, body: &[Point2D], alpha_scale: f32) {
/// ring of exactly `RIM` px with no stroke joins to notch it.
fn paint_rim(cx: &mut PaintCx<'_>, body: &[Point2D], alpha: f32) {
cx.backend
.fill_polygon(&outset(body, RIM, 0.0), Color::WHITE.with_alpha(alpha));
.fill_polygon(&outset(body, RIM, 0.0, 0.0), Color::WHITE.with_alpha(alpha));
}
fn paint_sprite(cx: &mut PaintCx<'_>, sprite: &CursorSprite, now_ms: u64, silhouette: &Silhouette) {
@ -740,9 +728,8 @@ fn paint_sprite(cx: &mut PaintCx<'_>, sprite: &CursorSprite, now_ms: u64, silhou
.collect()
};
let body = at(silhouette.body);
// Dark soft halo OUTSIDE the white rim (user-picked "O4", the macOS
// pointer treatment) - filled-expansion feather, see paint_soft_halo.
paint_soft_halo(cx, &body, sprite.alpha);
// Narrow neutral contact shadow outside the white rim.
paint_soft_shadow(cx, &body, sprite.alpha);
paint_rim(cx, &body, 0.95 * sprite.alpha);
cx.backend
.fill_polygon(&body, sprite.color.with_alpha(sprite.alpha));
@ -807,6 +794,19 @@ fn paint_name_pill(cx: &mut PaintCx<'_>, sprite: &CursorSprite, name: &str) {
name_w + PAD_X * 2.0,
PILL_H,
);
// Pencil's label uses the same compact contact shadow as its pointer:
// nearly centred, neutral black, and just soft enough to lift the tag.
cx.backend.fill_drop_shadow(
Rect::xywh(
pill.origin.x - 0.5,
pill.origin.y + 0.5,
pill.size.x,
pill.size.y,
),
PILL_H / 2.0,
1.5,
Color::BLACK.with_alpha(0.28 * sprite.alpha),
);
cx.backend.fill_round_rect(
pill,
PILL_H / 2.0,

View file

@ -469,9 +469,11 @@ mod paint_tests {
struct CursorCaptureBackend {
polygons: Vec<(Vec<Point2D>, Color)>,
polygon_strokes: Vec<(Vec<Point2D>, Color)>,
drop_shadows: Vec<(Rect, f32, f32, Color)>,
round_fills: Vec<(Rect, Color)>,
round_strokes: Vec<(Rect, Color, f32)>,
labels: Vec<String>,
paint_ops: Vec<&'static str>,
}
impl RenderBackend for CursorCaptureBackend {
@ -480,6 +482,7 @@ mod paint_tests {
fn fill_rect(&mut self, _: Rect, _: Color) {}
fn stroke_rect(&mut self, _: Rect, _: Color, _: f32) {}
fn draw_text(&mut self, layout: &TextLayout, _: Point2D) {
self.paint_ops.push("text");
if let Some(run) = layout.runs().first() {
self.labels.push(run.content.clone());
}
@ -490,8 +493,13 @@ mod paint_tests {
fn translate(&mut self, _: Point2D) {}
fn stroke_line(&mut self, _: Point2D, _: Point2D, _: Color, _: f32) {}
fn fill_round_rect(&mut self, rect: Rect, _: f32, color: Color) {
self.paint_ops.push("pill");
self.round_fills.push((rect, color));
}
fn fill_drop_shadow(&mut self, rect: Rect, radius: f32, blur: f32, color: Color) {
self.paint_ops.push("shadow");
self.drop_shadows.push((rect, radius, blur, color));
}
fn stroke_round_rect(&mut self, rect: Rect, _: f32, color: Color, width: f32) {
self.round_strokes.push((rect, color, width));
}
@ -545,7 +553,7 @@ mod paint_tests {
assert_eq!(
backend.polygons.len(),
7,
"4 halo feather layers + the white rim + body + tip wedge"
"4 shadow feather layers + the white rim + body + tip wedge"
);
let (pts, color) = &backend.polygons[5];
assert_eq!(
@ -558,11 +566,22 @@ mod paint_tests {
(pts[0].x - 40.0).abs() < 0.01 && (pts[0].y - 60.0).abs() < 0.01,
"pencil tip sits on the current element's centre"
);
let centroid = |polygon: &[Point2D]| {
let count = polygon.len() as f32;
Point2D::new(
polygon.iter().map(|p| p.x).sum::<f32>() / count,
polygon.iter().map(|p| p.y).sum::<f32>() / count,
)
};
let shadow_center = centroid(&backend.polygons[0].0);
let body_center = centroid(pts);
assert!((shadow_center.x - body_center.x + 0.5).abs() < 0.01);
assert!((shadow_center.y - body_center.y - 0.5).abs() < 0.01);
// The rim is FILLED geometry, not a stroke: the trait's fallback
// polygon stroke drew each edge as its own capped segment, which
// notched every vertex of the densely-sampled arc (user report
// 2026-07-12: "不要有锯齿感"). It must still be white, and sit
// between the halo and the body.
// between the shadow and the body.
let (rim_pts, rim_color) = &backend.polygons[4];
assert!(
(rim_color.r - 1.0).abs() < 0.01
@ -581,7 +600,21 @@ mod paint_tests {
skeleton owns the working-area affordance now"
);
assert_eq!(backend.labels, vec!["Mochi".to_string()]);
assert!(!backend.round_fills.is_empty(), "name pill paints");
let (pill, _) = backend.round_fills.last().expect("name pill paints");
let (shadow, radius, blur, color) = backend
.drop_shadows
.last()
.expect("name pill paints a contact shadow");
assert!((shadow.origin.x - pill.origin.x + 0.5).abs() < 0.01);
assert!((shadow.origin.y - pill.origin.y - 0.5).abs() < 0.01);
assert_eq!(shadow.size, pill.size);
assert!((*radius - 8.5).abs() < 0.01 && (*blur - 1.5).abs() < 0.01);
assert!(color.r < 0.01 && color.g < 0.01 && color.b < 0.01);
assert!((color.a - 0.28).abs() < 0.01);
assert_eq!(
&backend.paint_ops[backend.paint_ops.len() - 3..],
&["shadow", "pill", "text"]
);
}
#[test]
@ -609,6 +642,10 @@ mod paint_tests {
"fallback red fill"
);
assert!(backend.labels.is_empty(), "no name pill without a tag");
assert!(
backend.drop_shadows.is_empty(),
"no pill shadow without a tag"
);
assert!(backend.round_fills.is_empty(), "no pill capsule either");
assert!(
backend.round_strokes.is_empty(),
@ -635,6 +672,7 @@ mod paint_tests {
);
assert!(
backend.polygons.is_empty()
&& backend.drop_shadows.is_empty()
&& backend.round_fills.is_empty()
&& backend.round_strokes.is_empty()
);

View file

@ -50,6 +50,39 @@ pub enum SelectionHandle {
Left,
}
impl SelectionHandle {
/// Authored dimensions changed by this handle.
pub fn resize_axes(self) -> op_editor_core::drag_mutators::ResizeAxes {
use op_editor_core::drag_mutators::ResizeAxes;
match (self.resizes_width(), self.resizes_height()) {
(true, true) => ResizeAxes::Both,
(true, false) => ResizeAxes::Width,
(false, true) => ResizeAxes::Height,
(false, false) => unreachable!("every selection handle resizes at least one axis"),
}
}
/// Whether this handle authors the selected node's width.
pub fn resizes_width(self) -> bool {
!matches!(self, Self::Top | Self::Bottom)
}
/// Whether this handle authors the selected node's height.
pub fn resizes_height(self) -> bool {
!matches!(self, Self::Left | Self::Right)
}
/// Whether dragging this handle moves the selected node's left edge.
pub fn moves_left_edge(self) -> bool {
matches!(self, Self::Left | Self::TopLeft | Self::BottomLeft)
}
/// Whether dragging this handle moves the selected node's top edge.
pub fn moves_top_edge(self) -> bool {
matches!(self, Self::Top | Self::TopLeft | Self::TopRight)
}
}
/// Radius (screen px) of the rotation ring that sits OUTSIDE the
/// 4 selection corners. Matches the TS `ROTATE_OUTER_RADIUS`.
const ROTATE_OUTER_RADIUS: f32 = 16.0;
@ -339,8 +372,8 @@ pub struct CanvasViewport<'a> {
pub theme: Theme,
/// Host ms clock — text-edit caret blink.
pub now_ms: u64,
/// Node under the cursor (excluding selected nodes) — paints the
/// dashed hover outline (TS `drawHoverOutline`).
/// Hierarchy focus under the cursor. An unselected focus paints a
/// solid outline; its direct visible children paint dashed hints.
pub(super) hovered: Option<String>,
/// Top-level frame labels: (scene id, display name, label colour).
/// Collected from the canonical tree at build time (the scene
@ -412,7 +445,6 @@ impl<'a> CanvasViewport<'a> {
// The outline is a Select-tool affordance; a stale id
// from a previous tool must not paint.
.filter(|_| matches!(state.tool, op_editor_core::Tool::Select))
.filter(|id| !state.selection.set.iter().any(|s| s == *id))
.map(|id| id.as_str().to_string()),
frame_labels: collect_frame_labels(state),
}
@ -625,6 +657,31 @@ impl<'a> Widget for CanvasViewport<'a> {
} else {
None
};
let hovered_focus_selected = hovered_lookup
.is_some_and(|hovered| self.selected_set.iter().any(|selected| selected == hovered));
// Root labels carry their durable generating/selection colour
// from construction. Apply the transient hover tint here so
// `node_drag_active` suppresses it together with every other
// hierarchy-hover affordance.
let hovered_frame_labels = hovered_lookup.map(|hovered| {
self.frame_labels
.iter()
.map(|(id, label, color)| {
(
id.clone(),
label.clone(),
if id == hovered {
self.theme.primary
} else {
*color
},
)
})
.collect::<Vec<_>>()
});
let frame_labels = hovered_frame_labels
.as_deref()
.unwrap_or(&self.frame_labels);
let selected_root_frame_label = selection_chrome_visible
&& show_handles
&& self.scene.active_page().is_some_and(|page| {
@ -750,21 +807,34 @@ impl<'a> Widget for CanvasViewport<'a> {
self.pencil_cursor_style,
);
}
if let Some(screen) = paint_hits.hover_rect {
const HOVER: Color = Color {
r: 0.231,
g: 0.51,
b: 0.965,
a: 1.0,
};
// Replay the hovered node's root→node flip/rotation
// chain so the dashed outline lands on the rendered
// geometry, not the unrotated doc-space bounds.
let hover_transformed = super::canvas_overlay_transform::replay_on_backend(
cx,
&paint_hits.hover_transforms,
);
paint_dashed_rect(cx, screen, HOVER, 1.5);
const HOVER: Color = Color {
r: 0.231,
g: 0.51,
b: 0.965,
a: 1.0,
};
if !hovered_focus_selected {
if let Some(screen) = paint_hits.hover_rect {
// Replay the focus node's root→node flip/rotation
// chain so its solid outline lands on the rendered
// geometry, not the unrotated doc-space bounds.
let hover_transformed = super::canvas_overlay_transform::replay_on_backend(
cx,
&paint_hits.hover_transforms,
);
cx.backend.stroke_rect(screen, HOVER, 1.5);
if hover_transformed {
cx.backend.restore();
}
}
}
for (screen, transforms) in &paint_hits.hover_child_rects {
// A direct child can add its own flip/rotation after
// the focus node's ancestor chain, so replay each hint
// independently instead of sharing the focus transform.
let hover_transformed =
super::canvas_overlay_transform::replay_on_backend(cx, transforms);
paint_dashed_rect(cx, *screen, HOVER, 1.5);
if hover_transformed {
cx.backend.restore();
}
@ -772,7 +842,7 @@ impl<'a> Widget for CanvasViewport<'a> {
super::canvas_frame_labels::paint_frame_labels(
cx,
&page.children,
&self.frame_labels,
frame_labels,
if selected_root_frame_label {
&[]
} else {

View file

@ -290,6 +290,10 @@ pub struct PaintNodeHits<'a> {
/// Root→node transform chain active where the hovered node paints;
/// empty when `hover_rect` is `None` or the chain is identity.
pub(crate) hover_transforms: Vec<OverlayTransform>,
/// Direct visible children of the hovered focus node. Each child
/// keeps the transform chain active at its own paint site so the
/// dashed hierarchy hint follows rotated/flipped descendants.
pub(crate) hover_child_rects: Vec<(Rect, Vec<OverlayTransform>)>,
pub(crate) selected_node: Option<&'a SceneNode>,
pub(crate) selected_transforms: Vec<OverlayTransform>,
pub(crate) pen_node: Option<&'a SceneNode>,
@ -300,8 +304,13 @@ impl<'a> PaintNodeHits<'a> {
node: &'a SceneNode,
options: &PaintNodeOptions<'_, '_>,
transforms: &[OverlayTransform],
parent_hovered: bool,
) -> Self {
let hover_rect = hovered_outline_rect(node, options);
let is_hovered = options.hovered == Some(node.id.as_str());
let outline_rect = (is_hovered || parent_hovered)
.then(|| node_outline_rect(node, options))
.flatten();
let hover_rect = is_hovered.then_some(outline_rect).flatten();
let selected_node = (options.selected == Some(node.id.as_str())).then_some(node);
Self {
hover_transforms: if hover_rect.is_some() {
@ -310,6 +319,13 @@ impl<'a> PaintNodeHits<'a> {
Vec::new()
},
hover_rect,
hover_child_rects: if parent_hovered {
outline_rect
.map(|rect| vec![(rect, transforms.to_vec())])
.unwrap_or_default()
} else {
Vec::new()
},
selected_transforms: if selected_node.is_some() {
transforms.to_vec()
} else {
@ -325,6 +341,7 @@ impl<'a> PaintNodeHits<'a> {
self.hover_rect = child.hover_rect;
self.hover_transforms = child.hover_transforms;
}
self.hover_child_rects.extend(child.hover_child_rects);
if self.selected_node.is_none() {
self.selected_node = child.selected_node;
self.selected_transforms = child.selected_transforms;
@ -435,7 +452,7 @@ pub(crate) fn paint_node_with_options_hiding<'a>(
generation_accent,
queued_shell_ids,
};
paint_node_inner(cx, node, &options, &mut Vec::new())
paint_node_inner(cx, node, &options, &mut Vec::new(), false)
}
/// Paint a resolved scene page's node tree with the editor viewport
@ -482,6 +499,7 @@ fn paint_node_inner<'a>(
node: &'a SceneNode,
options: &PaintNodeOptions<'_, '_>,
transforms: &mut Vec<OverlayTransform>,
parent_hovered: bool,
) -> PaintNodeHits<'a> {
if options.hidden == Some(node.id.as_str()) {
return PaintNodeHits::default();
@ -586,7 +604,8 @@ fn paint_node_inner<'a>(
});
}
}
let mut hits = PaintNodeHits::for_node(node, options, transforms);
let is_hovered = options.hovered == Some(node.id.as_str());
let mut hits = PaintNodeHits::for_node(node, options, transforms, parent_hovered);
// Gaussian layer blur (Figma "Layer blur"): capture the node's
// whole rendered output — shadows, fill, stroke, children — into
@ -659,7 +678,7 @@ fn paint_node_inner<'a>(
}
let clipped = push_clip_content(cx, node, world_rect, zoom);
for child in node.children.iter().rev() {
let child_hover = paint_node_inner(cx, child, options, transforms);
let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered);
hits.merge_missing(child_hover);
}
if clipped {
@ -679,7 +698,7 @@ fn paint_node_inner<'a>(
// every recursing container branch, not just Frame.
let clipped = push_clip_content(cx, node, world_rect, zoom);
for child in node.children.iter().rev() {
let child_hover = paint_node_inner(cx, child, options, transforms);
let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered);
hits.merge_missing(child_hover);
}
if clipped {
@ -713,7 +732,7 @@ fn paint_node_inner<'a>(
// all rendered as blank cards).
let clipped = push_clip_content(cx, node, world_rect, zoom);
for child in node.children.iter().rev() {
let child_hover = paint_node_inner(cx, child, options, transforms);
let child_hover = paint_node_inner(cx, child, options, transforms, is_hovered);
hits.merge_missing(child_hover);
}
if clipped {
@ -848,10 +867,7 @@ fn paint_node_inner<'a>(
hits
}
fn hovered_outline_rect(node: &SceneNode, options: &PaintNodeOptions<'_, '_>) -> Option<Rect> {
if options.hovered != Some(node.id.as_str()) {
return None;
}
fn node_outline_rect(node: &SceneNode, options: &PaintNodeOptions<'_, '_>) -> Option<Rect> {
let bounds = node.aggregate_bounds();
if bounds.size.x <= 0.0 || bounds.size.y <= 0.0 {
return None;

View file

@ -54,8 +54,14 @@ struct RecordingBackend {
shader_fills: usize,
/// One `(radians, pivot)` per [`Op::Rotate`], in op order.
rotations: Vec<(f32, Point2D)>,
/// One `(scale, pivot)` per [`Op::Scale`], in op order.
scales: Vec<(Point2D, Point2D)>,
/// One color per [`Op::Stroke`], in op order.
stroke_colors: Vec<Color>,
/// Whether each stroke was emitted through `stroke_line`.
stroke_is_line: Vec<bool>,
stroke_rects: Vec<(Rect, Color)>,
stroke_lines: Vec<(Point2D, Point2D, Color)>,
}
impl crate::RenderBackend for RecordingBackend {
@ -66,9 +72,11 @@ impl crate::RenderBackend for RecordingBackend {
self.rects += 1;
self.ops.push(Op::Fill);
}
fn stroke_rect(&mut self, _: Rect, color: Color, _: f32) {
fn stroke_rect(&mut self, rect: Rect, color: Color, _: f32) {
self.strokes += 1;
self.stroke_colors.push(color);
self.stroke_is_line.push(false);
self.stroke_rects.push((rect, color));
self.ops.push(Op::Stroke);
}
fn draw_text(&mut self, layout: &TextLayout, point: Point2D) {
@ -91,16 +99,19 @@ impl crate::RenderBackend for RecordingBackend {
self.ops.push(Op::Restore);
}
fn translate(&mut self, _: Point2D) {}
fn scale(&mut self, _: Point2D, _: Point2D) {
fn scale(&mut self, scale: Point2D, pivot: Point2D) {
self.scales.push((scale, pivot));
self.ops.push(Op::Scale);
}
fn rotate(&mut self, radians: f32, pivot: Point2D) {
self.rotations.push((radians, pivot));
self.ops.push(Op::Rotate);
}
fn stroke_line(&mut self, _: Point2D, _: Point2D, color: Color, _: f32) {
fn stroke_line(&mut self, from: Point2D, to: Point2D, color: Color, _: f32) {
self.strokes += 1;
self.stroke_colors.push(color);
self.stroke_is_line.push(true);
self.stroke_lines.push((from, to, color));
self.ops.push(Op::Stroke);
}
fn fill_round_rect(&mut self, rect: Rect, _: f32, _: Color) {
@ -115,6 +126,7 @@ impl crate::RenderBackend for RecordingBackend {
fn stroke_round_rect(&mut self, _: Rect, _: f32, color: Color, _: f32) {
self.strokes += 1;
self.stroke_colors.push(color);
self.stroke_is_line.push(false);
self.ops.push(Op::Stroke);
}
fn fill_oval(&mut self, _: Rect, _: Color) {
@ -123,11 +135,13 @@ impl crate::RenderBackend for RecordingBackend {
fn stroke_oval(&mut self, _: Rect, color: Color, _: f32) {
self.strokes += 1;
self.stroke_colors.push(color);
self.stroke_is_line.push(false);
self.ops.push(Op::StrokeOval);
}
fn stroke_svg_path(&mut self, _: &str, _: Point2D, _: f32, color: Color, _: f32) {
self.strokes += 1;
self.stroke_colors.push(color);
self.stroke_is_line.push(false);
self.ops.push(Op::Stroke);
}
fn fill_round_rect_mesh_gradient(
@ -260,6 +274,47 @@ fn sample_scene() -> LayoutScene {
}
}
/// A focus frame with two visible direct children, one hidden direct
/// child, and one grandchild. Hover hierarchy tests use the distinct
/// edge coordinates to prove that only immediate visible children
/// receive dashed hints.
fn hover_hierarchy_scene() -> LayoutScene {
let direct_a = leaf(
"direct-a",
NodeKind::Rect,
Rect::xywh(30.0, 40.0, 48.0, 32.0),
None,
);
let grandchild = leaf(
"grandchild",
NodeKind::Rect,
Rect::xywh(122.0, 78.0, 24.0, 20.0),
None,
);
let mut direct_b = SceneNode::leaf("direct-b", NodeKind::Frame);
direct_b.bounds = Rect::xywh(100.0, 40.0, 80.0, 100.0);
direct_b.children = vec![grandchild];
let mut hidden = leaf(
"hidden-direct",
NodeKind::Rect,
Rect::xywh(188.0, 40.0, 24.0, 32.0),
None,
);
hidden.hidden = true;
let mut focus = SceneNode::leaf("focus", NodeKind::Frame);
focus.bounds = Rect::xywh(10.0, 10.0, 220.0, 180.0);
focus.children = vec![direct_a, direct_b, hidden];
LayoutScene {
pages: vec![ScenePage {
id: "p".into(),
name: "Page".into(),
children: vec![focus],
}],
active_page_index: 0,
}
}
fn sample_state() -> EditorState {
EditorState::sample()
}
@ -350,15 +405,16 @@ fn active_node_drag_hides_selection_chrome_and_dimension_label() {
}
#[test]
fn active_node_drag_suppresses_stale_child_hover_outline() {
fn active_node_drag_suppresses_focus_and_child_hover_outlines() {
let _guard = crate::agent_indicator_test_support::lock();
op_editor_core::agent_indicators::clear();
let mut state = EditorState::new();
state.doc.children = vec![named_frame_node("n1", "Frame")];
state.set_single_selection(op_editor_core::NodeId::new("n1"));
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n4"));
state.set_single_selection(op_editor_core::NodeId::new("n2"));
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n1"));
let scene = sample_scene();
let mut viewport = CanvasViewport::from_editor(&state, &scene);
viewport.node_drag_active = true;
viewport.frame_labels.clear();
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
@ -367,9 +423,23 @@ fn active_node_drag_suppresses_stale_child_hover_outline() {
viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0));
}
assert_eq!(
backend.strokes, 1,
"dragging should suppress stale child hover outlines, leaving only the frame stroke"
assert!(
backend
.stroke_colors
.iter()
.all(|color| *color != HOVER_OUTLINE_COLOR),
"dragging should suppress both the focus outline and all direct-child hierarchy hints"
);
let frame_label_color = backend
.texts
.iter()
.zip(backend.text_colors.iter())
.find_map(|(text, color)| (text == "Frame").then_some(*color))
.expect("root frame label should paint");
assert_ne!(
frame_label_color,
viewport.theme.primary.to_jian(),
"dragging should suppress the transient root-title hover tint too"
);
}
@ -958,6 +1028,33 @@ fn selected_root_frame_label_uses_primary_active_color() {
assert_eq!(color, viewport.theme.primary);
}
#[test]
fn hovered_root_frame_label_uses_primary_active_color() {
let _guard = crate::agent_indicator_test_support::lock();
op_editor_core::agent_indicators::clear();
let scene = sample_scene();
let mut state = EditorState::new();
state.doc.children = vec![named_frame_node("n1", "Frame")];
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("n1"));
let viewport = CanvasViewport::from_editor(&state, &scene);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 800.0, 600.0));
}
let color = backend
.texts
.iter()
.zip(backend.text_colors.iter())
.find_map(|(text, color)| (text == "Frame").then_some(*color))
.expect("root frame label should paint");
assert_eq!(color, viewport.theme.primary.to_jian());
}
#[test]
fn frame_label_paint_matches_roots_linearly() {
let _guard = crate::agent_indicator_test_support::lock();
@ -1108,6 +1205,130 @@ const SELECTION_BLUE: Color = Color {
a: 1.0,
};
fn line_lies_on_rect_edge(from: Point2D, to: Point2D, rect: Rect) -> bool {
const EPSILON: f32 = 0.01;
let left = rect.origin.x;
let right = rect.origin.x + rect.size.x;
let top = rect.origin.y;
let bottom = rect.origin.y + rect.size.y;
let in_x = |x: f32| x >= left - EPSILON && x <= right + EPSILON;
let in_y = |y: f32| y >= top - EPSILON && y <= bottom + EPSILON;
let same = |a: f32, b: f32| (a - b).abs() <= EPSILON;
((same(from.y, top) && same(to.y, top)) || (same(from.y, bottom) && same(to.y, bottom)))
&& in_x(from.x)
&& in_x(to.x)
|| ((same(from.x, left) && same(to.x, left)) || (same(from.x, right) && same(to.x, right)))
&& in_y(from.y)
&& in_y(to.y)
}
#[test]
fn hover_focus_is_solid_and_only_direct_visible_children_are_dashed() {
let _guard = crate::agent_indicator_test_support::lock();
op_editor_core::agent_indicators::clear();
let scene = hover_hierarchy_scene();
let mut state = EditorState::new();
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus"));
let viewport = CanvasViewport::from_editor(&state, &scene);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0));
}
let focus_solids: Vec<Rect> = backend
.stroke_rects
.iter()
.filter_map(|(rect, color)| (*color == HOVER_OUTLINE_COLOR).then_some(*rect))
.collect();
assert_eq!(
focus_solids,
vec![Rect::xywh(10.0, 10.0, 220.0, 180.0)],
"an unselected hierarchy focus should paint one solid outline"
);
let direct_a = Rect::xywh(30.0, 40.0, 48.0, 32.0);
let direct_b = Rect::xywh(100.0, 40.0, 80.0, 100.0);
let child_lines: Vec<(Point2D, Point2D)> = backend
.stroke_lines
.iter()
.filter_map(|(from, to, color)| (*color == HOVER_OUTLINE_COLOR).then_some((*from, *to)))
.collect();
assert!(
!child_lines.is_empty(),
"direct children should paint dashed hints"
);
assert!(child_lines
.iter()
.any(|(from, to)| line_lies_on_rect_edge(*from, *to, direct_a)));
assert!(child_lines
.iter()
.any(|(from, to)| line_lies_on_rect_edge(*from, *to, direct_b)));
assert!(
child_lines.iter().all(|(from, to)| {
line_lies_on_rect_edge(*from, *to, direct_a)
|| line_lies_on_rect_edge(*from, *to, direct_b)
}),
"hidden direct children and visible grandchildren must not receive hierarchy hints"
);
}
#[test]
fn selected_hover_focus_keeps_child_hints_selection_handles_and_dimensions() {
let _guard = crate::agent_indicator_test_support::lock();
op_editor_core::agent_indicators::clear();
let scene = hover_hierarchy_scene();
let mut state = EditorState::new();
state.doc.children = vec![named_frame_node("focus", "Focus")];
state.set_single_selection(op_editor_core::NodeId::new("focus"));
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus"));
let mut viewport = CanvasViewport::from_editor(&state, &scene);
viewport.selection_label = Some("220 × 180".into());
assert_eq!(
viewport.hovered.as_deref(),
Some("focus"),
"selected nodes must remain eligible as hierarchy hover focus"
);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0));
}
assert!(
backend
.stroke_rects
.iter()
.all(|(_, color)| *color != HOVER_OUTLINE_COLOR),
"a selected focus should not duplicate its solid outline"
);
assert!(
backend
.stroke_lines
.iter()
.any(|(_, _, color)| *color == HOVER_OUTLINE_COLOR),
"a selected focus should still expose dashed direct-child hints"
);
assert!(
backend
.stroke_colors
.iter()
.filter(|color| **color == viewport.theme.primary)
.count()
>= 8,
"single selection handles should still paint above hierarchy hover"
);
assert!(
backend.texts.iter().any(|text| text == "220 × 180"),
"the selected dimensions capsule should remain visible"
);
}
/// Replay the recorded op stream up to the first stroke painted in
/// `color`, tracking the save/restore transform stack, and return the
/// `(radians, pivot)` rotations active at that stroke. `None` when no
@ -1184,6 +1405,59 @@ fn active_rotations_at_first_oval_stroke(
None
}
#[derive(Clone, Default)]
struct ActiveTransforms {
rotations: Vec<(f32, Point2D)>,
scales: Vec<(Point2D, Point2D)>,
}
fn active_transforms_at_first_line_stroke(
backend: &RecordingBackend,
color: Color,
) -> Option<ActiveTransforms> {
let mut stroke_i = 0usize;
let mut rot_i = 0usize;
let mut scale_i = 0usize;
let mut stack = vec![ActiveTransforms::default()];
for op in &backend.ops {
match op {
Op::Save => {
let top = stack.last().cloned().unwrap_or_default();
stack.push(top);
}
Op::Restore => {
stack.pop();
}
Op::Scale => {
stack
.last_mut()
.expect("unbalanced save/restore")
.scales
.push(backend.scales[scale_i]);
scale_i += 1;
}
Op::Rotate => {
stack
.last_mut()
.expect("unbalanced save/restore")
.rotations
.push(backend.rotations[rot_i]);
rot_i += 1;
}
Op::Stroke | Op::StrokeOval => {
let is_match =
backend.stroke_is_line[stroke_i] && backend.stroke_colors[stroke_i] == color;
if is_match {
return stack.last().cloned();
}
stroke_i += 1;
}
_ => {}
}
}
None
}
#[test]
fn hover_outline_rotates_with_rotated_frame() {
let _guard = crate::agent_indicator_test_support::lock();
@ -1267,6 +1541,47 @@ fn hover_outline_on_child_applies_ancestor_rotation() {
);
}
#[test]
fn direct_child_hover_hint_replays_parent_and_child_rotation_flip_chain() {
let _guard = crate::agent_indicator_test_support::lock();
op_editor_core::agent_indicators::clear();
let mut scene = hover_hierarchy_scene();
let focus = &mut scene.pages[0].children[0];
let rotation = 0.4_f32;
focus.rotation = rotation;
focus.flip_y = true;
// Children paint in reverse order; the hidden child is skipped, so
// direct-b supplies the first dashed hierarchy stroke.
focus.children[1].flip_x = true;
let mut state = EditorState::new();
state.editor_ui.canvas_hover_node = Some(op_editor_core::NodeId::new("focus"));
let viewport = CanvasViewport::from_editor(&state, &scene);
let mut backend = RecordingBackend::default();
{
let mut cx = PaintCx {
backend: &mut backend,
};
viewport.paint(&mut cx, Rect::xywh(0.0, 0.0, 300.0, 240.0));
}
let transforms = active_transforms_at_first_line_stroke(&backend, HOVER_OUTLINE_COLOR)
.expect("a direct child should paint dashed hierarchy strokes");
assert_eq!(
transforms.rotations.len(),
1,
"the child hint should inherit the focus rotation once"
);
assert!((transforms.rotations[0].0 - rotation).abs() < 1e-4);
assert_eq!(
transforms.scales.len(),
2,
"the child hint should replay both the focus and child flips"
);
assert_eq!(transforms.scales[0].0, Point2D::new(1.0, -1.0));
assert_eq!(transforms.scales[1].0, Point2D::new(-1.0, 1.0));
}
#[test]
fn selection_overlay_on_child_applies_ancestor_rotation() {
let _guard = crate::agent_indicator_test_support::lock();

View file

@ -271,6 +271,41 @@ impl PropertyPanel {
Self::for_selection_at(state, 0)
}
/// Build the panel and replace a single selection's displayed W/H with
/// its layout-resolved canvas size. This matters for Fill/Hug nodes: the
/// canonical node stores a sizing keyword, while the inspector must show
/// the concrete size the user is about to freeze by typing a number.
pub fn for_selection_with_scene(
state: &EditorState,
scene: &crate::layout_scene::LayoutScene,
) -> Option<Self> {
Self::for_selection_at_with_scene(state, scene, 0)
}
/// Clocked variant of [`Self::for_selection_with_scene`].
pub fn for_selection_at_with_scene(
state: &EditorState,
scene: &crate::layout_scene::LayoutScene,
now_ms: u64,
) -> Option<Self> {
let mut panel = Self::for_selection_at(state, now_ms)?;
if state.selection_count() == 1 {
if let Some(node) = scene
.active_page()
.and_then(|page| page.find(state.selection.anchor.as_str()))
{
let bounds = node.aggregate_bounds();
if bounds.size.x.is_finite() && bounds.size.x >= 0.0 {
panel.snapshot.width = bounds.size.x.round() as i32;
}
if bounds.size.y.is_finite() && bounds.size.y >= 0.0 {
panel.snapshot.height = bounds.size.y.round() as i32;
}
}
}
Some(panel)
}
/// Same as [`for_selection`] but threads the host's monotonic
/// millisecond clock through so the focused-input caret can
/// blink off the same animation timer as the chat input.

View file

@ -117,38 +117,21 @@ pub fn editable_input_rects(
}
if visible.size_options {
y += SECTION_HEADER_HEIGHT;
// Mirror paint_size_section: omit the W/H hit-rect when its
// dimension is fill/hug, and reflow H into the left slot when W
// is hidden — but keep the row's vertical advance fixed so later
// sections don't shift. (TS size-section.tsx: input rendered
// only when the dimension is a concrete number.)
let w_left = Point2D::new(x0 + PAD_X, y);
let h_right = Point2D::new(x0 + PAD_X + half_w + 8.0, y);
let w_visible = !visible.size_fill_width && !visible.size_hug_width;
let h_visible = !visible.size_fill_height && !visible.size_hug_height;
if w_visible {
rects.push((
PropertyFocus::SizeW,
Rect {
origin: w_left,
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
}
if h_visible {
rects.push((
PropertyFocus::SizeH,
Rect {
origin: if w_visible { h_right } else { w_left },
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
}
// Collapse the input row when both dimensions are fill/hug (same
// rule as paint_size_section) so the checkboxes shift up.
if w_visible || h_visible {
y += INPUT_HEIGHT + 10.0;
}
rects.push((
PropertyFocus::SizeW,
Rect {
origin: Point2D::new(x0 + PAD_X, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
rects.push((
PropertyFocus::SizeH,
Rect {
origin: Point2D::new(x0 + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
},
));
y += INPUT_HEIGHT + 10.0;
let check_h = 22.0;
y += check_h * if visible.clip_content { 3.0 } else { 2.0 };
y += 12.0;

View file

@ -345,14 +345,9 @@ pub fn action_button_rects_with_fill_picker(
if visible.size_options {
y += SECTION_HEADER_HEIGHT;
// The W/H input row collapses when both dimensions are fill/hug
// (matches paint + editable_input_rects), so the size checkbox
// rects below shift up by the row height.
let w_visible = !visible.size_fill_width && !visible.size_hug_width;
let h_visible = !visible.size_fill_height && !visible.size_hug_height;
if w_visible || h_visible {
y += INPUT_HEIGHT + 10.0;
}
// W/H remain editable in every sizing mode. Committing a number
// switches only that axis back to fixed sizing.
y += INPUT_HEIGHT + 10.0;
let row_h = 22.0;
out.push((
PropertyPanelAction::ToggleSizeFillWidth,

View file

@ -657,53 +657,36 @@ pub fn paint_size_section(
origin: Point2D::new(x + PAD_X + half_w + 8.0, y),
size: Point2D::new(half_w, INPUT_HEIGHT),
};
// Hide the W/H box entirely when its dimension is fill/hug —
// matching TS size-section.tsx, which renders the NumberInput only
// when the dimension is a concrete number. Visible dimensions flow
// left-to-right, so when W is hidden, H slides into the left slot
// (no dangling empty half). The fixed `y += INPUT_HEIGHT + 10.0`
// below keeps the row height (and every later section's offset)
// unchanged regardless of how many boxes paint.
let w_visible = !flags.fill_width && !flags.hug_width;
let h_visible = !flags.fill_height && !flags.hug_height;
if w_visible {
let w_value = snapshot.width.to_string();
paint_input_with_prefix_focused_state(
cx,
theme,
w_rect,
"W",
edit.value_for(PropertyFocus::SizeW, &w_value),
edit.focus == Some(PropertyFocus::SizeW),
edit.caret_at(PropertyFocus::SizeW),
edit.select_all_at(PropertyFocus::SizeW),
edit.input_at(PropertyFocus::SizeW),
edit.now_ms,
);
}
if h_visible {
let h_value = snapshot.height.to_string();
let h_target = if w_visible { h_rect } else { w_rect };
paint_input_with_prefix_focused_state(
cx,
theme,
h_target,
"H",
edit.value_for(PropertyFocus::SizeH, &h_value),
edit.focus == Some(PropertyFocus::SizeH),
edit.caret_at(PropertyFocus::SizeH),
edit.select_all_at(PropertyFocus::SizeH),
edit.input_at(PropertyFocus::SizeH),
edit.now_ms,
);
}
// Collapse the whole input row when BOTH dimensions are fill/hug —
// the section shrinks up so the checkboxes sit under the label with
// no dangling empty row. `size_input_row_h` keeps the layout
// walkers in lockstep with this advance.
if w_visible || h_visible {
y += INPUT_HEIGHT + 10.0;
}
// Sizing mode and the numeric editor are complementary. Fill / Hug
// stays selected below while this row exposes the resolved snapshot
// size; committing a number replaces that axis with fixed sizing.
let w_value = snapshot.width.to_string();
paint_input_with_prefix_focused_state(
cx,
theme,
w_rect,
"W",
edit.value_for(PropertyFocus::SizeW, &w_value),
edit.focus == Some(PropertyFocus::SizeW),
edit.caret_at(PropertyFocus::SizeW),
edit.select_all_at(PropertyFocus::SizeW),
edit.input_at(PropertyFocus::SizeW),
edit.now_ms,
);
let h_value = snapshot.height.to_string();
paint_input_with_prefix_focused_state(
cx,
theme,
h_rect,
"H",
edit.value_for(PropertyFocus::SizeH, &h_value),
edit.focus == Some(PropertyFocus::SizeH),
edit.caret_at(PropertyFocus::SizeH),
edit.select_all_at(PropertyFocus::SizeH),
edit.input_at(PropertyFocus::SizeH),
edit.now_ms,
);
y += INPUT_HEIGHT + 10.0;
let row_h = 22.0;
paint_check_row(
cx,

View file

@ -6,7 +6,8 @@
use super::property_panel::{PropertyPanel, PropertyPanelAction};
use super::property_panel_sections as sections;
use super::property_panel_test_support::{state_from, visible_for};
use super::property_panel_test_support::{state_from, visible_for, CountingBackend};
use crate::layout_scene::{LayoutScene, NodeKind, SceneNode, ScenePage};
use crate::widgets::{PaintCx, Widget};
use crate::{Color, Point2D, Rect, TextLayout};
use jian_ops_schema::variable::{VariableKind, VariableScalar};
@ -59,6 +60,62 @@ fn for_selection_with_real_node_builds_snapshot() {
assert_eq!(panel.snapshot.height, 28);
}
#[test]
fn scene_aware_panel_reports_resolved_fill_and_hug_dimensions() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"ff","name":"Frame",
"width":"fill_container","height":"fit_content","children":[]}
]}"##,
);
state.set_single_selection(NodeId::new("ff"));
let mut resolved = SceneNode::leaf("ff", NodeKind::Frame);
resolved.bounds = Rect::xywh(0.0, 0.0, 390.0, 710.0);
let scene = LayoutScene {
pages: vec![ScenePage {
id: "p1".into(),
name: "Page 1".into(),
children: vec![resolved],
}],
active_page_index: 0,
};
let panel = PropertyPanel::for_selection_with_scene(&state, &scene)
.expect("scene-aware fill/hug panel");
assert_eq!((panel.snapshot.width, panel.snapshot.height), (390, 710));
assert!(panel.snapshot.size_fill_width);
assert!(panel.snapshot.size_hug_height);
}
#[test]
fn scene_aware_panel_keeps_unbounded_group_aggregate_dimensions() {
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"group","id":"g","name":"Group","children":[
{"type":"rectangle","id":"child","x":10,"y":20,
"width":70,"height":30}
]}
]}"##,
);
state.set_single_selection(NodeId::new("g"));
let mut child = SceneNode::leaf("child", NodeKind::Rect);
child.bounds = Rect::xywh(10.0, 20.0, 70.0, 30.0);
let mut group = SceneNode::leaf("g", NodeKind::Group);
group.children = vec![child];
let scene = LayoutScene {
pages: vec![ScenePage {
id: "p1".into(),
name: "Page 1".into(),
children: vec![group],
}],
active_page_index: 0,
};
let panel =
PropertyPanel::for_selection_with_scene(&state, &scene).expect("scene-aware group panel");
assert_eq!((panel.snapshot.width, panel.snapshot.height), (70, 30));
}
#[test]
fn for_selection_without_selection_returns_none() {
let state = EditorState::new();
@ -450,7 +507,7 @@ fn color_variable_picker_emits_bind_and_unbind_rows() {
}
#[test]
fn fill_width_hides_the_w_input_but_keeps_h_and_row_height() {
fn fill_width_keeps_both_numeric_inputs_visible_and_hittable() {
use op_editor_core::PropertyFocus;
let fill = {
let mut s = state_from(
@ -464,68 +521,27 @@ fn fill_width_hides_the_w_input_but_keeps_h_and_row_height() {
PropertyPanel::for_selection(&s).expect("fill-width frame panel")
};
assert!(fill.snapshot.size_fill_width, "width sizing should be fill");
assert!(
!fill.snapshot.size_fill_height,
"height stays a concrete number"
);
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
size: Point2D::new(280.0, 1200.0),
};
let fill_rects = sections::editable_input_rects(rect, visible_for(&fill), &fill.snapshot.fills);
// W is omitted (fill); H remains (numeric).
assert!(
!fill_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW),
"SizeW must be hidden when width is fill"
);
let fill_h = fill_rects
.iter()
.find(|(f, _)| *f == PropertyFocus::SizeH)
.map(|(_, r)| *r)
.expect("SizeH must remain");
// A fixed-width frame keeps both — and SizeH sits at the SAME y, so
// hiding W never collapses the row / shifts later sections.
let fixed = {
let mut s = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"ff","name":"Frame",
"x":40,"y":40,"width":360,"height":240,
"layout":"vertical","children":[]}
]}"##,
for focus in [PropertyFocus::SizeW, PropertyFocus::SizeH] {
let target = fill_rects
.iter()
.find(|(candidate, _)| *candidate == focus)
.map(|(_, rect)| *rect)
.expect("fill sizing must keep the numeric input");
let center = Point2D::new(
target.origin.x + target.size.x / 2.0,
target.origin.y + target.size.y / 2.0,
);
s.set_single_selection(NodeId::new("ff"));
PropertyPanel::for_selection(&s).expect("fixed-width frame panel")
};
let fixed_rects =
sections::editable_input_rects(rect, visible_for(&fixed), &fixed.snapshot.fills);
assert!(
fixed_rects.iter().any(|(f, _)| *f == PropertyFocus::SizeW),
"fixed width keeps SizeW"
);
let fixed_w = fixed_rects
.iter()
.find(|(f, _)| *f == PropertyFocus::SizeW)
.map(|(_, r)| *r)
.expect("SizeW present for fixed width");
let fixed_h = fixed_rects
.iter()
.find(|(f, _)| *f == PropertyFocus::SizeH)
.map(|(_, r)| *r)
.expect("SizeH present for fixed width");
assert!(
(fill_h.origin.y - fixed_h.origin.y).abs() < 0.01,
"hiding W must not move H's row (row height preserved)"
);
// With W hidden, H reflows into the (now-empty) LEFT slot.
assert!(
(fill_h.origin.x - fixed_w.origin.x).abs() < 0.01,
"H must slide into the left slot when W is hidden"
);
assert_eq!(fill.hit_test(rect, center), Some(focus));
}
}
#[test]
fn both_dimensions_fill_collapses_the_size_input_row() {
fn both_dimensions_fill_keep_the_size_input_row() {
use op_editor_core::PropertyFocus;
let rect = Rect {
origin: Point2D::new(0.0, 0.0),
@ -543,9 +559,6 @@ fn both_dimensions_fill_collapses_the_size_input_row() {
s.set_single_selection(NodeId::new("ff"));
PropertyPanel::for_selection(&s).expect("frame panel")
};
// The size checkboxes sit BELOW the W/H input row. When both
// dimensions are fill, the whole input row collapses, so the first
// checkbox (填充宽度) shifts up by exactly one input row.
let chk_y = |p: &PropertyPanel| {
sections::action_button_rects_with_fill_picker(
rect,
@ -565,25 +578,59 @@ fn both_dimensions_fill_collapses_the_size_input_row() {
.map(|(_, r)| r.origin.y)
.expect("fill-width checkbox rect")
};
// One dimension numeric (row present) vs both fill (row collapsed).
let one = panel_for("\"fill_container\"", "240");
let both = panel_for("\"fill_container\"", "\"fill_container\"");
assert!(one.snapshot.size_fill_width && both.snapshot.size_fill_height);
// INPUT_HEIGHT (30) + 10 gap = 40 px of collapse.
let delta = chk_y(&one) - chk_y(&both);
assert!(
(delta - 40.0).abs() < 0.01,
"both-hidden must collapse the input row (~40px up), got {delta}"
(chk_y(&one) - chk_y(&both)).abs() < 0.01,
"checkbox rows must not jump when both axes use fill"
);
// Neither W nor H emits a focus rect when both are hidden.
let both_inputs =
sections::editable_input_rects(rect, visible_for(&both), &both.snapshot.fills);
assert!(
!both_inputs
.iter()
.any(|(f, _)| matches!(f, PropertyFocus::SizeW | PropertyFocus::SizeH)),
"no W/H hit-rect when both dimensions are fill"
for focus in [PropertyFocus::SizeW, PropertyFocus::SizeH] {
assert!(
both_inputs.iter().any(|(candidate, _)| *candidate == focus),
"both fill axes must still emit the numeric hit rect"
);
}
}
#[test]
fn fill_and_hug_inputs_paint_snapshot_size_and_numeric_commit_makes_fixed() {
use op_editor_core::PropertyFocus;
let mut state = state_from(
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"ff","name":"Frame",
"x":40,"y":40,"width":"fill_container","height":"fit_content",
"layout":"vertical","children":[
{"type":"rectangle","id":"child","x":0,"y":0,
"width":180,"height":90}
]}
]}"##,
);
state.set_single_selection(NodeId::new("ff"));
let panel = PropertyPanel::for_selection(&state).expect("fill/hug frame panel");
assert_eq!((panel.snapshot.width, panel.snapshot.height), (180, 90));
assert!(panel.snapshot.size_fill_width && panel.snapshot.size_hug_height);
let mut backend = CountingBackend::default();
let mut cx = PaintCx {
backend: &mut backend,
};
panel.paint(
&mut cx,
Rect {
origin: Point2D::ZERO,
size: Point2D::new(280.0, 1200.0),
},
);
assert!(backend.texts.iter().any(|text| text == "180"));
assert!(backend.texts.iter().any(|text| text == "90"));
assert!(state.commit_property_edit(PropertyFocus::SizeW, 220.0));
assert!(state.commit_property_edit(PropertyFocus::SizeH, 130.0));
let fixed = PropertyPanel::for_selection(&state).expect("fixed frame panel");
assert_eq!((fixed.snapshot.width, fixed.snapshot.height), (220, 130));
assert!(!fixed.snapshot.size_fill_width && !fixed.snapshot.size_hug_height);
}
#[test]

View file

@ -201,9 +201,8 @@ pub struct VisibleSections {
pub layout_justify: LayoutJustifyValue,
pub layout_align: LayoutAlignValue,
pub size_options: bool,
/// Per-dimension sizing masks — when set, the matching W/H input is
/// hidden (fill/hug replaces the numeric field, TS size-section
/// parity). Read by `editable_input_rects` to gate the hit-rect.
/// Per-dimension sizing modes. Numeric W/H editors remain visible
/// and show the resolved snapshot size in every mode.
pub size_fill_width: bool,
pub size_fill_height: bool,
pub size_hug_width: bool,

View file

@ -424,6 +424,11 @@ pub(in crate::widget_host) struct HandleDragState {
pub(in crate::widget_host) start_screen_x: f32,
pub(in crate::widget_host) start_screen_y: f32,
pub(in crate::widget_host) start_bounds: Rect,
/// Authored parent-relative origin at press time. Kept separate from the
/// resolved absolute scene bounds so left/top drags of nested free nodes
/// do not jump into document coordinates.
pub(in crate::widget_host) start_authored_x: Option<f64>,
pub(in crate::widget_host) start_authored_y: Option<f64>,
}
/// Active rotation drag — `center_screen` is the screen-space

View file

@ -1,4 +1,4 @@
//! Canvas selection semantics (enter-group on double-click) + the
//! Canvas selection semantics (one-level drill on double-click) + the
//! node-drag release commit (auto-layout reorder / cross-container
//! reparenting).
//!
@ -34,51 +34,64 @@ struct ContainerDropCandidate {
}
impl WidgetHostNative {
/// Select-tool press on a canvas node (the deepest hit). Routes
/// double-clicks to enter-group / text-edit, then applies the TS
/// click-resolution rules (`skia-interaction.ts:368-405`): promote
/// a nested hit to its outermost frame/group ancestor (unit
/// selection) unless the user entered that container; a hit on a
/// child of an already-selected node keeps the selection. Always
/// consumes the press.
/// Select-tool press over a resolved root-to-deepest hit path.
/// A plain click selects the current level's primary node; a
/// double-click drills exactly one level to the child under the
/// pointer. The primary then becomes the entered sibling scope.
/// This is the same hierarchy Pencil exposes with a solid primary
/// hover outline and dashed direct-child guides.
pub(in crate::widget_host) fn apply_canvas_node_press(
&mut self,
ec_id: NodeId,
hit_path: Vec<NodeId>,
x: f32,
y: f32,
text_edit_was_active: bool,
viewport_height: f32,
) -> bool {
use op_editor_core::selection_resolve::{
resolve_canvas_selection_target, SelectionResolution,
let Some(deepest) = hit_path.last().cloned() else {
return false;
};
// Canvas double-click: 400 ms same-node → enter a selected
// container, else text-edit on Text nodes.
let Some(targets) = op_editor_core::selection_resolve::resolve_canvas_depth_targets(
&hit_path,
self.editor_state.editor_ui.entered_container.as_ref(),
) else {
return false;
};
// Canvas double-click: 400 ms over the same deepest geometry.
// Shift and an existing multi-selection deliberately disable
// drill-down so a set-edit gesture cannot unexpectedly enter a
// container.
let is_double = matches!(
&self.editor_state.editor_ui.last_canvas_click,
Some((prev, t)) if *prev == ec_id
Some((prev, t)) if *prev == deepest
&& self.now_ms.saturating_sub(*t) < 400
);
self.editor_state.editor_ui.last_canvas_click = Some((ec_id.clone(), self.now_ms));
) && !self.shift_held
&& self.editor_state.selection_count() <= 1;
self.editor_state.editor_ui.last_canvas_click = if self.shift_held || is_double {
None
} else {
Some((deepest, self.now_ms))
};
if is_double && !text_edit_was_active {
// TS dblclick order (skia-interaction.ts:1279-1296):
// entering a selected frame/group wins over the
// text-edit fallback.
if self.try_enter_selected_container_on_double_click(&ec_id, viewport_height) {
if let Some(secondary) = targets.secondary_under_pointer {
self.editor_state.set_single_selection(secondary.clone());
self.editor_state.editor_ui.entered_container = Some(targets.primary);
// Rebase the stationary-pointer hover immediately. The
// native 3 px probe cache would otherwise retain the old
// level until the mouse moved again.
self.editor_state.editor_ui.canvas_hover_node = Some(secondary);
self.last_hover_probe = None;
self.scroll_layer_panel_selection_into_view(viewport_height);
self.mark_dirty();
return true;
}
if self.editor_state.start_text_edit(ec_id.clone()) {
if self.editor_state.start_text_edit(targets.primary.clone()) {
self.editor_state.ui.text_edit_input.touch(self.now_ms);
self.mark_dirty();
return true;
}
}
let resolved = resolve_canvas_selection_target(
self.editor_state.active_children(),
&ec_id,
self.editor_state.editor_ui.entered_container.as_ref(),
&self.editor_state.selection.set,
);
let target = targets.primary;
let fresh_drag = NodeDragState {
last_screen_x: x,
last_screen_y: y,
@ -91,77 +104,45 @@ impl WidgetHostNative {
};
self.option_drag_source_ids.clear();
if self.shift_held {
// Shift+click toggles set membership of the resolved
// target; a child of a selected node keeps the set and
// just drags it.
if let SelectionResolution::Select(target) = resolved {
let was_in_set = self.editor_state.is_selected(&target);
self.editor_state.toggle_selection(target);
self.editor_state.sync_entered_container_with_selection();
if !was_in_set {
self.node_drag = Some(fresh_drag);
}
} else {
let was_in_set = self.editor_state.is_selected(&target);
self.editor_state.toggle_selection(target);
if !was_in_set {
self.node_drag = Some(fresh_drag);
}
if self
.editor_state
.editor_ui
.entered_container
.as_ref()
.is_some_and(|entered| !hit_path.contains(entered))
{
self.editor_state.editor_ui.entered_container = None;
}
self.scroll_layer_panel_selection_into_view(viewport_height);
return true;
}
// Plain click: keep a multi-set when clicking inside it (TS
// parity), else single-select the resolved target.
if let SelectionResolution::Select(target) = resolved {
let already_in_set = self.editor_state.is_selected(&target);
if !already_in_set || self.editor_state.selection_count() == 1 {
self.editor_state.set_single_selection(target);
}
// Plain click selects the solid-outline primary. Clicking a
// sibling inside an entered scope therefore moves selection at
// that level instead of dragging an arbitrary deepest leaf.
let already_in_set = self.editor_state.is_selected(&target);
if !already_in_set || self.editor_state.selection_count() == 1 {
self.editor_state.set_single_selection(target);
}
if self
.editor_state
.editor_ui
.entered_container
.as_ref()
.is_some_and(|entered| !hit_path.contains(entered))
{
self.editor_state.editor_ui.entered_container = None;
}
self.editor_state.sync_entered_container_with_selection();
self.scroll_layer_panel_selection_into_view(viewport_height);
self.editor_state.commit_history();
self.node_drag = Some(fresh_drag);
true
}
/// TS double-click enter-group (`skia-interaction.ts:1279-1294`):
/// when exactly one frame/group with children is selected, a
/// double-click selects the deepest hit under the cursor; when
/// that hit is inside the selected container, the container
/// becomes the entered context (promotion then stops at its
/// children). Returns `true` when the double-click was consumed.
pub(in crate::widget_host) fn try_enter_selected_container_on_double_click(
&mut self,
deepest: &NodeId,
viewport_height: f32,
) -> bool {
if self.editor_state.selection_count() != 1 {
return false;
}
let anchor = self.editor_state.selection.anchor.clone();
if !anchor.is_real() || anchor == *deepest {
return false;
}
let children = self.editor_state.active_children();
let Some(node) = op_editor_core::walkers::find_node(children, &anchor) else {
return false;
};
if !matches!(node, PenNode::Frame(_) | PenNode::Group(_)) {
return false;
}
if node.children().map(|c| c.is_empty()).unwrap_or(true) {
return false;
}
let enters =
op_editor_core::selection_resolve::is_strict_descendant(children, &anchor, deepest);
if enters {
self.editor_state.editor_ui.entered_container = Some(anchor);
}
self.editor_state.set_single_selection(deepest.clone());
self.editor_state.sync_entered_container_with_selection();
self.scroll_layer_panel_selection_into_view(viewport_height);
self.mark_dirty();
true
}
pub(in crate::widget_host) fn update_node_drag_preview(&mut self, drag: &NodeDragState) {
let id = self.editor_state.selection.anchor.clone();
let next = if id.is_real() {

View file

@ -30,6 +30,23 @@ const NESTED: &str = r#"{"version":"0.8.0","children":[
{"type":"rectangle","id":"other","name":"Other","x":650,"y":60,"width":40,"height":40}
]}"#;
/// Four selection depths at the shared probe point: root > l1 > l2 > l3.
/// Every nested node contains doc (470, 130), while `other` remains available
/// for multi-selection and outside-scope tests.
const FOUR_LEVEL: &str = r#"{"version":"0.8.0","children":[
{"type":"frame","id":"root","name":"Root","x":400,"y":60,"width":240,"height":240,
"children":[
{"type":"frame","id":"l1","name":"Level 1","x":20,"y":20,"width":200,"height":200,
"children":[
{"type":"frame","id":"l2","name":"Level 2","x":20,"y":20,"width":160,"height":160,
"children":[
{"type":"rectangle","id":"l3","name":"Level 3","x":20,"y":20,"width":60,"height":60}
]}
]}
]},
{"type":"rectangle","id":"other","name":"Other","x":700,"y":60,"width":40,"height":40}
]}"#;
/// Screen point for a doc point (zoom 1, pan 0 in a fresh host).
fn screen_at(host: &WidgetHostNative, doc_x: f32, doc_y: f32) -> (f32, f32) {
let (cx0, cy0, _cw, _ch) = host.canvas_region(VIEWPORT_W, VIEWPORT_H);
@ -45,44 +62,68 @@ fn release(host: &mut WidgetHostNative) {
let _ = host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H);
}
// --- GAP A: promotion / enter-group / Escape ---------------------------
// --- Relative-depth selection / enter-group / Escape -------------------
#[test]
fn click_on_nested_child_promotes_to_top_level_frame() {
fn first_press_on_four_level_hit_selects_level_one() {
let mut host = WidgetHostNative::new();
seed(&mut host, NESTED);
press_doc(&mut host, 450.0, 110.0); // over `leaf`
assert_eq!(host.editor_state().selection.anchor, NodeId::new("card"));
seed(&mut host, FOUR_LEVEL);
press_doc(&mut host, 470.0, 130.0);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("l1"));
assert_eq!(host.editor_state().editor_ui.entered_container, None);
}
#[test]
fn click_on_child_of_selected_multi_set_keeps_the_set() {
fn click_on_selected_primary_in_multi_set_keeps_the_set() {
let mut host = WidgetHostNative::new();
seed(&mut host, NESTED);
host.editor_state_mut().selection.set = vec![NodeId::new("card"), NodeId::new("other")];
seed(&mut host, FOUR_LEVEL);
host.editor_state_mut().selection.set = vec![NodeId::new("l1"), NodeId::new("other")];
host.editor_state_mut().selection.anchor = NodeId::new("other");
host.mark_paint_dirty_for_test();
press_doc(&mut host, 450.0, 110.0); // child of selected `card`
press_doc(&mut host, 470.0, 130.0);
assert_eq!(host.editor_state().selection_count(), 2, "set preserved");
assert!(host.node_drag.is_some(), "press still drags the set");
}
#[test]
fn double_click_selected_container_enters_and_selects_child() {
fn double_click_drills_one_level_and_third_click_does_not_chain() {
let mut host = WidgetHostNative::new();
seed(&mut host, NESTED);
press_doc(&mut host, 450.0, 110.0); // first click → selects `card`
seed(&mut host, FOUR_LEVEL);
host.set_now_ms(1_000);
press_doc(&mut host, 470.0, 130.0);
release(&mut host);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("card"));
press_doc(&mut host, 450.0, 110.0); // double-click (same node, <400 ms)
assert_eq!(host.editor_state().selection.anchor, NodeId::new("l1"));
host.set_now_ms(1_200);
press_doc(&mut host, 470.0, 130.0);
release(&mut host);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("l2"));
assert_eq!(
host.editor_state().editor_ui.entered_container,
Some(NodeId::new("card")),
"double-click on the selected container enters it"
Some(NodeId::new("l1")),
"double-click enters exactly the primary level"
);
assert_eq!(
host.editor_state().editor_ui.canvas_hover_node,
Some(NodeId::new("l2")),
"stationary hover rebases to the newly selected second level"
);
host.set_now_ms(1_300);
press_doc(&mut host, 470.0, 130.0);
release(&mut host);
assert_eq!(
host.editor_state().selection.anchor,
NodeId::new("l2"),
"the click after a consumed double-click must not chain into l3"
);
assert_eq!(
host.editor_state().editor_ui.entered_container,
Some(NodeId::new("l1"))
);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("leaf"));
}
#[test]
@ -133,23 +174,49 @@ fn blank_canvas_press_exits_the_entered_container() {
#[test]
fn clicking_root_frame_label_selects_that_root() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[
{"type":"frame","id":"music","name":"Music App Home","x":400,"y":60,"width":240,"height":200,
"children":[]}
]}"#,
);
seed(&mut host, FOUR_LEVEL);
press_doc(&mut host, 424.0, 42.0);
assert_eq!(host.editor_state().selection.anchor, NodeId::new("music"));
assert_eq!(host.editor_state().selection.anchor, NodeId::new("root"));
assert!(
host.node_drag.is_some(),
"label press should behave like a root press"
);
}
#[test]
fn cursor_hover_inside_four_level_tree_resolves_to_level_one() {
let mut host = WidgetHostNative::new();
seed(&mut host, FOUR_LEVEL);
let _ = host.layout_scene();
host.last_viewport_w = VIEWPORT_W;
host.last_viewport_h = VIEWPORT_H;
let (x, y) = screen_at(&host, 470.0, 130.0);
assert!(host.apply_cursor_move(x, y));
assert_eq!(
host.editor_state().editor_ui.canvas_hover_node,
Some(NodeId::new("l1"))
);
}
#[test]
fn cursor_hover_on_frame_label_resolves_to_root() {
let mut host = WidgetHostNative::new();
seed(&mut host, FOUR_LEVEL);
let _ = host.layout_scene();
host.last_viewport_w = VIEWPORT_W;
host.last_viewport_h = VIEWPORT_H;
let (x, y) = screen_at(&host, 424.0, 42.0);
assert!(host.apply_cursor_move(x, y));
assert_eq!(
host.editor_state().editor_ui.canvas_hover_node,
Some(NodeId::new("root"))
);
}
fn overlapping_rect_stack(count: usize) -> String {
let children = (0..count)
.map(|i| {

View file

@ -4,8 +4,8 @@ use super::helpers::{resize_bounds, PANEL_MAX_WIDTH, PANEL_MIN_WIDTH};
use super::{DragState, PanelResizeKind, WidgetHostNative};
use op_editor_core::codegen::CodeSelection;
use op_editor_ui::widgets::{
AIChatHit, AIChatPlaceholder, ChatResizeEdge, AI_CHAT_MAX_RATIO, AI_CHAT_MIN_HEIGHT,
AI_CHAT_MIN_WIDTH,
AIChatHit, AIChatPlaceholder, CanvasViewport, ChatResizeEdge, AI_CHAT_MAX_RATIO,
AI_CHAT_MIN_HEIGHT, AI_CHAT_MIN_WIDTH,
};
use op_editor_ui::{Point2D, Rect};
@ -195,6 +195,9 @@ impl WidgetHostNative {
let total_dx = ((x - drag.press_screen_x) / zoom) as f64;
let total_dy = ((y - drag.press_screen_y) / zoom) as f64;
if !drag.moved {
// Once the gesture becomes a drag it cannot be the first
// half of a later double-click drill.
self.editor_state.editor_ui.last_canvas_click = None;
let option_source_ids: Vec<op_editor_core::NodeId> =
self.editor_state.selection.set.to_vec();
if self.alt_held
@ -1037,8 +1040,20 @@ impl WidgetHostNative {
let dx = (x - drag.start_screen_x) / zoom;
let dy = (y - drag.start_screen_y) / zoom;
let new_bounds = resize_bounds(drag.start_bounds, drag.handle, dx, dy);
self.editor_state
.set_selected_bounds(rect_to_doc_rect(new_bounds));
let new_x = drag.handle.moves_left_edge().then(|| {
drag.start_authored_x.unwrap_or(0.0)
+ f64::from(new_bounds.origin.x - drag.start_bounds.origin.x)
});
let new_y = drag.handle.moves_top_edge().then(|| {
drag.start_authored_y.unwrap_or(0.0)
+ f64::from(new_bounds.origin.y - drag.start_bounds.origin.y)
});
self.editor_state.resize_selected_bounds(
rect_to_doc_rect(new_bounds),
drag.handle.resize_axes(),
new_x,
new_y,
);
self.mark_dirty();
return true;
}
@ -1351,11 +1366,11 @@ impl WidgetHostNative {
self.mark_dirty();
return true;
}
// Canvas hover outline (TS `hoveredNodeId`): track the node
// under the cursor while the Select tool idles over the
// canvas. Reads the CURRENT layout scene without refreshing
// (same discipline as layer-row hover — hover must not
// rebuild a stale scene).
// Canvas hierarchy hover: resolve the current level's focus
// from the root-to-deepest scene path. Shared paint outlines
// the focus solid and all direct children dashed. Reads the
// CURRENT layout scene without refreshing (same discipline as
// layer-row hover — hover must not rebuild a stale scene).
let hover_eligible = !over_topmost
&& matches!(self.editor_state.tool, op_editor_core::Tool::Select)
&& self.over_canvas(x, y, self.last_viewport_w, self.last_viewport_h);
@ -1371,12 +1386,31 @@ impl WidgetHostNative {
}
}
self.last_hover_probe = Some((x, y));
let (cx0, cy0) = self.canvas_origin();
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc = self.editor_state.viewport.to_document(canvas_local);
self.layout_scene
.node_at_doc_point(doc, self.editor_state.viewport.zoom)
.map(|id| op_editor_core::NodeId::new(&id))
let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h);
let canvas_rect = Rect {
origin: Point2D::new(cx0, cy0),
size: Point2D::new(cw, ch),
};
let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene);
if let Some(root) = canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y)) {
Some(op_editor_core::NodeId::new(root))
} else {
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc = self.editor_state.viewport.to_document(canvas_local);
self.layout_scene
.node_path_at_doc_point(doc, self.editor_state.viewport.zoom)
.and_then(|path| {
let path = path
.into_iter()
.map(op_editor_core::NodeId::new)
.collect::<Vec<_>>();
op_editor_core::selection_resolve::resolve_canvas_depth_targets(
&path,
self.editor_state.editor_ui.entered_container.as_ref(),
)
.map(|targets| targets.primary)
})
}
} else {
self.last_hover_probe = None;
None

View file

@ -1,8 +1,9 @@
//! Drag/release tests split from `input_tests.rs` so each test module
//! stays under the repository file-size ceiling.
use super::{NodeDragState, WidgetHostNative};
use super::{HandleDragState, NodeDragState, WidgetHostNative};
use op_editor_core::{NodeId, PenNodeExt};
use op_editor_ui::{widgets::SelectionHandle, Point2D, Rect};
/// Seed a host's `editor_state` from a canonical `.op` JSON snippet.
fn seed(host: &mut WidgetHostNative, json: &str) {
@ -30,6 +31,153 @@ fn three_rects(boxes: [(f64, f64, f64, f64); 3], ids: [&str; 3]) -> String {
)
}
#[test]
fn bottom_right_handle_resizes_only_selected_container() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{
"type":"frame","id":"frame","name":"frame","x":100,"y":80,
"width":200,"height":160,"layout":"none","children":[
{"type":"rectangle","id":"child","name":"child","x":12,"y":18,
"width":60,"height":30}
]
}]}"#,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("frame"));
let child_before = authored_geometry(&host, "child");
host.handle_drag = Some(HandleDragState {
handle: SelectionHandle::BottomRight,
start_screen_x: 500.0,
start_screen_y: 500.0,
start_bounds: Rect {
origin: Point2D::new(100.0, 80.0),
size: Point2D::new(200.0, 160.0),
},
start_authored_x: Some(100.0),
start_authored_y: Some(80.0),
});
assert!(host.apply_cursor_move(540.0, 525.0));
assert_eq!(
authored_geometry(&host, "frame"),
(Some(100.0), Some(80.0), Some(240.0), Some(185.0))
);
assert_eq!(
authored_geometry(&host, "child"),
child_before,
"normal container resize must not scale or translate fixed descendants"
);
}
#[test]
fn edge_handle_freezes_only_its_axis_and_preserves_descendants() {
for (handle, move_to, expected_width, expected_height) in [
(
SelectionHandle::Right,
Point2D::new(550.0, 500.0),
serde_json::json!(250.0),
serde_json::json!("fit_content"),
),
(
SelectionHandle::Bottom,
Point2D::new(500.0, 540.0),
serde_json::json!("fill_container"),
serde_json::json!(120.0),
),
] {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{
"type":"frame","id":"frame","name":"frame","x":100,"y":80,
"width":"fill_container","height":"fit_content","layout":"vertical",
"children":[
{"type":"rectangle","id":"child","name":"child","x":12,"y":18,
"width":60,"height":30}
]
}]}"#,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("frame"));
let child_before = authored_geometry(&host, "child");
host.handle_drag = Some(HandleDragState {
handle,
start_screen_x: 500.0,
start_screen_y: 500.0,
start_bounds: Rect {
origin: Point2D::new(100.0, 80.0),
size: Point2D::new(200.0, 80.0),
},
start_authored_x: Some(100.0),
start_authored_y: Some(80.0),
});
assert!(host.apply_cursor_move(move_to.x, move_to.y));
let frame = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("frame"),
)
.expect("frame present");
let frame_json = serde_json::to_value(frame).expect("frame serializes");
assert_eq!(frame_json["width"], expected_width, "handle: {handle:?}");
assert_eq!(frame_json["height"], expected_height, "handle: {handle:?}");
assert_eq!(
authored_geometry(&host, "child"),
child_before,
"edge resize must leave fixed descendant geometry untouched; handle: {handle:?}"
);
}
}
#[test]
fn fill_child_reflows_to_resized_parent_without_authored_mutation() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"0.8.0","children":[{
"type":"frame","id":"frame","name":"frame","x":100,"y":80,
"width":200,"height":160,"layout":"vertical","children":[
{"type":"rectangle","id":"child","name":"child",
"width":"fill_container","height":30}
]
}]}"#,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("frame"));
host.handle_drag = Some(HandleDragState {
handle: SelectionHandle::Right,
start_screen_x: 500.0,
start_screen_y: 500.0,
start_bounds: Rect {
origin: Point2D::new(100.0, 80.0),
size: Point2D::new(200.0, 160.0),
},
start_authored_x: Some(100.0),
start_authored_y: Some(80.0),
});
assert!(host.apply_cursor_move(560.0, 500.0));
host.refresh_layout_scene();
let child = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("child"),
)
.expect("child present");
assert_eq!(child.width_px(), None, "Fill keyword must stay authored");
let resolved = host
.layout_scene
.active_page()
.and_then(|page| page.find("child"))
.expect("resolved child present");
assert_eq!(resolved.bounds.size.x, 260.0);
assert_eq!(resolved.bounds.size.y, 30.0);
}
#[test]
fn anchor_press_release_without_motion_does_not_push_history() {
// Codex CONCERN: a press-release on an anchor without any
@ -454,6 +602,21 @@ fn scene_node_xy(host: &WidgetHostNative, id: &str) -> (f32, f32) {
(n.bounds.origin.x, n.bounds.origin.y)
}
fn authored_geometry(
host: &WidgetHostNative,
id: &str,
) -> (Option<f64>, Option<f64>, Option<f64>, Option<f64>) {
let node =
op_editor_core::walkers::find_node(host.editor_state().active_children(), &NodeId::new(id))
.expect("node present");
(
node.base().x,
node.base().y,
node.width_px(),
node.height_px(),
)
}
/// Read a path anchor's `(x, y)` from the host's `editor_state.doc`.
fn anchor_at(host: &WidgetHostNative, id: &str, idx: usize) -> (f64, f64) {
let n = host

View file

@ -226,7 +226,11 @@ impl WidgetHostNative {
}
// 5. PropertyPanel — only when selection.
let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms);
let property_panel = PropertyPanel::for_selection_at_with_scene(
&self.editor_state,
&self.layout_scene,
self.now_ms,
);
let property_panel_width = ui.property_panel_width;
let right_rail_x = viewport_width - property_panel_width;
if let Some(panel) = property_panel.as_ref() {

View file

@ -14,6 +14,7 @@ use super::{
PanelResizeKind, RotateDragState, WidgetHostNative,
};
use op_editor_core::codegen::CodeSelection;
use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_ui::widgets::{
rotation_corner_at_point, selection_handle_at_point, AIChatHit, AIChatPlaceholder,
CanvasViewport, LayoutCx, LocalePicker, PropertyPanel, Toolbar, TopBar, TopBarHit, Widget,
@ -701,7 +702,8 @@ impl WidgetHostNative {
// 0c. PropertyPanel input row.
self.refresh_layout_scene();
if let Some(panel) =
PropertyPanel::for_selection(&self.editor_state).filter(|_| !in_git_panel)
PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene)
.filter(|_| !in_git_panel)
{
let property_rect = Rect {
origin: Point2D::new(
@ -783,7 +785,16 @@ impl WidgetHostNative {
}
if let Some(focus) = panel.hit_test(property_rect, Point2D::new(x, y)) {
self.commit_property_focus_if_any();
let initial = super::press_helpers::property_focus_initial(focus, &panel);
// Committing the previous W/H field can change layout. Rebuild
// the scene before seeding the newly focused field so Fill/Hug
// reads the concrete post-commit canvas size, not a stale one.
self.refresh_layout_scene();
let resolved_panel =
PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene);
let initial = resolved_panel
.as_ref()
.map(|panel| super::press_helpers::property_focus_initial(focus, panel))
.unwrap_or_default();
// shell-core `PropertyFocus` → op-editor-core.
self.editor_state.ui.property_focus = Some(focus);
self.editor_state
@ -1032,6 +1043,11 @@ impl WidgetHostNative {
&self.editor_state,
Point2D::new(x, y),
) {
let (start_authored_x, start_authored_y) = self
.editor_state
.selected_node()
.map(|node| (node.base().x, node.base().y))
.unwrap_or((None, None));
if let Some(node) = self
.layout_scene
.active_page()
@ -1046,6 +1062,8 @@ impl WidgetHostNative {
start_screen_x: x,
start_screen_y: y,
start_bounds: raw,
start_authored_x,
start_authored_y,
});
return true;
}
@ -1090,22 +1108,25 @@ impl WidgetHostNative {
{
let ec_id = op_editor_core::NodeId::new(&node_id);
return self.apply_canvas_node_press(
ec_id,
vec![ec_id],
x,
y,
text_edit_was_active,
viewport_height,
);
}
if let Some(node_id) = self
if let Some(hit_path) = self
.layout_scene
.node_at_doc_point(doc_point, self.editor_state.viewport.zoom)
.node_path_at_doc_point(doc_point, self.editor_state.viewport.zoom)
{
// Selection promotion / enter-group / drag start —
// Relative-level selection / one-step drill / drag start —
// see `canvas_select_drag.rs`.
let ec_id = op_editor_core::NodeId::new(&node_id);
let hit_path = hit_path
.into_iter()
.map(op_editor_core::NodeId::new)
.collect();
return self.apply_canvas_node_press(
ec_id,
hit_path,
x,
y,
text_edit_was_active,
@ -1113,15 +1134,22 @@ impl WidgetHostNative {
);
}
// Empty canvas press — start a marquee.
self.editor_state.editor_ui.last_canvas_click = None;
let cleared_now = if !self.shift_held {
let was_set = !self.editor_state.selection.set.is_empty();
let had_scope = self.editor_state.editor_ui.entered_container.is_some();
if was_set {
self.editor_state.clear_selection();
}
// Clicking blank canvas steps out of the entered
// container (clearing-exits rule).
self.editor_state.sync_entered_container_with_selection();
was_set
let exited_scope =
had_scope && self.editor_state.editor_ui.entered_container.is_none();
if was_set || exited_scope {
self.mark_dirty();
}
was_set || exited_scope
} else {
false
};

View file

@ -18,6 +18,20 @@ impl WidgetHostNative {
action: op_editor_ui::widgets::PropertyPanelAction,
) {
use op_editor_ui::widgets::PropertyPanelAction as A;
// A sizing keyword toggle may temporarily swap an instance's merged
// display node into the document below. Capture the real canvas size
// before that scope starts so turning Fill/Hug off freezes exactly
// what the user sees, without rebuilding a scene from the temporary
// instance-write representation.
let resolved_sizing_fallback = match action {
A::ToggleSizeFillWidth | A::ToggleSizeHugWidth => {
self.resolved_selected_sizing_axis(true)
}
A::ToggleSizeFillHeight | A::ToggleSizeHugHeight => {
self.resolved_selected_sizing_axis(false)
}
_ => None,
};
// Instance / component lifecycle actions act on the REAL Ref
// node, so they dispatch BEFORE the instance-write redirect
// scope below swaps in the merged display node.
@ -70,16 +84,32 @@ impl WidgetHostNative {
self.set_selected_layout_mode(mode);
}
A::ToggleSizeFillWidth => {
self.toggle_selected_sizing(true, SizingKeyword::FillContainer);
self.toggle_selected_sizing(
true,
SizingKeyword::FillContainer,
resolved_sizing_fallback,
);
}
A::ToggleSizeFillHeight => {
self.toggle_selected_sizing(false, SizingKeyword::FillContainer);
self.toggle_selected_sizing(
false,
SizingKeyword::FillContainer,
resolved_sizing_fallback,
);
}
A::ToggleSizeHugWidth => {
self.toggle_selected_sizing(true, SizingKeyword::FitContent);
self.toggle_selected_sizing(
true,
SizingKeyword::FitContent,
resolved_sizing_fallback,
);
}
A::ToggleSizeHugHeight => {
self.toggle_selected_sizing(false, SizingKeyword::FitContent);
self.toggle_selected_sizing(
false,
SizingKeyword::FitContent,
resolved_sizing_fallback,
);
}
A::ToggleSizeClipContent => {
self.toggle_selected_clip_content();

View file

@ -6,6 +6,29 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use op_editor_core::ui_draft::PropertyFocus;
impl WidgetHostNative {
pub(in crate::widget_host) fn resolved_selected_sizing_axis(
&mut self,
width: bool,
) -> Option<f64> {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return None;
}
self.refresh_layout_scene();
self.layout_scene
.active_page()
.and_then(|page| page.find(id.as_str()))
.map(|node| node.aggregate_bounds())
.map(|bounds| {
if width {
f64::from(bounds.size.x)
} else {
f64::from(bounds.size.y)
}
})
.filter(|value| value.is_finite() && *value >= 0.0)
}
pub(in crate::widget_host) fn set_selected_layout_mode(
&mut self,
mode: op_editor_core::FlexLayout,
@ -33,12 +56,13 @@ impl WidgetHostNative {
&mut self,
width: bool,
keyword: SizingKeyword,
resolved_fallback: Option<f64>,
) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
let (is_current, fallback) = {
let (is_current, aggregate_fallback) = {
let Some(node) = self.editor_state.selected_node() else {
return;
};
@ -47,6 +71,7 @@ impl WidgetHostNative {
let bounds = op_editor_core::aggregate_bounds(node);
(is_current, if width { bounds.w } else { bounds.h })
};
let fallback = resolved_fallback.unwrap_or(aggregate_fallback);
self.editor_state.commit_history();
if is_current {
let focus = if width {

View file

@ -1,7 +1,7 @@
use super::WidgetHostNative;
use op_editor_core::codegen::{CodegenHover, CodegenPhase};
use op_editor_core::PropertyTab;
use op_editor_core::{ButtonPressTarget, NodeId};
use op_editor_core::{ButtonPressTarget, NodeId, PropertyFocus};
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::{PropertyPanel, PropertyPanelAction};
use op_editor_ui::Point2D;
@ -42,6 +42,24 @@ fn point_for_action(
panic!("no property-panel action point maps to requested action");
}
fn point_for_focus(host: &WidgetHostNative, want: PropertyFocus) -> Point2D {
let panel = PropertyPanel::for_selection(host.editor_state()).expect("property panel");
let rect = host.property_rect(VIEWPORT_W, VIEWPORT_H);
let mut y = rect.origin.y + 2.0;
while y < rect.origin.y + rect.size.y {
let mut x = rect.origin.x + 2.0;
while x < rect.origin.x + rect.size.x {
let point = Point2D::new(x, y);
if panel.hit_test(rect, point) == Some(want) {
return point;
}
x += 2.0;
}
y += 2.0;
}
panic!("no property-panel input point maps to {want:?}");
}
fn point_inside_property_panel_without_target(host: &WidgetHostNative) -> Point2D {
let panel = PropertyPanel::for_selection(host.editor_state()).expect("property panel");
let rect = host.property_rect(VIEWPORT_W, VIEWPORT_H);
@ -62,6 +80,16 @@ fn point_inside_property_panel_without_target(host: &WidgetHostNative) -> Point2
panic!("no empty property-panel point found");
}
fn selected_scene_size(host: &mut WidgetHostNative) -> (f32, f32) {
let id = host.editor_state().selection.anchor.as_str().to_string();
let node = host
.layout_scene()
.active_page()
.and_then(|page| page.find(&id))
.expect("selected scene node present");
(node.bounds.size.x, node.bounds.size.y)
}
#[test]
fn property_panel_action_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHostNative::new();
@ -95,6 +123,79 @@ fn property_panel_action_press_sets_and_release_clears_pressed_button() {
assert_eq!(host.editor_state().editor_ui.pressed_button, None);
}
#[test]
fn fill_width_input_seeds_from_resolved_canvas_width() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"root","width":390,"height":710,
"layout":"vertical","children":[
{"type":"frame","id":"fill","width":"fill_container",
"height":"fit_content","layout":"vertical","children":[
{"type":"rectangle","id":"child","width":180,"height":90}
]}
]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("fill"));
let point = point_for_focus(&host, PropertyFocus::SizeW);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(host.editor_state().ui.property_input.text(), "390");
}
#[test]
fn disabling_fill_height_freezes_resolved_height_then_numeric_input_resizes_scene() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"screen","width":390,"height":710,
"layout":"vertical","gap":0,"children":[
{"type":"frame","id":"content","name":"Content Wrapper",
"width":"fill_container","height":"fill_container",
"layout":"vertical","children":[
{"type":"rectangle","id":"body","width":"fill_container","height":100}
]},
{"type":"frame","id":"nav","width":"fill_container","height":94}
]}
]}"##,
);
host.editor_state_mut()
.set_single_selection(NodeId::new("content"));
assert_eq!(selected_scene_size(&mut host), (390.0, 616.0));
host.apply_property_action(PropertyPanelAction::ToggleSizeFillHeight);
let content = op_editor_core::walkers::find_node(
host.editor_state().active_children(),
&NodeId::new("content"),
)
.expect("content present");
let content_json = serde_json::to_value(content).expect("content serializes");
assert_eq!(
content_json["height"],
serde_json::json!(616.0),
"turning Fill Height off must freeze the current resolved height"
);
assert_eq!(selected_scene_size(&mut host), (390.0, 616.0));
let point = point_for_focus(&host, PropertyFocus::SizeH);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(host.editor_state().ui.property_input.text(), "616");
assert!(host.apply_select_all());
assert!(host.apply_text('2'));
assert!(host.apply_text('0'));
assert!(host.apply_text('0'));
assert!(host.apply_send());
assert_eq!(selected_scene_size(&mut host), (390.0, 200.0));
}
#[test]
fn property_panel_background_consumes_clicks() {
let mut host = WidgetHostNative::new();

View file

@ -57,6 +57,8 @@ mod blur_inputs;
mod blur_inputs_tests;
#[cfg(test)]
mod boolean_toolbar_tests;
#[cfg(test)]
mod canvas_hierarchy_tests;
mod chat_design_apply;
#[cfg(test)]
mod chat_design_apply_tests;

View file

@ -0,0 +1,108 @@
//! Web host regression coverage for Pencil-style canvas hierarchy depth.
use super::WidgetHost;
use op_editor_core::{NodeId, Tool};
use op_editor_ui::Point2D;
const VIEWPORT_W: f32 = 1200.0;
const VIEWPORT_H: f32 = 800.0;
const FOUR_LEVELS: &str = r#"{"version":"0.8.0","children":[
{"type":"frame","id":"root","name":"Root Frame","x":500,"y":100,"width":300,"height":300,
"children":[
{"type":"frame","id":"level-1","name":"Level 1","x":20,"y":20,"width":240,"height":240,
"children":[
{"type":"frame","id":"level-2","name":"Level 2","x":20,"y":20,"width":180,"height":180,
"children":[
{"type":"rectangle","id":"level-3","name":"Level 3","x":20,"y":20,"width":100,"height":100}
]}
]}
]}
]}"#;
fn seed() -> WidgetHost {
let doc = jian_ops_schema::load_str(FOUR_LEVELS)
.expect("four-level fixture parses")
.value;
let mut host = WidgetHost::new();
host.editor_state = op_editor_core::EditorState::from_document(doc);
host.editor_state.tool = Tool::Select;
host.editor_state_dirty = true;
host.last_viewport_w = VIEWPORT_W;
host.last_viewport_h = VIEWPORT_H;
host
}
fn screen_at(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D {
let (cx0, cy0, _, _) = host.canvas_region(VIEWPORT_W, VIEWPORT_H);
Point2D::new(cx0 + doc_x, cy0 + doc_y)
}
#[test]
fn hover_and_double_press_drill_exactly_one_level_then_consume_stamp() {
let mut host = seed();
// Absolute bounds nest at root 500, level-1 520, level-2 540,
// level-3 560. This point is inside all four rendered nodes.
let point = screen_at(&host, 580.0, 180.0);
assert!(host.apply_cursor_move(point.x, point.y));
assert_eq!(
host.editor_state.editor_ui.canvas_hover_node,
Some(NodeId::new("level-1")),
"idle hover resolves to the root scope's direct child"
);
host.set_now_ms(1_000);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(host.editor_state.selection.anchor, NodeId::new("level-1"));
assert_eq!(host.editor_state.editor_ui.entered_container, None);
assert!(host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H));
host.set_now_ms(1_200);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(
host.editor_state.selection.anchor,
NodeId::new("level-2"),
"the second press drills only to the direct child under the pointer"
);
assert_eq!(
host.editor_state.editor_ui.entered_container,
Some(NodeId::new("level-1"))
);
assert_eq!(
host.editor_state.editor_ui.canvas_hover_node,
Some(NodeId::new("level-2")),
"stationary-pointer hover rebases to the newly entered depth"
);
assert_eq!(
host.editor_state.editor_ui.last_canvas_click, None,
"a completed double press consumes its stamp"
);
let _ = host.apply_release_with_viewport(VIEWPORT_W, VIEWPORT_H);
host.set_now_ms(1_300);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(
host.editor_state.selection.anchor,
NodeId::new("level-2"),
"a third press starts a fresh click instead of drilling to level-3"
);
assert_eq!(
host.editor_state.editor_ui.entered_container,
Some(NodeId::new("level-1"))
);
}
#[test]
fn frame_label_hover_targets_the_root() {
let mut host = seed();
// Frame labels span y = root_top - 32 .. root_top - 4 and begin
// four pixels left of the frame. Pick a point over "Root Frame".
let label_point = screen_at(&host, 524.0, 82.0);
assert!(host.apply_cursor_move(label_point.x, label_point.y));
assert_eq!(
host.editor_state.editor_ui.canvas_hover_node,
Some(NodeId::new("root"))
);
}

View file

@ -5,7 +5,7 @@
//! the spine under the repo's 800-line cap (mirrors the native
//! host's `widget_host/input.rs` split).
use op_editor_ui::widgets::TOP_BAR_HEIGHT;
use op_editor_ui::widgets::{CanvasViewport, TOP_BAR_HEIGHT};
use op_editor_ui::{Point2D, Rect};
use super::WidgetHost;
@ -795,6 +795,47 @@ impl WidgetHost {
self.mark_dirty();
return true;
}
// Canvas hierarchy hover. The scene hit path is resolved to
// the current level's primary target; shared canvas paint then
// draws that node solid plus all of its direct children dashed.
// Frame labels participate as explicit root targets.
let hover_eligible = !over_topmost
&& matches!(self.editor_state.tool, op_editor_core::Tool::Select)
&& self.over_canvas(x, y, self.last_viewport_w, self.last_viewport_h);
let new_canvas_hover = if hover_eligible {
let (cx0, cy0, cw, ch) = self.canvas_region(self.last_viewport_w, self.last_viewport_h);
let canvas_rect = Rect {
origin: Point2D::new(cx0, cy0),
size: Point2D::new(cw, ch),
};
let canvas = CanvasViewport::from_editor(&self.editor_state, &self.layout_scene);
if let Some(root) = canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y)) {
Some(op_editor_core::NodeId::new(root))
} else {
let local = Point2D::new(x - cx0, y - cy0);
let doc = self.editor_state.viewport.to_document(local);
self.layout_scene
.node_path_at_doc_point(doc, self.editor_state.viewport.zoom)
.and_then(|path| {
let path = path
.into_iter()
.map(op_editor_core::NodeId::new)
.collect::<Vec<_>>();
op_editor_core::selection_resolve::resolve_canvas_depth_targets(
&path,
self.editor_state.editor_ui.entered_container.as_ref(),
)
.map(|targets| targets.primary)
})
}
} else {
None
};
if new_canvas_hover != self.editor_state.editor_ui.canvas_hover_node {
self.editor_state.editor_ui.canvas_hover_node = new_canvas_hover;
self.mark_dirty();
return true;
}
false
}

View file

@ -37,6 +37,83 @@ pub(in crate::widget_host) struct NodeDragState {
}
impl WidgetHost {
/// Resolve a canvas press from the rendered root-to-deepest hit
/// path. Plain click selects the solid-outline primary; a second
/// press inside 400 ms drills exactly one level to the direct child
/// under the pointer and enters the primary as the sibling scope.
pub(in crate::widget_host) fn apply_canvas_node_press(
&mut self,
hit_path: Vec<NodeId>,
x: f32,
y: f32,
text_edit_was_active: bool,
viewport_height: f32,
) -> bool {
let Some(deepest) = hit_path.last().cloned() else {
return false;
};
let Some(targets) = op_editor_core::selection_resolve::resolve_canvas_depth_targets(
&hit_path,
self.editor_state.editor_ui.entered_container.as_ref(),
) else {
return false;
};
let is_double = matches!(
&self.editor_state.editor_ui.last_canvas_click,
Some((prev, t)) if *prev == deepest
&& self.now_ms.saturating_sub(*t) < 400
) && !self.shift_held
&& self.editor_state.selection_count() <= 1;
self.editor_state.editor_ui.last_canvas_click = if self.shift_held || is_double {
None
} else {
Some((deepest, self.now_ms))
};
if is_double && !text_edit_was_active {
if let Some(secondary) = targets.secondary_under_pointer {
self.editor_state.set_single_selection(secondary.clone());
self.editor_state.editor_ui.entered_container = Some(targets.primary);
self.editor_state.editor_ui.canvas_hover_node = Some(secondary);
self.scroll_layer_panel_selection_into_view(viewport_height);
self.mark_dirty();
return true;
}
if self.editor_state.start_text_edit(targets.primary.clone()) {
self.editor_state.ui.text_edit_input.touch(self.now_ms);
self.mark_dirty();
return true;
}
}
let target = targets.primary;
let mut should_start_drag = true;
if self.shift_held {
let was_in_set = self.editor_state.is_selected(&target);
self.editor_state.toggle_selection(target);
should_start_drag = !was_in_set;
} else {
let already_in_set = self.editor_state.is_selected(&target);
if !already_in_set || self.editor_state.selection_count() == 1 {
self.editor_state.set_single_selection(target);
}
}
if self
.editor_state
.editor_ui
.entered_container
.as_ref()
.is_some_and(|entered| !hit_path.contains(entered))
{
self.editor_state.editor_ui.entered_container = None;
}
self.scroll_layer_panel_selection_into_view(viewport_height);
if should_start_drag {
self.start_node_drag(x, y);
}
self.mark_dirty();
true
}
pub(in crate::widget_host) fn start_node_drag(&mut self, x: f32, y: f32) {
self.editor_state.commit_history();
self.option_drag_source_ids.clear();
@ -68,6 +145,7 @@ impl WidgetHost {
let total_dx = ((x - drag.press_screen_x) / zoom) as f64;
let total_dy = ((y - drag.press_screen_y) / zoom) as f64;
if !drag.moved {
self.editor_state.editor_ui.last_canvas_click = None;
let option_source_ids: Vec<NodeId> = self.editor_state.selection.set.to_vec();
if self.alt_held
&& !option_source_ids.is_empty()

View file

@ -180,7 +180,11 @@ impl WidgetHost {
canvas.paint(&mut cx, canvas_rect);
}
let property_panel = PropertyPanel::for_selection_at(&self.editor_state, self.now_ms);
let property_panel = PropertyPanel::for_selection_at_with_scene(
&self.editor_state,
&self.layout_scene,
self.now_ms,
);
if let Some(panel) = property_panel.as_ref() {
let property_rect = Rect {
origin: Point2D::new(viewport_width - ui.property_panel_width, TOP_BAR_HEIGHT),

View file

@ -666,7 +666,9 @@ impl WidgetHost {
// 0c. PropertyPanel button / checkbox — flex modes + size
// flags. Runs AFTER locale picker + TopBar so the
// dropdown overlays still win.
if let Some(panel) = PropertyPanel::for_selection(&self.editor_state) {
if let Some(panel) =
PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene)
{
let property_rect = Rect {
origin: Point2D::new(
viewport_width - self.editor_state.editor_ui.property_panel_width,
@ -932,71 +934,47 @@ impl WidgetHost {
canvas.frame_label_at_point(canvas_rect, Point2D::new(x, y))
{
let node_id = op_editor_core::NodeId::new(&sc_node_id);
if self.shift_held {
let was_in_set = self.editor_state.is_selected(&node_id);
self.editor_state.toggle_selection(node_id);
if !was_in_set {
self.start_node_drag(x, y);
}
} else {
let already_in_set = self.editor_state.is_selected(&node_id);
if !already_in_set || self.editor_state.selection_count() == 1 {
self.editor_state.set_single_selection(node_id);
}
self.start_node_drag(x, y);
}
self.scroll_layer_panel_selection_into_view(viewport_height);
self.mark_dirty();
return true;
}
let hit = self
.layout_scene
.node_at_doc_point(doc_point, self.editor_state.viewport.zoom);
if let Some(sc_node_id) = hit {
let node_id = op_editor_core::NodeId::new(&sc_node_id);
// Canvas double-click: 400 ms same-node → enter
// text-edit on Text nodes.
let is_double = matches!(
self.editor_state.editor_ui.last_canvas_click.clone(),
Some((prev, t))
if prev == node_id && self.now_ms.saturating_sub(t) < 400
return self.apply_canvas_node_press(
vec![node_id],
x,
y,
text_edit_was_active,
viewport_height,
);
}
let hit_path = self
.layout_scene
.node_path_at_doc_point(doc_point, self.editor_state.viewport.zoom);
if let Some(hit_path) = hit_path {
let hit_path = hit_path
.into_iter()
.map(op_editor_core::NodeId::new)
.collect();
return self.apply_canvas_node_press(
hit_path,
x,
y,
text_edit_was_active,
viewport_height,
);
self.editor_state.editor_ui.last_canvas_click =
Some((node_id.clone(), self.now_ms));
if is_double
&& !text_edit_was_active
&& self.editor_state.start_text_edit(node_id.clone())
{
self.editor_state.ui.text_edit_input.touch(self.now_ms);
self.mark_dirty();
return true;
}
let mut should_start_drag = true;
if self.shift_held {
let was_in_set = self.editor_state.is_selected(&node_id);
self.editor_state.toggle_selection(node_id);
should_start_drag = !was_in_set;
} else {
let already_in_set = self.editor_state.is_selected(&node_id);
if !already_in_set || self.editor_state.selection_count() == 1 {
self.editor_state.set_single_selection(node_id);
}
}
self.scroll_layer_panel_selection_into_view(viewport_height);
if should_start_drag {
self.start_node_drag(x, y);
}
self.mark_dirty();
return true;
}
// Empty canvas with Select → marquee.
self.editor_state.editor_ui.last_canvas_click = None;
let cleared_now = if !self.shift_held {
let was_set = !self.editor_state.selection.set.is_empty();
let exited_scope = self
.editor_state
.editor_ui
.entered_container
.take()
.is_some();
if was_set {
self.editor_state.clear_selection();
}
if was_set || exited_scope {
self.mark_dirty();
}
was_set
was_set || exited_scope
} else {
false
};

View file

@ -97,6 +97,13 @@ impl WidgetHost {
}
_ => {}
}
// Resolve Fill/Hug pixels against the real scene before an instance
// write scope temporarily swaps a Ref anchor for its display node.
let resolved_sizing_fallback = match action {
A::ToggleSizeFillWidth | A::ToggleSizeHugWidth => self.selected_resolved_size(true),
A::ToggleSizeFillHeight | A::ToggleSizeHugHeight => self.selected_resolved_size(false),
_ => None,
};
let instance_scope = self.editor_state.begin_instance_write_for_anchor();
match action {
A::SetPropertyTab(tab) => {
@ -106,16 +113,32 @@ impl WidgetHost {
self.set_selected_layout_mode(mode);
}
A::ToggleSizeFillWidth => {
self.toggle_selected_sizing(true, SizingKeyword::FillContainer);
self.toggle_selected_sizing(
true,
SizingKeyword::FillContainer,
resolved_sizing_fallback,
);
}
A::ToggleSizeFillHeight => {
self.toggle_selected_sizing(false, SizingKeyword::FillContainer);
self.toggle_selected_sizing(
false,
SizingKeyword::FillContainer,
resolved_sizing_fallback,
);
}
A::ToggleSizeHugWidth => {
self.toggle_selected_sizing(true, SizingKeyword::FitContent);
self.toggle_selected_sizing(
true,
SizingKeyword::FitContent,
resolved_sizing_fallback,
);
}
A::ToggleSizeHugHeight => {
self.toggle_selected_sizing(false, SizingKeyword::FitContent);
self.toggle_selected_sizing(
false,
SizingKeyword::FitContent,
resolved_sizing_fallback,
);
}
A::ToggleSizeClipContent => {
self.toggle_selected_clip_content();

View file

@ -11,7 +11,11 @@ impl WidgetHost {
point: Point2D,
) -> bool {
self.commit_property_focus_if_any();
let (focus, initial) = if let Some(panel) = PropertyPanel::for_selection(&self.editor_state)
// The previous property commit may have changed the selected node's
// resolved Fill/Hug size. Refresh before taking the next input seed.
self.refresh_layout_scene();
let (focus, initial) = if let Some(panel) =
PropertyPanel::for_selection_with_scene(&self.editor_state, &self.layout_scene)
{
let focus = panel.hit_test(property_rect, point).unwrap_or(focus);
(

View file

@ -6,6 +6,23 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use op_editor_core::PropertyFocus;
impl WidgetHost {
pub(in crate::widget_host) fn selected_resolved_size(&mut self, width: bool) -> Option<f64> {
self.refresh_layout_scene();
let id = self.editor_state.selection.anchor.as_str();
self.layout_scene
.active_page()
.and_then(|page| page.find(id))
.map(|node| node.aggregate_bounds())
.map(|bounds| {
if width {
f64::from(bounds.size.x)
} else {
f64::from(bounds.size.y)
}
})
.filter(|value| value.is_finite() && *value >= 0.0)
}
pub(in crate::widget_host) fn set_selected_layout_mode(
&mut self,
mode: op_editor_core::FlexLayout,
@ -33,12 +50,16 @@ impl WidgetHost {
&mut self,
width: bool,
keyword: SizingKeyword,
resolved_fallback: Option<f64>,
) {
let id = self.editor_state.selection.anchor.clone();
if !id.is_real() {
return;
}
let (is_current, fallback) = {
// Leaving Fill / Hug must freeze the layout-resolved canvas size.
// The canonical keyword has no literal pixels, so aggregate_bounds
// can collapse to the much smaller descendant-content union.
let (is_current, aggregate_fallback) = {
let Some(node) = self.editor_state.selected_node() else {
return;
};
@ -47,6 +68,7 @@ impl WidgetHost {
let bounds = op_editor_core::aggregate_bounds(node);
(is_current, if width { bounds.w } else { bounds.h })
};
let fallback = resolved_fallback.unwrap_or(aggregate_fallback);
self.editor_state.commit_history();
if is_current {
let focus = if width {

View file

@ -7,7 +7,7 @@ use jian_ops_schema::sizing::{SizingBehavior, SizingKeyword};
use jian_ops_schema::style::PenFill;
use op_editor_core::codegen::{CodegenHover, CodegenPhase};
use op_editor_core::image_panel_state::{ImageAssetCheck, ImageAssetStatus};
use op_editor_core::{ButtonPressTarget, NodeId};
use op_editor_core::{ButtonPressTarget, NodeId, PropertyFocus};
use op_editor_core::{FlexLayout, PaddingEditMode, PropertyTab};
use op_editor_ui::widgets::property_panel_action::CodegenAction;
use op_editor_ui::widgets::property_panel_action::{
@ -57,6 +57,24 @@ fn point_for_action(host: &WidgetHost, want: impl Fn(&PropertyPanelAction) -> bo
panic!("no property-panel action point maps to requested action");
}
fn point_for_focus(host: &WidgetHost, want: PropertyFocus) -> Point2D {
let panel = PropertyPanel::for_selection(&host.editor_state).expect("property panel");
let rect = property_rect(host);
let mut y = rect.origin.y + 2.0;
while y < rect.origin.y + rect.size.y {
let mut x = rect.origin.x + 2.0;
while x < rect.origin.x + rect.size.x {
let point = Point2D::new(x, y);
if panel.hit_test(rect, point) == Some(want) {
return point;
}
x += 2.0;
}
y += 2.0;
}
panic!("no property-panel input point maps to {want:?}");
}
fn point_inside_property_panel_without_target(host: &WidgetHost) -> Point2D {
let panel = PropertyPanel::for_selection(&host.editor_state).expect("property panel");
let rect = property_rect(host);
@ -120,6 +138,17 @@ fn ref_node<'a>(host: &'a WidgetHost, id: &str) -> &'a jian_ops_schema::node::Re
}
}
fn selected_scene_size(host: &mut WidgetHost) -> (f32, f32) {
let id = host.editor_state.selection.anchor.as_str().to_string();
host.refresh_layout_scene();
let node = host
.layout_scene
.active_page()
.and_then(|page| page.find(&id))
.expect("selected scene node present");
(node.bounds.size.x, node.bounds.size.y)
}
#[test]
fn property_panel_action_press_sets_and_release_clears_pressed_button() {
let mut host = WidgetHost::new();
@ -152,6 +181,46 @@ fn property_panel_action_press_sets_and_release_clears_pressed_button() {
assert_eq!(host.editor_state.editor_ui.pressed_button, None);
}
#[test]
fn web_disabling_fill_height_freezes_resolved_height_then_numeric_input_resizes_scene() {
let mut host = WidgetHost::new();
seed(
&mut host,
r##"{ "version": "0.8.0", "children": [
{"type":"frame","id":"screen","width":390,"height":710,
"layout":"vertical","gap":0,"children":[
{"type":"frame","id":"content","name":"Content Wrapper",
"width":"fill_container","height":"fill_container",
"layout":"vertical","children":[
{"type":"rectangle","id":"body","width":"fill_container","height":100}
]},
{"type":"frame","id":"nav","width":"fill_container","height":94}
]}
]}"##,
);
host.editor_state
.set_single_selection(NodeId::new("content"));
assert_eq!(selected_scene_size(&mut host), (390.0, 616.0));
host.apply_property_action(PropertyPanelAction::ToggleSizeFillHeight);
let content = selected_frame(&host);
assert_eq!(
content.container.height,
Some(SizingBehavior::Number(616.0)),
"turning Fill Height off must freeze the current resolved height"
);
assert_eq!(selected_scene_size(&mut host), (390.0, 616.0));
let point = point_for_focus(&host, PropertyFocus::SizeH);
assert!(host.apply_press(point.x, point.y, VIEWPORT_W, VIEWPORT_H));
assert_eq!(host.editor_state.ui.property_input.text(), "616");
host.editor_state.ui.property_input.set_text("200");
assert!(host.apply_send());
assert_eq!(selected_scene_size(&mut host), (390.0, 200.0));
}
#[test]
fn web_property_panel_component_button_creates_and_detaches_component() {
let mut host = WidgetHost::new();

View file

@ -1,3 +1,4 @@
use op_editor_core::pen_node_ext::PenNodeExt;
use op_editor_ui::util::resize_bounds;
use op_editor_ui::widgets::{selection_handle_at_point, SelectionHandle};
use op_editor_ui::{Point2D, Rect};
@ -10,6 +11,8 @@ pub(in crate::widget_host) struct HandleDragState {
pub(in crate::widget_host) start_screen_x: f32,
pub(in crate::widget_host) start_screen_y: f32,
pub(in crate::widget_host) start_bounds: Rect,
pub(in crate::widget_host) start_authored_x: Option<f64>,
pub(in crate::widget_host) start_authored_y: Option<f64>,
}
impl WidgetHost {
@ -41,6 +44,11 @@ impl WidgetHost {
else {
return false;
};
let (start_authored_x, start_authored_y) = self
.editor_state
.selected_node()
.map(|node| (node.base().x, node.base().y))
.unwrap_or((None, None));
let raw = node.bounds;
if raw.size.x <= 0.0 && raw.size.y <= 0.0 {
return false;
@ -51,6 +59,8 @@ impl WidgetHost {
start_screen_x: x,
start_screen_y: y,
start_bounds: raw,
start_authored_x,
start_authored_y,
});
true
}
@ -67,8 +77,20 @@ impl WidgetHost {
let dx = (x - drag.start_screen_x) / zoom;
let dy = (y - drag.start_screen_y) / zoom;
let new_bounds = resize_bounds(drag.start_bounds, drag.handle, dx, dy);
self.editor_state
.set_selected_bounds(rect_to_doc_rect(new_bounds));
let new_x = drag.handle.moves_left_edge().then(|| {
drag.start_authored_x.unwrap_or(0.0)
+ f64::from(new_bounds.origin.x - drag.start_bounds.origin.x)
});
let new_y = drag.handle.moves_top_edge().then(|| {
drag.start_authored_y.unwrap_or(0.0)
+ f64::from(new_bounds.origin.y - drag.start_bounds.origin.y)
});
self.editor_state.resize_selected_bounds(
rect_to_doc_rect(new_bounds),
drag.handle.resize_axes(),
new_x,
new_y,
);
self.mark_dirty();
true
}

View file

@ -1,6 +1,5 @@
use super::WidgetHost;
use jian_ops_schema::node::PenNode;
use op_editor_core::{own_bounds, walkers::find_node, NodeId, Tool};
use op_editor_core::{own_bounds, walkers::find_node, NodeId, PenNodeExt, Tool};
use op_editor_ui::Point2D;
const VW: f32 = 1200.0;
@ -21,6 +20,24 @@ fn seed(host: &mut WidgetHost) {
host.editor_state_dirty = true;
}
fn seed_container(host: &mut WidgetHost) {
let doc = jian_ops_schema::load_str(
r##"{"version":"0.8.0","children":[
{"type":"frame","id":"box","name":"Frame","x":100,"y":100,
"width":120,"height":80,"layout":"none","children":[
{"type":"rectangle","id":"child","name":"Child","x":10,"y":12,
"width":30,"height":20,"fill":[{"type":"solid","color":"#22C55E"}]}
]}
]}"##,
)
.expect("container fixture JSON parses")
.value;
host.editor_state = op_editor_core::EditorState::from_document(doc);
host.editor_state.tool = Tool::Select;
host.editor_state.set_single_selection(NodeId::new("box"));
host.editor_state_dirty = true;
}
fn screen(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D {
let (cx0, cy0, _, _) = host.canvas_region(VW, VH);
Point2D::new(cx0 + doc_x, cy0 + doc_y)
@ -29,10 +46,7 @@ fn screen(host: &WidgetHost, doc_x: f32, doc_y: f32) -> Point2D {
fn box_bounds(host: &WidgetHost) -> op_editor_core::DocRect {
let node = find_node(host.editor_state.active_children(), &NodeId::new("box"))
.expect("box remains in document");
match node {
PenNode::Rectangle(_) => own_bounds(node),
_ => panic!("fixture box is not a rectangle"),
}
own_bounds(node)
}
#[test]
@ -53,3 +67,26 @@ fn select_tool_dragging_bottom_right_handle_resizes_selected_shape_like_native()
assert_eq!(bounds.w, 160.0);
assert_eq!(bounds.h, 105.0);
}
#[test]
fn dragging_container_handle_resizes_only_the_container() {
let mut host = WidgetHost::new();
seed_container(&mut host);
let press = screen(&host, 220.0, 180.0);
let move_to = screen(&host, 260.0, 205.0);
assert!(host.apply_press(press.x, press.y, VW, VH));
assert!(host.apply_cursor_move(move_to.x, move_to.y));
assert!(host.apply_release_with_viewport(VW, VH));
let bounds = box_bounds(&host);
assert_eq!(bounds.w, 160.0);
assert_eq!(bounds.h, 105.0);
let child = find_node(host.editor_state.active_children(), &NodeId::new("child"))
.expect("child remains in document");
assert_eq!(child.base().x, Some(10.0));
assert_eq!(child.base().y, Some(12.0));
assert_eq!(child.width_px(), Some(30.0));
assert_eq!(child.height_px(), Some(20.0));
}

View file

@ -288,9 +288,7 @@ pub fn lookup(key: &str) -> Option<&'static str> {
"ai.quickAction.bottomNavPrompt" => "Design a mobile app bottom navigation bar with 5 tabs: Home, Search, Add, Messages, Profile",
"ai.quickAction.colorPalette" => "Suggest a color palette for my app",
"ai.quickAction.colorPalettePrompt" => "Suggest a modern color palette for a pet care app",
"ai.quickAction.dashboard" => {
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
}
"ai.quickAction.dashboard" => "Dark music streaming mobile app",
"ai.quickAction.dashboardPrompt" => {
"Design a dark-themed music streaming mobile app home screen. Include a greeting \"Good evening\", horizontal scrollable \"Recently Played\" album art cards, \"Made For You\" section with 3 playlist cards showing cover art and playlist names, \"New Releases\" section with 4 album cards in a 2x2 grid, and a floating mini player bar at the bottom showing current track with play/pause controls. Bottom tab bar (Home, Search, Library, Premium). Dark background with lime green accent."
}

View file

@ -292,9 +292,7 @@ pub fn lookup(key: &str) -> Option<&'static str> {
}
"ai.quickAction.colorPalette" => "为我的应用推荐配色方案",
"ai.quickAction.colorPalettePrompt" => "为一个宠物护理应用推荐一套现代配色方案",
"ai.quickAction.dashboard" => {
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片封面和歌单名\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
}
"ai.quickAction.dashboard" => "暗色音乐流媒体 App 首页",
"ai.quickAction.dashboardPrompt" => {
"设计一个暗色音乐流媒体App首页。包含问候语\"晚上好\"\"最近播放\"横向滑动专辑封面卡片、\"为你推荐\"区3张歌单卡片封面和歌单名\"新发行\"区4张专辑卡片2x2网格、底部悬浮迷你播放器当前曲目+播放/暂停控件)。底部导航栏(首页、搜索、音乐库、会员)。深色背景搭配荧光绿强调。"
}

View file

@ -879,7 +879,10 @@ fn text_to_payload(n: &TextNode) -> NodePayload {
p.font_family = n.font_family.clone().unwrap_or_default();
p.font_size = n.font_size.unwrap_or(0.0) as f32;
p.font_weight = resolve_font_weight(n.font_weight.as_ref());
p.line_height = n.line_height.unwrap_or(0.0) as f32;
// Keep paint on the same canonical multiplier used by layout measurement.
// In particular, text carrying a pixel-like lineHeight must not measure
// with the default and then paint with hundreds of pixels of leading.
p.line_height = n.layout_line_height_multiplier().unwrap_or(0.0) as f32;
p.letter_spacing = n.letter_spacing.unwrap_or(0.0) as f32;
p.text_align = n
.text_align

View file

@ -274,6 +274,88 @@ fn fit_content_text_resolves_scene_bounds_to_measured_text() {
);
}
#[test]
fn omitted_height_text_uses_content_height_for_fixed_and_fill_width() {
// Live M3 regression: the model emitted pixel-like lineHeight values while
// omitting text height. They are outside the canonical multiplier range;
// auto-height text must fall back to content measurement instead of
// becoming fontSize * lineHeight hundreds of pixels tall.
let src = r##"{
"version":"1.0.0","children":[{
"type":"frame","id":"root","width":320,"height":300,
"layout":"vertical","gap":4,"children":[
{"type":"text","id":"fixed","width":144,
"content":"Neon Lights Live","fontSize":13,"lineHeight":17},
{"type":"text","id":"fill","width":"fill_container",
"content":"Sunset Jazz on Pier 17","fontSize":14,"lineHeight":18},
{"type":"rectangle","id":"after","width":"fill_container","height":20}
]
}]
}"##;
let scene = editor_state_to_layout_scene(&state_from(src));
let page = scene.active_page().expect("active page");
let fixed = page.find("fixed").expect("fixed-width text");
let fill = page.find("fill").expect("fill-width text");
let after = page.find("after").expect("following sibling");
assert_eq!(
fixed.line_height, 0.0,
"paint should use default line height"
);
assert_eq!(
fill.line_height, 0.0,
"paint should use default line height"
);
assert!(
(10.0..40.0).contains(&fixed.bounds.size.y),
"fixed-width omitted-height text should use content height, got {:?}",
fixed.bounds
);
assert!(
(10.0..40.0).contains(&fill.bounds.size.y),
"fill-width omitted-height text should use content height, got {:?}",
fill.bounds
);
assert!(
after.bounds.origin.y >= fill.bounds.origin.y + fill.bounds.size.y,
"following sibling must come after auto-height text: fixed={:?} fill={:?} after={:?}",
fixed.bounds,
fill.bounds,
after.bounds
);
assert!(
after.bounds.origin.y < 100.0,
"auto-height text must not consume the clipped parent: {:?}",
after.bounds
);
}
#[test]
fn explicit_height_multiline_text_rejects_pixel_like_line_height_for_paint() {
// An explicit box height is a geometry contract, not permission to change
// lineHeight from a multiplier into pixels. Layout and paint must therefore
// use the same fallback semantics for multi-line fixed-height text.
let src = r##"{
"version":"1.0.0","children":[{
"type":"text","id":"explicit","width":180,"height":52,
"textGrowth":"fixed-width-height",
"content":"First line\nSecond line","fontSize":14,"lineHeight":17
}]
}"##;
let scene = editor_state_to_layout_scene(&state_from(src));
let node = scene
.active_page()
.expect("active page")
.find("explicit")
.expect("explicit-height text");
assert_eq!(node.bounds.size.y, 52.0, "authored box height is preserved");
assert_eq!(
node.line_height, 0.0,
"paint must use the default multiplier instead of treating 17 as 17x"
);
}
#[test]
fn fit_content_stack_reserves_missing_height_text_before_next_section() {
let src = r##"{

2
vendor/jian vendored

@ -1 +1 @@
Subproject commit dcc033356e23c59ef5edf60f3c6d83e3a3356cc4
Subproject commit 91ac895f11c1b70b8df7f8abff91751efd323b15