From 2090b5a8ebad130c26b17d7b0586f53276ef2a67 Mon Sep 17 00:00:00 2001 From: Fini Date: Mon, 6 Jul 2026 00:47:11 +0800 Subject: [PATCH] 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. --- crates/op-editor-core/src/host_support.rs | 11 +++---- .../src/widgets/layer_panel_tests.rs | 3 +- .../src/chat_session_launch.rs | 10 +++--- .../chat_session_launch_selection_tests.rs | 4 +-- crates/op-host-desktop/src/main_tests.rs | 14 ++++----- crates/op-host-desktop/src/persistence.rs | 31 +++++++++++++++---- .../src/widget_host/input_drag_tests.rs | 4 +-- .../op-host-web/src/widget_host/io_tests.rs | 6 ++-- 8 files changed, 51 insertions(+), 32 deletions(-) diff --git a/crates/op-editor-core/src/host_support.rs b/crates/op-editor-core/src/host_support.rs index 1a4e3ea55..0b78f64e8 100644 --- a/crates/op-editor-core/src/host_support.rs +++ b/crates/op-editor-core/src/host_support.rs @@ -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), diff --git a/crates/op-editor-ui/src/widgets/layer_panel_tests.rs b/crates/op-editor-ui/src/widgets/layer_panel_tests.rs index 221d1b53d..ccf0abf42 100644 --- a/crates/op-editor-ui/src/widgets/layer_panel_tests.rs +++ b/crates/op-editor-ui/src/widgets/layer_panel_tests.rs @@ -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); diff --git a/crates/op-host-desktop/src/chat_session_launch.rs b/crates/op-host-desktop/src/chat_session_launch.rs index 10c60117b..803933818 100644 --- a/crates/op-host-desktop/src/chat_session_launch.rs +++ b/crates/op-host-desktop/src/chat_session_launch.rs @@ -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; } diff --git a/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs b/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs index 881b9a474..46c683f15 100644 --- a/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs +++ b/crates/op-host-desktop/src/chat_session_launch_selection_tests.rs @@ -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" ); } diff --git a/crates/op-host-desktop/src/main_tests.rs b/crates/op-host-desktop/src/main_tests.rs index c187fa838..12355e24a 100644 --- a/crates/op-host-desktop/src/main_tests.rs +++ b/crates/op-host-desktop/src/main_tests.rs @@ -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; diff --git a/crates/op-host-desktop/src/persistence.rs b/crates/op-host-desktop/src/persistence.rs index c58f86d3f..8dacdf298 100644 --- a/crates/op-host-desktop/src/persistence.rs +++ b/crates/op-host-desktop/src/persistence.rs @@ -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)); + } } diff --git a/crates/op-host-native/src/widget_host/input_drag_tests.rs b/crates/op-host-native/src/widget_host/input_drag_tests.rs index 3c1155af3..132a13dc2 100644 --- a/crates/op-host-native/src/widget_host/input_drag_tests.rs +++ b/crates/op-host-native/src/widget_host/input_drag_tests.rs @@ -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] diff --git a/crates/op-host-web/src/widget_host/io_tests.rs b/crates/op-host-web/src/widget_host/io_tests.rs index b32916dc9..07a58eba8 100644 --- a/crates/op-host-web/src/widget_host/io_tests.rs +++ b/crates/op-host-web/src/widget_host/io_tests.rs @@ -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.