fix(editor): new/opened documents open with nothing selected

A fresh starter canvas and a just-opened .op no longer ship the root
frame pre-selected, which both reads cleaner and stops a stray selection
from mis-routing a whole-screen design request into modify.
This commit is contained in:
Fini 2026-07-06 00:47:11 +08:00
parent 92977e08dd
commit 2090b5a8eb
8 changed files with 51 additions and 32 deletions

View file

@ -66,8 +66,7 @@ impl EditorState {
/// Build the document a fresh launch opens with — a single empty
/// starter Frame `n10` matching the TypeScript app's blank
/// document geometry, selected so the user can immediately resize
/// / move it or drop nodes inside. No demo decoration.
/// document geometry. No default selection and no demo decoration.
pub fn starter() -> Self {
let src = r##"{
"version": "0.8.0",
@ -81,9 +80,7 @@ impl EditorState {
let doc = jian_ops_schema::load_str(src)
.expect("EditorState::starter() fixture parses")
.value;
let mut state = Self::from_document(doc);
state.set_single_selection(NodeId::new("n10"));
state
Self::from_document(doc)
}
/// Spawn a fresh leaf node for the active shape / frame / text /
@ -625,14 +622,14 @@ mod tests {
}
#[test]
fn starter_is_a_single_empty_frame_with_it_selected() {
fn starter_is_a_single_empty_frame_with_nothing_selected() {
let s = EditorState::starter();
// Exactly one top-level node — the starter Frame. The
// `max_node_id() == 10` check proves no demo children:
// the n11..n14 sample tree would lift it to 14.
assert_eq!(s.doc.children.len(), 1);
assert_eq!(s.max_node_id(), 10);
assert_eq!(s.selection.anchor, NodeId::new("n10"));
assert!(s.selection.is_empty());
let frame = match &s.doc.children[0] {
jian_ops_schema::node::PenNode::Frame(frame) => frame,
other => panic!("starter should be a frame, got {:?}", other),

View file

@ -177,7 +177,8 @@ fn first_layer_lock_top_left(panel: &LayerPanel, rect: Rect) -> Point2D {
#[test]
fn selected_visible_unlocked_layer_does_not_expose_trailing_actions_without_hover() {
let state = EditorState::starter();
let mut state = EditorState::starter();
state.set_single_selection(NodeId::new("n10"));
let panel = LayerPanel::from_editor(&state);
assert!(panel.items[0].selected);
assert!(!panel.items[0].hovered);

View file

@ -236,11 +236,11 @@ pub fn launch_if_pending(
fn should_launch_direct_modify(state: &EditorState, user_text: &str) -> bool {
// A pristine "from-scratch" canvas holds only the blank starter frame:
// there is nothing real to modify, so ANY design request on it is a NEW
// design, never a modify — even when the starter frame is selected and the
// prompt is a bare noun phrase the new-screen gates don't recognize
// (measured: "Luxury webapp for managing barbershop clients" on a fresh
// canvas fell into run_modify_turn → glm flat-nodes → empty `"`). This is
// the desktop parity of web_chat_standard's `page_children_empty => New`.
// design, never a modify — even when the prompt is a bare noun phrase the
// new-screen gates don't recognize (measured: "Luxury webapp for managing
// barbershop clients" on a fresh canvas fell into run_modify_turn → glm
// flat-nodes → empty `"`). This is the desktop parity of
// web_chat_standard's `page_children_empty => New`.
if active_page_is_blank_starter_frame(state) {
return false;
}

View file

@ -51,10 +51,10 @@ fn state_with_selected_header() -> EditorState {
fn blank_starter_canvas_never_launches_modify() {
let state = EditorState::starter();
assert!(!state.selection.is_empty(), "starter frame is selected");
assert!(state.selection.is_empty(), "fresh starter has no selection");
assert!(
!should_launch_direct_modify(&state, "Luxury webapp for managing barbershop clients"),
"fresh blank starter canvas has no real target to modify"
"fresh blank starter canvas has no selected target or real content to modify"
);
}

View file

@ -90,14 +90,14 @@ fn selected_count_chip_clear_click_clears_canvas_selection() {
#[test]
fn fresh_app_fits_blank_frame_like_ts_canvas_init() {
let app = DesktopApp::new(None);
assert!(app.host.editor_state().selection.is_empty());
let v = app.host.editor_state().viewport;
// Golden fit values track `property_panel_width` (the right rail is
// shown on the fresh app, so the canvas region = 1440 − panel). At
// the TS-matching `w-64` (256 px) panel the blank frame fits at 0.68.
assert!((v.zoom - 0.68).abs() < 1e-3, "zoom {}", v.zoom);
// With no fresh selection, the right rail is hidden and the
// blank frame fits in the wider canvas region.
assert!((v.zoom - 0.8933333).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 158.0).abs() < 1e-2, "pan_y {}", v.pan_y);
assert!((v.pan_y - 72.66669).abs() < 1e-2, "pan_y {}", v.pan_y);
}
#[test]
@ -108,9 +108,9 @@ fn fresh_app_refits_blank_frame_to_actual_window_size_once() {
assert!(app.fit_initial_blank_frame_to_actual_viewport());
let v = app.host.editor_state().viewport;
assert!((v.zoom - 0.31333333).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.zoom - 0.52666664).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 204.66666).abs() < 1e-2, "pan_y {}", v.pan_y);
assert!((v.pan_y - 119.33334).abs() < 1e-2, "pan_y {}", v.pan_y);
app.viewport_width = 1200.0;
app.viewport_height = 800.0;

View file

@ -109,6 +109,7 @@ fn load_into_host(host: &mut WidgetHostNative, path: &std::path::Path) -> Result
let mut state = load_editor_state(path, locale)?;
preserve_app_preferences(host.editor_state(), &mut state);
set_file_name_display(&mut state, Some(path));
state.clear_selection();
let bb = active_page_bbox(&state);
eprintln!(
"[open] {} top-level nodes; content bbox {:?}",
@ -440,10 +441,7 @@ mod tests {
assert_eq!(outcome, ActionOutcome::Saved);
assert!(current_path.is_none());
assert_eq!(host.editor_state().doc.children.len(), 1);
assert_eq!(
host.editor_state().selection.anchor,
op_editor_core::NodeId::new("n10")
);
assert!(host.editor_state().selection.is_empty());
let frame = match &host.editor_state().doc.children[0] {
jian_ops_schema::node::PenNode::Frame(frame) => frame,
other => panic!(
@ -462,9 +460,9 @@ mod tests {
Some(jian_ops_schema::sizing::SizingBehavior::Number(800.0))
));
let v = host.editor_state().viewport;
assert!((v.zoom - 0.68).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.zoom - 0.8933333).abs() < 1e-3, "zoom {}", v.zoom);
assert!((v.pan_x - 64.0).abs() < 1e-2, "pan_x {}", v.pan_x);
assert!((v.pan_y - 158.0).abs() < 1e-2, "pan_y {}", v.pan_y);
assert!((v.pan_y - 72.66669).abs() < 1e-2, "pan_y {}", v.pan_y);
}
#[test]
@ -555,4 +553,25 @@ mod tests {
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(sidecar_path(&path));
}
#[test]
fn opening_document_leaves_nothing_selected() {
let mut host = WidgetHostNative::new();
host.editor_state_mut()
.set_single_selection(op_editor_core::NodeId::new("n10"));
let mut state_to_open = EditorState::starter();
state_to_open.set_single_selection(op_editor_core::NodeId::new("n10"));
let path = temp_op_path("open-clears-selection");
save_to_path(&state_to_open, &path).expect("save succeeds");
let mut current_path = None;
assert!(open_path(&mut host, path.clone(), &mut current_path, None));
assert!(host.editor_state().selection.is_empty());
assert_eq!(host.editor_state().doc.children.len(), 1);
let _ = std::fs::remove_file(&path);
let _ = std::fs::remove_file(sidecar_path(&path));
}
}

View file

@ -414,11 +414,11 @@ fn incremental_drag_then_doc_restored_to_cached_value_rebuilds_the_scene() {
#[test]
fn host_carries_editor_state_as_source_of_truth() {
// A fresh host opens with the demo sample seeded onto
// A fresh host opens with the blank starter document seeded onto
// `EditorState` — the host's single source of truth.
let host = WidgetHostNative::new();
assert!(!host.editor_state().doc.children.is_empty());
assert!(!host.editor_state().selection.is_empty());
assert!(host.editor_state().selection.is_empty());
}
#[test]

View file

@ -93,8 +93,10 @@ fn ime_commit_without_any_focused_input_is_a_no_op() {
#[test]
fn paste_text_routes_to_focused_rename() {
let mut host = WidgetHost::new();
// The starter document selects the blank starter frame; begin an
// inline rename on it like a layer-row double-click would.
// Begin an inline rename on the blank starter frame like a
// layer-row double-click would.
host.editor_state
.set_single_selection(op_editor_core::NodeId::new("n10"));
let id = host.editor_state.selection.anchor.clone();
assert!(host.editor_state.start_rename_layer(id));
// Select-all so the paste replaces the seeded name deterministically.