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
@@ -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()?;@@ -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(),@@ -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();