collective

Browse / github.com/storytold/craft-libs / patch

Docking: hide 'Move to group' when no panel accepts the move (lightcraft#1768)

Docking: hide 'Move to group' when no panel accepts the move (lightcraft#1768) Fixes storytold/lightcraft#1768. A movable panel's tab context menu always offered **Move to group**, even when the layout held no other panel that accepts tabs — with only one panel open (e.g. the sole right panel in LightCraft), it opened an empty submenu. `Layout` gains `move_targets()` — the same filter the menu applies (every other panel whose permissions `accepts_tabs`) — and the menu item renders only when the list is non-empty. A unit test pins the lone-panel and protected-anchor cases. LightCraft picks this up when its `craft-ui` rev is bumped.

Status
open
Score
0.00
Adopters
0 other operators
Author
NibrasAz7 (imported from pull request #130, open)
Base
457a078809690994b22debdffb703ff36d3129df
Size
+25 −3 in 3 files
Review
pending
Shared
2026-10-28
Id
817b2362bfdd8585c03a5a13

How it went

No other operator has reported using it yet.

Diff

raw · crates/ui/src/docking/model.rs crates/ui/src/docking/surface.rs crates/ui/src/docking/tests.rs

diff --git a/crates/ui/src/docking/model.rs b/crates/ui/src/docking/model.rs--- a/crates/ui/src/docking/model.rs+++ b/crates/ui/src/docking/model.rs@@ -287,6 +287,12 @@ impl<P: Clone + Eq + Hash> Layout<P> {         self.panels().contains(&panel)     } +    /// The panels `panel` can join as a tab: every other panel whose permissions accept tabs.+    /// A "Move to group" menu with an empty answer has nothing to offer and stays hidden.+    pub fn move_targets(&self, panel: &P, permissions: impl Fn(&P) -> Permissions) -> Vec<&P> {+        self.panels().into_iter().filter(|target| *target != panel && permissions(target).accepts_tabs).collect()+    }+     /// Capture a panel's nearest surviving group/split neighbor before hiding it.     pub fn location(&self, panel: &P) -> Result<Location<P>, DockError> {         self.validate()?;diff --git a/crates/ui/src/docking/surface.rs b/crates/ui/src/docking/surface.rs--- a/crates/ui/src/docking/surface.rs+++ b/crates/ui/src/docking/surface.rs@@ -705,12 +705,12 @@ impl<P: Clone + Eq + Hash + Debug + Send + Sync + 'static> Renderer<'_, P> {                     ui.close();                 }             }-            if policy.movable {+            if policy.movable && !self.layout.move_targets(panel, self.permissions).is_empty() {                 let text = self.content.action_label(ui, &self.style.move_label, &self.style.font, self.style.text);                 let response = ui.menu_button(text, |ui| {                     egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {-                        for target in self.layout.panels() {-                            if target != panel && (self.permissions)(target).accepts_tabs && self.menu_label(ui, target).clicked() {+                        for target in self.layout.move_targets(panel, self.permissions) {+                            if self.menu_label(ui, target).clicked() {                                 self.output.actions.push(Action::Move {                                     panel: panel.clone(),                                     anchor: target.clone(),diff --git a/crates/ui/src/docking/tests.rs b/crates/ui/src/docking/tests.rs--- a/crates/ui/src/docking/tests.rs+++ b/crates/ui/src/docking/tests.rs@@ -51,6 +51,22 @@ fn detach_redock_merge_and_collapse_empty_source_containers() {     assert_eq!(layout.panels().len(), 6); } +#[test]+fn move_targets_skip_the_panel_itself_and_protected_anchors() {+    let layout = sample();+    let all = |_: &u32| Permissions::default();+    let targets = layout.move_targets(&1, all);+    assert_eq!(targets.len(), 5);+    assert!(!targets.contains(&&1));+    let guarded = |p: &u32| if *p == 3 { Permissions::PROTECTED } else { Permissions::default() };+    assert!(!layout.move_targets(&1, guarded).contains(&&3));++    // A lone panel has nowhere to go — its "Move to group" menu hides+    // (storytold/lightcraft#1768).+    let single = Layout { root: Some(tabs(&[1], 0)), floating: Vec::new() };+    assert!(single.move_targets(&1, all).is_empty());+}+ #[test] fn invalid_moves_are_atomic_including_after_a_source_would_have_been_removed() {     let layout = sample();