use crate::panes::{FloatingPanes, TiledPanes}; use std::cell::RefCell; use std::collections::BTreeMap; use std::rc::Rc; use zellij_utils::{ input::layout::{ FloatingPaneLayout, LayoutConstraint, SwapFloatingLayout, SwapTiledLayout, TiledPaneLayout, }, pane_size::{PaneGeom, Size}, }; #[derive(Clone, Debug, Default)] pub struct SwapLayouts { swap_tiled_layouts: Vec, swap_floating_layouts: Vec, current_floating_layout_position: usize, current_tiled_layout_position: usize, is_floating_damaged: bool, is_tiled_damaged: bool, /// Positions of layouts synthesized for the responsive collapse. synthesized_tiled_positions: Vec, display_area: Rc>, // includes all panes (including eg. the status bar and tab bar in the default layout) } impl SwapLayouts { pub fn new( swap_layouts: (Vec, Vec), display_area: Rc>, ) -> Self { let display_area = display_area.clone(); SwapLayouts { swap_tiled_layouts: swap_layouts.0, swap_floating_layouts: swap_layouts.1, is_floating_damaged: false, is_tiled_damaged: false, display_area, ..Default::default() } } pub fn set_base_layout(&mut self, layout: (TiledPaneLayout, Vec)) { let mut base_swap_tiled_layout = BTreeMap::new(); let mut base_swap_floating_layout = BTreeMap::new(); let tiled_panes_count = layout.0.pane_count(); let floating_panes_count = layout.1.len(); // we set ExactPanes to the current panes in the layout, because the base layout is not // intended to be progressive - i.e. to have additional panes added to it // we also don't want it to be applied for less than the expected amount of panes, because // then unintended things can happen // we still want to keep it around in case we'd like to swap layouts without adding panes base_swap_tiled_layout.insert(LayoutConstraint::ExactPanes(tiled_panes_count), layout.0); base_swap_floating_layout .insert(LayoutConstraint::ExactPanes(floating_panes_count), layout.1); self.swap_tiled_layouts .insert(0, (base_swap_tiled_layout, Some("BASE".into()))); self.swap_floating_layouts .insert(0, (base_swap_floating_layout, Some("BASE".into()))); self.current_tiled_layout_position = 0; self.current_floating_layout_position = 0; } pub fn set_swap_tiled_layouts(&mut self, new_swap_tiled_layouts: Vec) { self.swap_tiled_layouts = new_swap_tiled_layouts; self.current_tiled_layout_position = 0; } pub fn set_swap_floating_layouts( &mut self, new_swap_floating_layouts: Vec, ) { self.swap_floating_layouts = new_swap_floating_layouts; self.current_floating_layout_position = 0; } pub fn set_is_floating_damaged(&mut self) { self.is_floating_damaged = true; } pub fn set_is_tiled_damaged(&mut self) { self.is_tiled_damaged = true; } pub fn reset_floating_damage(&mut self) { self.is_floating_damaged = false; } pub fn is_floating_damaged(&self) -> bool { self.is_floating_damaged } pub fn is_tiled_damaged(&self) -> bool { self.is_tiled_damaged } /// Append a synthesized "flatten into one stack" layout under `name`, and /// return its position. /// /// A custom layout file only gets swap layouts from a hand-written sibling /// `.swap.kdl`, so most layouts have none and the responsive /// collapse has nothing to select. Rather than require that file, synthesize /// the one layout the collapse actually needs. /// /// Appended rather than inserted so it cannot shift the position of the BASE /// layout at 0, which is what a collapse restores back to. pub fn append_synthesized_stack_layout(&mut self, name: &str) -> usize { // MinPanes(2) because a "stack" of one pane is just that pane, and // collapsing to it would be a no-op that still counted as collapsed self.append_synthesized_layout( name, LayoutConstraint::MinPanes(2), TiledPaneLayout::single_stack(), ) } /// Append a synthesized "spread into a grid" layout, and return its position. /// /// The way back out of a collapse when the layout that was active before it /// no longer applies -- typically BASE, which is pinned to the pane count it /// was written with, so adding a single pane leaves the tab with nothing to /// expand into and stuck stacked. pub fn append_synthesized_grid_layout(&mut self, name: &str) -> usize { // NoConstraint: this is the fallback, so it must never be the thing that // fails to apply. Feasibility is still checked against the display area. self.append_synthesized_layout( name, LayoutConstraint::NoConstraint, TiledPaneLayout::single_grid(), ) } fn append_synthesized_layout( &mut self, name: &str, constraint: LayoutConstraint, layout: TiledPaneLayout, ) -> usize { if let Some(existing) = self.position_of_tiled_layout_named(name) { return existing; } let mut constraints = BTreeMap::new(); constraints.insert(constraint, layout); self.swap_tiled_layouts .push((constraints, Some(name.to_string()))); let position = self.swap_tiled_layouts.len() - 1; self.synthesized_tiled_positions.push(position); position } /// Whether this tab has any tiled swap layout the USER defined. /// /// "BASE" is inserted from the tab's own layout, and the synthesized stack is /// ours, so neither says anything about user intent. Matters because the /// damaged flag means two different things: with user swap layouts it tracks /// "the arrangement was changed by hand", but with none it is set on the /// first pane added -- relayout finds nothing applicable and gives up -- and /// then never clears, which would disable the responsive collapse entirely /// for exactly the bare layouts it is most useful for. pub fn has_user_defined_tiled_layouts(&self) -> bool { self.swap_tiled_layouts .iter() .enumerate() .any(|(position, (_constraints, name))| { !self.synthesized_tiled_positions.contains(&position) && name.as_deref() != Some("BASE") }) } /// Position of the swap tiled layout with this name, if any. /// /// Used by the responsive-layout switch, which selects a layout BY NAME /// rather than by cycling. Deliberately name-based: adding a size variant to /// LayoutConstraint would change a type that crosses both the plugin /// protobuf API and the client/server contract, and the committed prost /// assets cannot be regenerated here (no protoc). pub fn position_of_tiled_layout_named(&self, name: &str) -> Option { self.swap_tiled_layouts .iter() .position(|(_constraints, layout_name)| { layout_name.as_deref().map(str::trim) == Some(name.trim()) }) } pub fn current_tiled_layout_position(&self) -> usize { self.current_tiled_layout_position } /// Jump straight to a swap layout position, instead of cycling to it. pub fn set_tiled_layout_position(&mut self, position: usize) { if position < self.swap_tiled_layouts.len() { self.current_tiled_layout_position = position; } } /// The layout at a given position, resolved against the current pane count /// and display area. /// /// Mirrors the per-position body of swap_tiled_panes (same constraint check, /// same position_panes_in_space feasibility check) but WITHOUT the cycling, /// so a specific layout can be selected directly. pub fn tiled_layout_at( &self, position: usize, tiled_panes: &TiledPanes, ) -> Option { let swap_layout = self.swap_tiled_layouts.iter().nth(position)?; for (constraint, layout) in swap_layout.0.iter() { if self.state_fits_tiled_panes_constraint(constraint, tiled_panes) { let display_area = self.display_area.borrow(); let pane_count = tiled_panes.visible_panes_count(); let display_area = PaneGeom::from(&*display_area); if layout .position_panes_in_space(&display_area, Some(pane_count), false, true) .is_ok() { return Some(layout.clone()); } } } None } pub fn tiled_layout_info(&self) -> (Option, bool) { // (swap_layout_name, is_swap_layout_dirty) match self .swap_tiled_layouts .iter() .nth(self.current_tiled_layout_position) { Some(current_tiled_layout) => ( current_tiled_layout.1.clone().or_else(|| { Some(format!( "Layout #{}", self.current_tiled_layout_position + 1 )) }), self.is_tiled_damaged, ), None => (None, self.is_tiled_damaged), } } pub fn floating_layout_info(&self) -> (Option, bool) { // (swap_layout_name, is_swap_layout_dirty) match self .swap_floating_layouts .iter() .nth(self.current_floating_layout_position) { Some(current_floating_layout) => ( current_floating_layout.1.clone().or_else(|| { Some(format!( "Layout #{}", self.current_floating_layout_position + 1 )) }), self.is_floating_damaged, ), None => (None, self.is_floating_damaged), } } pub fn swap_floating_panes( &mut self, floating_panes: &FloatingPanes, search_backwards: bool, ) -> Option> { if self.swap_floating_layouts.is_empty() { return None; } let initial_position = self.current_floating_layout_position; macro_rules! progress_layout { () => {{ if search_backwards { if self.current_floating_layout_position == 0 { self.current_floating_layout_position = self.swap_floating_layouts.len().saturating_sub(1); } else { self.current_floating_layout_position -= 1; } } else { self.current_floating_layout_position += 1; } }}; } if !self.is_floating_damaged && self .swap_floating_layouts .iter() .nth(self.current_floating_layout_position) .is_some() { progress_layout!(); } self.is_floating_damaged = false; loop { match self .swap_floating_layouts .iter() .nth(self.current_floating_layout_position) { Some(swap_layout) => { for (constraint, layout) in swap_layout.0.iter() { if self.state_fits_floating_panes_constraint(constraint, floating_panes) { return Some(layout.clone()); }; } progress_layout!(); }, None => { self.current_floating_layout_position = 0; }, }; if self.current_floating_layout_position == initial_position { break; } } None } fn state_fits_tiled_panes_constraint( &self, constraint: &LayoutConstraint, tiled_panes: &TiledPanes, ) -> bool { match constraint { LayoutConstraint::MaxPanes(max_panes) => { tiled_panes.visible_panes_count() <= *max_panes }, LayoutConstraint::MinPanes(min_panes) => { tiled_panes.visible_panes_count() >= *min_panes }, LayoutConstraint::ExactPanes(pane_count) => { tiled_panes.visible_panes_count() == *pane_count }, LayoutConstraint::NoConstraint => true, } } fn state_fits_floating_panes_constraint( &self, constraint: &LayoutConstraint, floating_panes: &FloatingPanes, ) -> bool { match constraint { LayoutConstraint::MaxPanes(max_panes) => { floating_panes.visible_panes_count() <= *max_panes }, LayoutConstraint::MinPanes(min_panes) => { floating_panes.visible_panes_count() >= *min_panes }, LayoutConstraint::ExactPanes(pane_count) => { floating_panes.visible_panes_count() == *pane_count }, LayoutConstraint::NoConstraint => true, } } pub fn swap_tiled_panes( &mut self, tiled_panes: &TiledPanes, search_backwards: bool, ) -> Option { if self.swap_tiled_layouts.is_empty() { return None; } macro_rules! progress_layout { () => {{ if search_backwards { if self.current_tiled_layout_position == 0 { self.current_tiled_layout_position = self.swap_tiled_layouts.len().saturating_sub(1); } else { self.current_tiled_layout_position -= 1; } } else { self.current_tiled_layout_position += 1; } }}; } let initial_position = self.current_tiled_layout_position; if !self.is_tiled_damaged && self .swap_tiled_layouts .iter() .nth(self.current_tiled_layout_position) .is_some() { progress_layout!(); } self.is_tiled_damaged = false; loop { match self .swap_tiled_layouts .iter() .nth(self.current_tiled_layout_position) { Some(swap_layout) => { for (constraint, layout) in swap_layout.0.iter() { if self.state_fits_tiled_panes_constraint(constraint, tiled_panes) { let focus_layout_if_not_focused = true; let display_area = self.display_area.borrow(); // TODO: reuse the assets from position_panes_in_space here? let pane_count = tiled_panes.visible_panes_count(); let display_area = PaneGeom::from(&*display_area); if layout .position_panes_in_space( &display_area, Some(pane_count), false, focus_layout_if_not_focused, ) .is_ok() { return Some(layout.clone()); } }; } progress_layout!(); }, None => { self.current_tiled_layout_position = 0; }, }; if self.current_tiled_layout_position == initial_position { break; } } None } }