fix(panels): hide right rail when nothing is selected

This commit is contained in:
Fini 2026-07-24 21:11:27 +08:00
parent 124db9d144
commit ea9dca5d10
10 changed files with 214 additions and 88 deletions

View file

@ -149,17 +149,25 @@ impl EditorState {
out
}
/// Right-rail PropertyPanel visibility gate. Both tabs always have a
/// meaningful fallback: Design exposes active-page properties whenever
/// the selection is empty or stale, and Code uses the active page's
/// children. Keep this consistent with `PropertyPanel::for_selection_at`
/// so canvas geometry never disagrees with the panel that gets painted.
/// Right-rail PropertyPanel visibility gate. Design and Interact require
/// at least one live selected node. Code remains selection-independent:
/// it falls back to the active page's children.
pub fn property_panel_visible(&self) -> bool {
true
if self.editor_ui.property_tab == crate::PropertyTab::Code {
return true;
}
if self.selection.set.is_empty() {
return false;
}
let children = self.active_children();
self.selection.set.iter().any(|id| {
find_node(children, id).is_some()
|| crate::instance_override::split_instance_child_anchor(id, &self.doc).is_some()
})
}
/// True when any widget occupies the right rail: currently the
/// selection inspector or active-page inspector.
/// True when a selection inspector or selection-independent Code panel
/// occupies the right rail.
pub fn right_rail_visible(&self) -> bool {
self.property_panel_visible()
}

View file

@ -840,26 +840,29 @@ fn toggle_node_collapsed_none_id_is_noop() {
// --- Panel visibility predicates (Gap 4) ----------------------------
#[test]
fn property_panel_visible_falls_back_to_page_for_empty_or_stale_selection() {
fn property_panel_visible_tracks_selection() {
let mut s = three_rects();
s.clear_selection();
assert!(s.property_panel_visible());
assert!(!s.property_panel_visible());
s.editor_ui.property_tab = crate::PropertyTab::Interact;
assert!(!s.property_panel_visible());
s.editor_ui.property_tab = crate::PropertyTab::Design;
s.set_single_selection(NodeId::new("n1"));
assert!(s.property_panel_visible());
// A stale selection falls back to the active-page inspector.
// A selection of an id that does not resolve is not visible.
s.set_single_selection(NodeId::new("nope"));
assert!(s.property_panel_visible());
assert!(!s.property_panel_visible());
}
#[test]
fn right_rail_visible_for_page_or_selection_inspector() {
fn right_rail_visible_true_on_selection_only() {
let mut s = three_rects();
s.clear_selection();
// No selection → active-page inspector.
assert!(s.right_rail_visible());
// No selection → hidden.
assert!(!s.right_rail_visible());
// The VariablesPanel is a floating canvas overlay, not a right-rail panel.
s.editor_ui.variables_panel_open = true;
assert!(s.right_rail_visible());
assert!(!s.right_rail_visible());
s.editor_ui.variables_panel_open = false;
// Selection makes it visible.
s.set_single_selection(NodeId::new("n1"));
@ -870,16 +873,16 @@ fn right_rail_visible_for_page_or_selection_inspector() {
fn right_rail_stays_visible_on_code_tab_without_selection() {
let mut s = three_rects();
s.clear_selection();
// Design tab + no selection → active-page properties.
assert!(s.right_rail_visible());
// Design tab + no selection → hidden (baseline).
assert!(!s.right_rail_visible());
// The Code tab is selection-independent (TS falls back to the active
// page's children), so the rail must stay open with no selection.
s.editor_ui.property_tab = crate::PropertyTab::Code;
assert!(s.property_panel_visible());
assert!(s.right_rail_visible());
// Back to Design without a selection → page inspector remains.
// Back to Design without a selection → hidden again.
s.editor_ui.property_tab = crate::PropertyTab::Design;
assert!(s.right_rail_visible());
assert!(!s.right_rail_visible());
}
#[test]

View file

@ -363,29 +363,7 @@ impl PropertyPanel {
None,
));
}
let (page_name, page_background) = match state.doc.pages.as_ref() {
Some(pages) if !pages.is_empty() => {
let index = state.ui.active_page_index.min(pages.len() - 1);
(
pages[index].name.clone(),
pages[index].background_color.clone(),
)
}
_ => ("Page 1".to_string(), None),
};
let mut panel = Self::build_from_snapshot(
state,
NodeSnapshot::empty_for_code_tab(),
op_editor_core::FillType::Solid,
now_ms,
false,
None,
None,
);
panel.page_only = true;
panel.page_name = page_name;
panel.page_background = page_background;
Some(panel)
None
}
/// Selection-driven panel builder — `None` when no selected id

View file

@ -232,7 +232,7 @@ mod tests {
}
#[test]
fn no_selection_page_inspector_preserves_rgba_and_hits_input_and_clear() {
fn no_selection_does_not_implicitly_open_page_inspector() {
let doc = jian_ops_schema::load_str(
r##"{
"version":"1.0.0",
@ -247,27 +247,6 @@ mod tests {
.expect("page fixture")
.value;
let state = op_editor_core::EditorState::from_document(doc);
let panel = PropertyPanel::for_selection(&state).expect("page inspector");
assert!(panel.page_only);
assert_eq!(panel.page_name, "Canvas A");
assert_eq!(panel.page_background.as_deref(), Some("#d7e4f380"));
let panel_rect = Rect::xywh(0.0, 0.0, 280.0, 500.0);
let input = background_input_rect(panel_rect, true);
assert_eq!(
panel.hit_test(
panel_rect,
Point2D::new(input.origin.x + 8.0, input.origin.y + 8.0)
),
Some(PropertyFocus::PageBackgroundHex)
);
let clear = clear_rect(panel_rect, true).unwrap();
assert_eq!(
panel.hit_test_action(
panel_rect,
Point2D::new(clear.origin.x + 8.0, clear.origin.y + 8.0)
),
Some(PropertyPanelAction::ClearPageBackground)
);
assert!(PropertyPanel::for_selection(&state).is_none());
}
}

View file

@ -131,12 +131,9 @@ fn scene_aware_panel_keeps_unbounded_group_aggregate_dimensions() {
}
#[test]
fn for_selection_without_selection_builds_page_inspector() {
fn for_selection_without_selection_returns_none() {
let state = EditorState::new();
let panel = PropertyPanel::for_selection(&state).expect("implicit page inspector");
assert!(panel.page_only);
assert_eq!(panel.page_name, "Page 1");
assert_eq!(panel.page_background, None);
assert!(PropertyPanel::for_selection(&state).is_none());
}
#[test]
@ -164,12 +161,19 @@ fn for_selection_code_tab_builds_panel_without_selection() {
}
#[test]
fn for_selection_design_tab_shows_page_inspector_without_selection() {
fn for_selection_design_tab_still_hides_panel_without_selection() {
let mut state = EditorState::sample();
state.clear_selection();
state.editor_ui.property_tab = PropertyTab::Design;
let panel = PropertyPanel::for_selection(&state).expect("page inspector");
assert!(panel.page_only);
assert!(PropertyPanel::for_selection(&state).is_none());
}
#[test]
fn for_selection_interact_tab_still_hides_panel_without_selection() {
let mut state = EditorState::sample();
state.clear_selection();
state.editor_ui.property_tab = PropertyTab::Interact;
assert!(PropertyPanel::for_selection(&state).is_none());
}
#[test]
@ -202,10 +206,10 @@ fn inactive_property_tab_hover_paints_pill_background() {
}
#[test]
fn for_selection_with_stale_selection_falls_back_to_page_inspector() {
fn for_selection_with_stale_selection_returns_none() {
let mut state = EditorState::sample();
state.set_single_selection(NodeId::new("n9999"));
assert!(PropertyPanel::for_selection(&state).is_some_and(|panel| panel.page_only));
assert!(PropertyPanel::for_selection(&state).is_none());
}
#[test]

View file

@ -168,10 +168,10 @@ fn fresh_app_fits_blank_frame_like_ts_canvas_init() {
assert!(app.host.editor_state().selection.is_empty());
let v = app.host.editor_state().viewport;
// The page-properties rail stays visible for background editing; fit uses its narrower canvas.
assert!((v.zoom - 0.68).abs() < 1e-3, "zoom {}", v.zoom);
// No implicit page inspector without a selection: fit uses the full canvas width.
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]
@ -182,9 +182,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.66667).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

@ -498,9 +498,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]

View file

@ -732,19 +732,25 @@ fn toolbar_panel_actions_open_variables_and_design_panels() {
))
);
// The VariablesPanel is a floating canvas overlay, not a right-rail
// tab. With no node selected the rail remains the active-page
// inspector, so toggling Variables must not affect its visibility.
assert!(host.editor_state().right_rail_visible());
// tab. With no node selected the rail stays hidden, so toggling
// Variables must not affect its visibility.
assert!(!host.editor_state().right_rail_visible());
let (canvas_left, _, canvas_width, _) = host.canvas_region(viewport_w, viewport_h);
assert_eq!(
canvas_width,
viewport_w - canvas_left,
"an empty Design selection must not reserve a blank right rail"
);
assert!(host.apply_press(variables_x, variables_y, viewport_w, viewport_h));
assert!(host.editor_state().editor_ui.variables_panel_open);
assert!(host.editor_state().right_rail_visible());
assert!(!host.editor_state().right_rail_visible());
assert_eq!(
host.editor_state().editor_ui.property_tab,
op_editor_core::PropertyTab::Design
);
assert!(host.apply_press(variables_x, variables_y, viewport_w, viewport_h));
assert!(!host.editor_state().editor_ui.variables_panel_open);
assert!(host.editor_state().right_rail_visible());
assert!(!host.editor_state().right_rail_visible());
let (design_x, design_y) = toolbar_action_point_for_test(
&host,
@ -2578,3 +2584,67 @@ fn import_menu_routes_its_two_rows_to_figma_and_html() {
assert!(host.apply_press(button_center.x, button_center.y, vw, vh));
assert!(!host.editor_state().editor_ui.import_menu_open);
}
#[test]
fn right_rail_host_routing_tracks_design_selection_and_code_fallback() {
let mut host = WidgetHostNative::new();
seed(
&mut host,
r#"{"version":"1.0.0","children":[{"type":"rectangle","id":"n-rail","name":"Rail probe","x":0,"y":0,"width":100,"height":50}]}"#,
);
host.editor_state_mut().chat.collapsed = true;
host.editor_state_mut().editor_ui.property_tab = op_editor_core::PropertyTab::Design;
host.editor_state_mut().clear_selection();
let viewport_w = 1200.0;
let viewport_h = 800.0;
let property_width = host.editor_state().editor_ui.property_panel_width;
let old_property_gutter_x = viewport_w - property_width;
let press_y = TOP_BAR_HEIGHT + 180.0;
let (canvas_left, _, canvas_width, _) = host.canvas_region(viewport_w, viewport_h);
assert_eq!(
canvas_left + canvas_width,
viewport_w,
"empty Design selection must release the entire right rail to the canvas"
);
assert_eq!(
host.panel_resize_hover(old_property_gutter_x, press_y, viewport_w),
None,
"the former property-panel gutter must not remain interactive"
);
let right_edge_x = viewport_w - 2.0;
assert!(host.over_canvas(right_edge_x, press_y, viewport_w, viewport_h));
host.apply_press(right_edge_x, press_y, viewport_w, viewport_h);
assert!(
host.marquee_drag.is_some(),
"a blank press at the viewport's right edge must route to the canvas"
);
assert!(!host.is_resizing_panel());
assert!(host.apply_release_with_viewport(viewport_w, viewport_h));
host.editor_state_mut()
.set_single_selection(NodeId::new("n-rail"));
assert!(host.editor_state().right_rail_visible());
let (selected_left, _, selected_width, _) = host.canvas_region(viewport_w, viewport_h);
assert_eq!(selected_left + selected_width, old_property_gutter_x);
assert!(matches!(
host.panel_resize_hover(old_property_gutter_x, press_y, viewport_w),
Some(super::PanelResizeKind::PropertyLeft)
));
assert!(!host.over_canvas(right_edge_x, press_y, viewport_w, viewport_h));
host.editor_state_mut().clear_selection();
host.editor_state_mut().editor_ui.property_tab = op_editor_core::PropertyTab::Code;
assert!(
host.editor_state().right_rail_visible(),
"Code remains selection-independent"
);
let (code_left, _, code_width, _) = host.canvas_region(viewport_w, viewport_h);
assert_eq!(code_left + code_width, old_property_gutter_x);
assert!(matches!(
host.panel_resize_hover(old_property_gutter_x, press_y, viewport_w),
Some(super::PanelResizeKind::PropertyLeft)
));
}

View file

@ -750,6 +750,46 @@ mod viewport_fit_tests {
EditorState::from_document(doc)
}
#[test]
fn design_canvas_size_reserves_the_right_rail_only_when_the_panel_is_visible() {
const VIEWPORT_WIDTH: f32 = 1200.0;
const VIEWPORT_HEIGHT: f32 = 800.0;
const PROPERTY_PANEL_WIDTH: f32 = 280.0;
let mut state = state_with_root(844.0);
state.editor_ui.sidebar_open = false;
state.editor_ui.property_panel_width = PROPERTY_PANEL_WIDTH;
state.editor_ui.property_tab = op_editor_core::PropertyTab::Design;
state.clear_selection();
assert_eq!(
design_canvas_size(&state, VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
(VIEWPORT_WIDTH, VIEWPORT_HEIGHT - TOP_BAR_HEIGHT),
"Design with no selection must use the full canvas width"
);
state.set_single_selection(op_editor_core::NodeId::new("root"));
assert_eq!(
design_canvas_size(&state, VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
(
VIEWPORT_WIDTH - PROPERTY_PANEL_WIDTH,
VIEWPORT_HEIGHT - TOP_BAR_HEIGHT
),
"Design with a live selection must reserve the property rail"
);
state.clear_selection();
state.editor_ui.property_tab = op_editor_core::PropertyTab::Code;
assert_eq!(
design_canvas_size(&state, VIEWPORT_WIDTH, VIEWPORT_HEIGHT),
(
VIEWPORT_WIDTH - PROPERTY_PANEL_WIDTH,
VIEWPORT_HEIGHT - TOP_BAR_HEIGHT
),
"Code remains selection-independent and must reserve the property rail"
);
}
#[test]
fn growth_past_the_viewport_triggers_refit_and_refit_restores_visibility() {
let mut state = state_with_root(844.0);

View file

@ -66,6 +66,50 @@ fn painted_inside(backend: &CaptureBackend, target: Rect) -> bool {
})
}
#[test]
fn empty_design_selection_reclaims_property_rail_until_a_node_is_selected() {
let mut host = WidgetHost::new();
assert!(host.editor_state.selection.is_empty());
assert_eq!(
host.editor_state.editor_ui.property_tab,
op_editor_core::PropertyTab::Design
);
let property_width = host.editor_state.editor_ui.property_panel_width;
let property_rect = Rect::xywh(
W - property_width,
TOP_BAR_HEIGHT,
property_width,
H - TOP_BAR_HEIGHT,
);
let (canvas_left, _, canvas_width, _) = host.canvas_region(W, H);
assert!(
(canvas_left + canvas_width - W).abs() < f32::EPSILON,
"an empty Design selection must let the canvas reach the viewport edge"
);
let mut empty_backend = CaptureBackend::default();
host.paint_editor(&mut empty_backend, W, H);
assert!(
!painted_inside(&empty_backend, property_rect),
"an empty Design selection must not paint the PropertyPanel rail"
);
host.editor_state.set_single_selection(NodeId::new("n10"));
let (selected_left, _, selected_width, _) = host.canvas_region(W, H);
assert!(
(selected_left + selected_width - property_rect.origin.x).abs() < f32::EPSILON,
"a live selection must reserve exactly the PropertyPanel rail"
);
let mut selected_backend = CaptureBackend::default();
host.paint_editor(&mut selected_backend, W, H);
assert!(
painted_inside(&selected_backend, property_rect),
"a live selection must paint the PropertyPanel rail"
);
}
fn seed_layer_doc(host: &mut WidgetHost) {
let doc = jian_ops_schema::load_str(
r#"{"version":"1.0.0","children":[