From 6ba19d3ea2e0c96dba2e6232daa124e7a7379819 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Thu, 6 Mar 2025 19:30:16 -0800 Subject: [PATCH] feat(wm): add locked containers per workspace This commit adds the concept of locked container indexes to komorebi workspaces. When a container index is locked, it can only be displaced by manual user actual - usually when another container is moved there, and when this happens, that container becomes the locked container. In the locked state, the container at the locked index should never be displaced by new windows opening or existing windows around it being closed. When the total number of containers on a workspace falls below the number of the locked index, the locked index will be removed. A locked index can be identified by a special border color linked to the new WindowKind::UnfocusedLocked variant. The implementation of locked container indexes is backed by a new data structure called a LockedDeque, which is a VecDeque with an auxiliary HashSet which keeps track of locked indices. A new komorebic command "toggle-lock" has been added to support programmatic use of this feature, as well as the LockMonitorWorkspaceContainer and UnlockMonitorWorkspaceContainer SocketMessage variants which can be used by status bars. --- komorebi/src/border_manager/border.rs | 1 + komorebi/src/border_manager/mod.rs | 15 +- komorebi/src/core/mod.rs | 4 + komorebi/src/lib.rs | 1 + komorebi/src/locked_deque.rs | 367 ++++++++++++++++++++++++++ komorebi/src/process_command.rs | 40 +++ komorebi/src/static_config.rs | 6 + komorebi/src/theme_manager.rs | 17 ++ komorebi/src/window_manager.rs | 15 ++ komorebi/src/workspace.rs | 95 ++++++- komorebic/src/main.rs | 5 + schema.json | 54 ++++ 12 files changed, 613 insertions(+), 7 deletions(-) create mode 100644 komorebi/src/locked_deque.rs diff --git a/komorebi/src/border_manager/border.rs b/komorebi/src/border_manager/border.rs index 017b0e71..2fd26c94 100644 --- a/komorebi/src/border_manager/border.rs +++ b/komorebi/src/border_manager/border.rs @@ -272,6 +272,7 @@ impl Border { WindowKind::Monocle, WindowKind::Unfocused, WindowKind::Floating, + WindowKind::UnfocusedLocked, ] { let color = window_kind_colour(window_kind); let color = D2D1_COLOR_F { diff --git a/komorebi/src/border_manager/mod.rs b/komorebi/src/border_manager/mod.rs index 310ecf12..326e3d99 100644 --- a/komorebi/src/border_manager/mod.rs +++ b/komorebi/src/border_manager/mod.rs @@ -48,6 +48,8 @@ lazy_static! { AtomicU32::new(u32::from(Colour::Rgb(Rgb::new(66, 165, 245)))); pub static ref UNFOCUSED: AtomicU32 = AtomicU32::new(u32::from(Colour::Rgb(Rgb::new(128, 128, 128)))); + pub static ref UNFOCUSED_LOCKED: AtomicU32 = + AtomicU32::new(u32::from(Colour::Rgb(Rgb::new(158, 8, 8)))); pub static ref MONOCLE: AtomicU32 = AtomicU32::new(u32::from(Colour::Rgb(Rgb::new(255, 51, 153)))); pub static ref STACK: AtomicU32 = AtomicU32::new(u32::from(Colour::Rgb(Rgb::new(0, 165, 66)))); @@ -149,6 +151,7 @@ pub fn destroy_all_borders() -> color_eyre::Result<()> { fn window_kind_colour(focus_kind: WindowKind) -> u32 { match focus_kind { WindowKind::Unfocused => UNFOCUSED.load(Ordering::Relaxed), + WindowKind::UnfocusedLocked => UNFOCUSED_LOCKED.load(Ordering::Relaxed), WindowKind::Single => FOCUSED.load(Ordering::Relaxed), WindowKind::Stack => STACK.load(Ordering::Relaxed), WindowKind::Monocle => MONOCLE.load(Ordering::Relaxed), @@ -229,7 +232,11 @@ pub fn handle_notifications(wm: Arc>) -> color_eyre::Result let window_kind = if idx != ws.focused_container_idx() || monitor_idx != focused_monitor_idx { - WindowKind::Unfocused + if ws.locked_containers().contains(&idx) { + WindowKind::UnfocusedLocked + } else { + WindowKind::Unfocused + } } else if c.windows().len() > 1 { WindowKind::Stack } else { @@ -493,7 +500,11 @@ pub fn handle_notifications(wm: Arc>) -> color_eyre::Result || monitor_idx != focused_monitor_idx || focused_window_hwnd != foreground_window { - WindowKind::Unfocused + if ws.locked_containers().contains(&idx) { + WindowKind::UnfocusedLocked + } else { + WindowKind::Unfocused + } } else if c.windows().len() > 1 { WindowKind::Stack } else { diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 0cb88d72..d86e6435 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -84,6 +84,9 @@ pub enum SocketMessage { PromoteFocus, PromoteWindow(OperationDirection), EagerFocus(String), + LockMonitorWorkspaceContainer(usize, usize, usize), + UnlockMonitorWorkspaceContainer(usize, usize, usize), + ToggleLock, ToggleFloat, ToggleMonocle, ToggleMaximize, @@ -312,6 +315,7 @@ pub enum WindowKind { Monocle, #[default] Unfocused, + UnfocusedLocked, Floating, } diff --git a/komorebi/src/lib.rs b/komorebi/src/lib.rs index 4e6ecc81..774e027f 100644 --- a/komorebi/src/lib.rs +++ b/komorebi/src/lib.rs @@ -9,6 +9,7 @@ pub mod colour; pub mod container; pub mod core; pub mod focus_manager; +pub mod locked_deque; pub mod monitor; pub mod monitor_reconciliator; pub mod process_command; diff --git a/komorebi/src/locked_deque.rs b/komorebi/src/locked_deque.rs new file mode 100644 index 00000000..6586b647 --- /dev/null +++ b/komorebi/src/locked_deque.rs @@ -0,0 +1,367 @@ +use std::collections::HashSet; +use std::collections::VecDeque; + +pub struct LockedDeque<'a, T> { + deque: &'a mut VecDeque, + locked_indices: &'a mut HashSet, +} + +impl<'a, T: Clone + PartialEq> LockedDeque<'a, T> { + pub fn new(deque: &'a mut VecDeque, locked_indices: &'a mut HashSet) -> Self { + Self { + deque, + locked_indices, + } + } + + pub fn insert(&mut self, index: usize, value: T) -> usize { + insert_respecting_locks(self.deque, self.locked_indices, index, value) + } + + pub fn remove(&mut self, index: usize) -> Option { + remove_respecting_locks(self.deque, self.locked_indices, index) + } +} + +fn insert_respecting_locks( + deque: &mut VecDeque, + locked_indices: &mut HashSet, + index: usize, + value: T, +) -> usize { + if deque.is_empty() { + deque.push_back(value); + return 0; + } + + // Find the actual insertion point (first unlocked index >= requested index) + let mut actual_index = index; + while actual_index < deque.len() && locked_indices.contains(&actual_index) { + actual_index += 1; + } + + // If we're inserting at the end, just push_back + if actual_index >= deque.len() { + deque.push_back(value); + return actual_index; + } + + // Store original values at locked positions + let locked_values: Vec<(usize, T)> = locked_indices + .iter() + .filter_map(|&idx| { + if idx < deque.len() { + Some((idx, deque[idx].clone())) + } else { + None + } + }) + .collect(); + + // Store all original values + let original_values: Vec = deque.iter().cloned().collect(); + + // Create a new deque with the correct final size + let mut new_deque = VecDeque::with_capacity(deque.len() + 1); + for _ in 0..deque.len() + 1 { + new_deque.push_back(value.clone()); // Temporary placeholder + } + + // First, place the new value at the insertion point + new_deque[actual_index] = value.clone(); + + // Then, place all locked values at their original positions + for (idx, val) in &locked_values { + new_deque[*idx] = val.clone(); + } + + // Now, fill in all remaining positions with values from the original deque, + // accounting for the shift caused by insertion + let mut orig_idx = 0; + #[allow(clippy::needless_range_loop)] + for new_idx in 0..new_deque.len() { + // Skip positions that are already filled (insertion point and locked positions) + if new_idx == actual_index || locked_indices.contains(&new_idx) { + continue; + } + + // Skip original elements that were at locked positions + while orig_idx < original_values.len() && locked_indices.contains(&orig_idx) { + orig_idx += 1; + } + + // If we still have original elements to place + if orig_idx < original_values.len() { + new_deque[new_idx] = original_values[orig_idx].clone(); + orig_idx += 1; + } + } + + // Update the original deque + *deque = new_deque; + + actual_index +} + +fn remove_respecting_locks( + deque: &mut VecDeque, + locked_indices: &mut HashSet, + index: usize, +) -> Option { + if index >= deque.len() { + return None; + } + + let removed = deque[index].clone(); + + // If removing a locked index, just remove it and unlock + if locked_indices.contains(&index) { + locked_indices.remove(&index); + deque.remove(index); + + // Update locked indices after the removal point + let new_locked: HashSet = locked_indices + .iter() + .map(|&idx| if idx > index { idx - 1 } else { idx }) + .collect(); + *locked_indices = new_locked; + + return Some(removed); + } + + // Let's build a new deque with the correct order + let mut result = VecDeque::with_capacity(deque.len() - 1); + + // 1. First include all elements before the removal index + #[allow(clippy::needless_range_loop)] + for i in 0..index { + result.push_back(deque[i].clone()); + } + + // 2. Then for each element after the removal index + #[allow(clippy::needless_range_loop)] + for i in (index + 1)..deque.len() { + // If the previous index was locked, we need to swap this element + // with the previous one in our result + if locked_indices.contains(&(i - 1)) { + // Insert this element before the locked element + if !result.is_empty() { + let locked_element = result.pop_back().unwrap(); + result.push_back(deque[i].clone()); + result.push_back(locked_element); + } else { + // This shouldn't happen with valid inputs + result.push_back(deque[i].clone()); + } + } else { + // Normal case, just add the element + result.push_back(deque[i].clone()); + } + } + + // Update the original deque + *deque = result; + + // Important: Keep the same locked indices (don't update them) + // Only remove any that are now out of bounds + locked_indices.retain(|&idx| idx < deque.len()); + + Some(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::collections::VecDeque; + + #[test] + fn test_insert_respecting_locks() { + // Test case 1: Basic insertion with locked index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + // Insert at index 0, should shift elements while keeping index 2 locked + insert_respecting_locks(&mut deque, &mut locked, 0, 99); + assert_eq!(deque, VecDeque::from(vec![99, 0, 2, 1, 3, 4])); + // Element '2' remains at index 2, element '1' that was at index 1 is now at index 3 + } + + // Test case 2: Insert at a locked index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + // Try to insert at locked index 2, should insert at index 3 instead + let actual_index = insert_respecting_locks(&mut deque, &mut locked, 2, 99); + assert_eq!(actual_index, 3); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 99, 3, 4])); + } + + // Test case 3: Multiple locked indices + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(1); // Lock index 1 + locked.insert(3); // Lock index 3 + + // Insert at index 0, should maintain locked indices + insert_respecting_locks(&mut deque, &mut locked, 0, 99); + assert_eq!(deque, VecDeque::from(vec![99, 1, 0, 3, 2, 4])); + // Elements '1' and '3' remain at indices 1 and 3 + } + + // Test case 4: Insert at end + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + // Insert at end of deque + let actual_index = insert_respecting_locks(&mut deque, &mut locked, 5, 99); + assert_eq!(actual_index, 5); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4, 99])); + } + + // Test case 5: Empty deque + { + let mut deque = VecDeque::new(); + let mut locked = HashSet::new(); + + // Insert into empty deque + let actual_index = insert_respecting_locks(&mut deque, &mut locked, 0, 99); + assert_eq!(actual_index, 0); + assert_eq!(deque, VecDeque::from(vec![99])); + } + + // Test case 6: All indices locked + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + for i in 0..5 { + locked.insert(i); // Lock all indices + } + + // Try to insert at index 2, should insert at the end + let actual_index = insert_respecting_locks(&mut deque, &mut locked, 2, 99); + assert_eq!(actual_index, 5); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4, 99])); + } + + // Test case 7: Consecutive locked indices + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + locked.insert(3); // Lock index 3 + + // Insert at index 1, should maintain consecutive locked indices + insert_respecting_locks(&mut deque, &mut locked, 1, 99); + assert_eq!(deque, VecDeque::from(vec![0, 99, 2, 3, 1, 4])); + // Elements '2' and '3' remain at indices 2 and 3 + } + } + + #[test] + fn test_remove_respecting_locks() { + // Test case 1: Remove a non-locked index before a locked index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 0); + assert_eq!(removed, Some(0)); + assert_eq!(deque, VecDeque::from(vec![1, 3, 2, 4])); + assert!(locked.contains(&2)); // Index 2 should still be locked + } + + // Test case 2: Remove a locked index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 2); + assert_eq!(removed, Some(2)); + assert_eq!(deque, VecDeque::from(vec![0, 1, 3, 4])); + assert!(!locked.contains(&2)); // Index 2 should be unlocked + } + + // Test case 3: Remove an index after a locked index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(1); // Lock index 1 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 3); + assert_eq!(removed, Some(3)); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 4])); + assert!(locked.contains(&1)); // Index 1 should still be locked + } + + // Test case 4: Multiple locked indices + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(1); // Lock index 1 + locked.insert(3); // Lock index 3 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 0); + assert_eq!(removed, Some(0)); + assert_eq!(deque, VecDeque::from(vec![2, 1, 4, 3])); + assert!(locked.contains(&1) && locked.contains(&3)); // Both indices should still be locked + } + + // Test case 5: Remove the last element + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 4); + assert_eq!(removed, Some(4)); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3])); + assert!(locked.contains(&2)); // Index 2 should still be locked + } + + // Test case 6: Invalid index + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 10); + assert_eq!(removed, None); + assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4])); // Deque unchanged + assert!(locked.contains(&2)); // Lock unchanged + } + + // Test case 7: Remove enough elements to make a locked index invalid + { + let mut deque = VecDeque::from(vec![0, 1, 2]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + + remove_respecting_locks(&mut deque, &mut locked, 0); + assert_eq!(deque, VecDeque::from(vec![1, 2])); + assert!(!locked.contains(&2)); // Index 2 should now be invalid + } + + // Test case 8: Removing an element before multiple locked indices + { + let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4, 5]); + let mut locked = HashSet::new(); + locked.insert(2); // Lock index 2 + locked.insert(4); // Lock index 4 + + let removed = remove_respecting_locks(&mut deque, &mut locked, 1); + assert_eq!(removed, Some(1)); + assert_eq!(deque, VecDeque::from(vec![0, 3, 2, 5, 4])); + assert!(locked.contains(&2) && locked.contains(&4)); // Both indices should still be locked + } + } +} diff --git a/komorebi/src/process_command.rs b/komorebi/src/process_command.rs index 54ee3e49..868c7e68 100644 --- a/komorebi/src/process_command.rs +++ b/komorebi/src/process_command.rs @@ -1,4 +1,5 @@ use color_eyre::eyre::anyhow; +use color_eyre::eyre::OptionExt; use color_eyre::Result; use miow::pipe::connect; use net2::TcpStreamExt; @@ -359,6 +360,41 @@ impl WindowManager { SocketMessage::Minimize => { Window::from(WindowsApi::foreground_window()?).minimize(); } + SocketMessage::LockMonitorWorkspaceContainer( + monitor_idx, + workspace_idx, + container_idx, + ) => { + let monitor = self + .monitors_mut() + .get_mut(monitor_idx) + .ok_or_eyre("no monitor at the given index")?; + + let workspace = monitor + .workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("no workspace at the given index")?; + + workspace.locked_containers.insert(container_idx); + } + SocketMessage::UnlockMonitorWorkspaceContainer( + monitor_idx, + workspace_idx, + container_idx, + ) => { + let monitor = self + .monitors_mut() + .get_mut(monitor_idx) + .ok_or_eyre("no monitor at the given index")?; + + let workspace = monitor + .workspaces_mut() + .get_mut(workspace_idx) + .ok_or_eyre("no workspace at the given index")?; + + workspace.locked_containers.remove(&container_idx); + } + SocketMessage::ToggleLock => self.toggle_lock()?, SocketMessage::ToggleFloat => self.toggle_float()?, SocketMessage::ToggleMonocle => self.toggle_monocle()?, SocketMessage::ToggleMaximize => self.toggle_maximize()?, @@ -1783,6 +1819,10 @@ impl WindowManager { WindowKind::Unfocused => { border_manager::UNFOCUSED.store(Rgb::new(r, g, b).into(), Ordering::SeqCst); } + WindowKind::UnfocusedLocked => { + border_manager::UNFOCUSED_LOCKED + .store(Rgb::new(r, g, b).into(), Ordering::SeqCst); + } WindowKind::Floating => { border_manager::FLOATING.store(Rgb::new(r, g, b).into(), Ordering::SeqCst); } diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index 432a2be8..ff53a559 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -500,6 +500,9 @@ pub enum KomorebiTheme { /// Border colour when the container is unfocused (default: Base) #[serde(skip_serializing_if = "Option::is_none")] unfocused_border: Option, + /// Border colour when the container is unfocused and locked (default: Red) + #[serde(skip_serializing_if = "Option::is_none")] + unfocused_locked_border: Option, /// Stackbar focused tab text colour (default: Green) #[serde(skip_serializing_if = "Option::is_none")] stackbar_focused_text: Option, @@ -532,6 +535,9 @@ pub enum KomorebiTheme { /// Border colour when the container is unfocused (default: Base01) #[serde(skip_serializing_if = "Option::is_none")] unfocused_border: Option, + /// Border colour when the container is unfocused and locked (default: Base08) + #[serde(skip_serializing_if = "Option::is_none")] + unfocused_locked_border: Option, /// Stackbar focused tab text colour (default: Base0B) #[serde(skip_serializing_if = "Option::is_none")] stackbar_focused_text: Option, diff --git a/komorebi/src/theme_manager.rs b/komorebi/src/theme_manager.rs index eb4790d7..be70a715 100644 --- a/komorebi/src/theme_manager.rs +++ b/komorebi/src/theme_manager.rs @@ -76,6 +76,7 @@ pub fn handle_notifications() -> color_eyre::Result<()> { monocle_border, floating_border, unfocused_border, + unfocused_locked_border, stackbar_focused_text, stackbar_unfocused_text, stackbar_background, @@ -87,6 +88,7 @@ pub fn handle_notifications() -> color_eyre::Result<()> { monocle_border, floating_border, unfocused_border, + unfocused_locked_border, stackbar_focused_text, stackbar_unfocused_text, stackbar_background, @@ -112,6 +114,10 @@ pub fn handle_notifications() -> color_eyre::Result<()> { .unwrap_or(komorebi_themes::CatppuccinValue::Base) .color32(name.as_theme()); + let unfocused_locked_border = unfocused_locked_border + .unwrap_or(komorebi_themes::CatppuccinValue::Red) + .color32(name.as_theme()); + let stackbar_focused_text = stackbar_focused_text .unwrap_or(komorebi_themes::CatppuccinValue::Green) .color32(name.as_theme()); @@ -130,6 +136,7 @@ pub fn handle_notifications() -> color_eyre::Result<()> { monocle_border, floating_border, unfocused_border, + unfocused_locked_border, stackbar_focused_text, stackbar_unfocused_text, stackbar_background, @@ -142,6 +149,7 @@ pub fn handle_notifications() -> color_eyre::Result<()> { monocle_border, floating_border, unfocused_border, + unfocused_locked_border, stackbar_focused_text, stackbar_unfocused_text, stackbar_background, @@ -163,6 +171,10 @@ pub fn handle_notifications() -> color_eyre::Result<()> { .unwrap_or(komorebi_themes::Base16Value::Base01) .color32(*name); + let unfocused_locked_border = unfocused_locked_border + .unwrap_or(komorebi_themes::Base16Value::Base08) + .color32(*name); + let floating_border = floating_border .unwrap_or(komorebi_themes::Base16Value::Base09) .color32(*name); @@ -185,6 +197,7 @@ pub fn handle_notifications() -> color_eyre::Result<()> { monocle_border, floating_border, unfocused_border, + unfocused_locked_border, stackbar_focused_text, stackbar_unfocused_text, stackbar_background, @@ -198,6 +211,10 @@ pub fn handle_notifications() -> color_eyre::Result<()> { border_manager::FLOATING.store(u32::from(Colour::from(floating_border)), Ordering::SeqCst); border_manager::UNFOCUSED .store(u32::from(Colour::from(unfocused_border)), Ordering::SeqCst); + border_manager::UNFOCUSED_LOCKED.store( + u32::from(Colour::from(unfocused_locked_border)), + Ordering::SeqCst, + ); STACKBAR_TAB_BACKGROUND_COLOUR.store( u32::from(Colour::from(stackbar_background)), diff --git a/komorebi/src/window_manager.rs b/komorebi/src/window_manager.rs index 1c677965..5138bcb1 100644 --- a/komorebi/src/window_manager.rs +++ b/komorebi/src/window_manager.rs @@ -342,6 +342,7 @@ impl From<&WindowManager> for State { float_override: workspace.float_override, layer: workspace.layer, globals: workspace.globals, + locked_containers: workspace.locked_containers.clone(), workspace_config: None, }) .collect::>(); @@ -2849,6 +2850,20 @@ impl WindowManager { self.update_focused_workspace(is_floating_window, true) } + #[tracing::instrument(skip(self))] + pub fn toggle_lock(&mut self) -> Result<()> { + let workspace = self.focused_workspace_mut()?; + let index = workspace.focused_container_idx(); + + if workspace.locked_containers().contains(&index) { + workspace.locked_containers_mut().remove(&index); + } else { + workspace.locked_containers_mut().insert(index); + } + + Ok(()) + } + #[tracing::instrument(skip(self))] pub fn float_window(&mut self) -> Result<()> { tracing::info!("floating window"); diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index c1c7d2ed..e9b3aecd 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::collections::VecDeque; use std::fmt::Display; use std::fmt::Formatter; @@ -25,6 +26,7 @@ use crate::core::Rect; use crate::border_manager::BORDER_OFFSET; use crate::border_manager::BORDER_WIDTH; use crate::container::Container; +use crate::locked_deque::LockedDeque; use crate::ring::Ring; use crate::should_act; use crate::stackbar_manager; @@ -90,6 +92,8 @@ pub struct Workspace { pub globals: WorkspaceGlobals, #[getset(get = "pub", get_mut = "pub", set = "pub")] pub layer: WorkspaceLayer, + #[getset(get = "pub", get_mut = "pub", set = "pub")] + pub locked_containers: HashSet, #[serde(skip_serializing_if = "Option::is_none")] #[getset(get = "pub", set = "pub")] pub workspace_config: Option, @@ -139,6 +143,7 @@ impl Default for Workspace { layer: Default::default(), globals: Default::default(), workspace_config: None, + locked_containers: Default::default(), } } } @@ -912,9 +917,10 @@ impl Workspace { .ok_or_else(|| anyhow!("there is no window"))?; if container.windows().is_empty() { - self.containers_mut() - .remove(container_idx) - .ok_or_else(|| anyhow!("there is no container"))?; + let mut locked_containers = self.locked_containers().clone(); + let mut ld = LockedDeque::new(self.containers_mut(), &mut locked_containers); + ld.remove(container_idx); + self.locked_containers = locked_containers; // Whenever a container is empty, we need to remove any resize dimensions for it too if self.resize_dimensions().get(container_idx).is_some() { @@ -1045,7 +1051,7 @@ impl Workspace { } pub fn new_container_for_window(&mut self, window: Window) { - let next_idx = if self.containers().is_empty() { + let mut next_idx = if self.containers().is_empty() { 0 } else { self.focused_container_idx() + 1 @@ -1057,7 +1063,10 @@ impl Workspace { if next_idx > self.containers().len() { self.containers_mut().push_back(container); } else { - self.containers_mut().insert(next_idx, container); + let mut locked_containers = self.locked_containers().clone(); + let mut ld = LockedDeque::new(self.containers_mut(), &mut locked_containers); + next_idx = ld.insert(next_idx, container); + self.locked_containers = locked_containers; } if next_idx > self.resize_dimensions().len() { @@ -1640,6 +1649,82 @@ impl Workspace { mod tests { use super::*; + use crate::container::Container; + use crate::Window; + use std::collections::HashMap; + use std::collections::HashSet; + + #[test] + fn test_locked_containers_with_new_window() { + let mut ws = Workspace::default(); + + let mut state = HashMap::new(); + let mut locked = HashSet::new(); + + // add 3 containers + for i in 0..4 { + let container = Container::default(); + state.insert(i, container.id().to_string()); + ws.add_container_to_back(container); + } + assert_eq!(ws.containers().len(), 4); + + // set index 3 locked + locked.insert(3); + ws.locked_containers = locked; + + // focus container at index 2 + ws.focus_container(2); + + // simulate a new window being launched on this workspace + ws.new_container_for_window(Window::from(123)); + + // new length should be 5, with the focus on the new window at index 4 + assert_eq!(ws.containers().len(), 5); + assert_eq!(ws.focused_container_idx(), 4); + assert_eq!( + ws.focused_container() + .unwrap() + .focused_window() + .unwrap() + .hwnd, + 123 + ); + + // when inserting a new container at index 0, index 3's container should not change + ws.focus_container(0); + ws.new_container_for_window(Window::from(234)); + assert_eq!( + ws.containers()[3].id().to_string(), + state.get(&3).unwrap().to_string() + ); + } + + #[test] + fn test_locked_containers_remove_window() { + let mut ws = Workspace::default(); + + let mut locked = HashSet::new(); + + // add 4 containers + for i in 0..4 { + let mut container = Container::default(); + container.windows_mut().push_back(Window::from(i)); + ws.add_container_to_back(container); + } + assert_eq!(ws.containers().len(), 4); + + // set index 1 locked + locked.insert(1); + ws.locked_containers = locked; + + ws.remove_window(0).unwrap(); + assert_eq!(ws.containers()[0].focused_window().unwrap().hwnd, 2); + // index 1 should still be the same + assert_eq!(ws.containers()[1].focused_window().unwrap().hwnd, 1); + assert_eq!(ws.containers()[2].focused_window().unwrap().hwnd, 3); + } + #[test] fn test_contains_window() { // Create default workspace diff --git a/komorebic/src/main.rs b/komorebic/src/main.rs index 185d6946..581703d2 100644 --- a/komorebic/src/main.rs +++ b/komorebic/src/main.rs @@ -1283,6 +1283,8 @@ enum SubCommand { ToggleMonocle, /// Toggle native maximization for the focused window ToggleMaximize, + /// Toggle a lock for the focused container, ensuring it will not be displaced by any new windows + ToggleLock, /// Restore all hidden windows (debugging command) RestoreWindows, /// Force komorebi to manage the focused window @@ -1947,6 +1949,9 @@ fn main() -> Result<()> { SubCommand::ToggleMaximize => { send_message(&SocketMessage::ToggleMaximize)?; } + SubCommand::ToggleLock => { + send_message(&SocketMessage::ToggleLock)?; + } SubCommand::WorkspaceLayout(arg) => { send_message(&SocketMessage::WorkspaceLayout( arg.monitor, diff --git a/schema.json b/schema.json index 26f6d345..f981dc9d 100644 --- a/schema.json +++ b/schema.json @@ -2211,6 +2211,38 @@ "Mantle", "Crust" ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Red)", + "type": "string", + "enum": [ + "Rosewater", + "Flamingo", + "Pink", + "Mauve", + "Red", + "Maroon", + "Peach", + "Yellow", + "Green", + "Teal", + "Sky", + "Sapphire", + "Blue", + "Lavender", + "Text", + "Subtext1", + "Subtext0", + "Overlay2", + "Overlay1", + "Overlay0", + "Surface2", + "Surface1", + "Surface0", + "Base", + "Mantle", + "Crust" + ] } } }, @@ -2700,6 +2732,28 @@ "Base0E", "Base0F" ] + }, + "unfocused_locked_border": { + "description": "Border colour when the container is unfocused and locked (default: Base08)", + "type": "string", + "enum": [ + "Base00", + "Base01", + "Base02", + "Base03", + "Base04", + "Base05", + "Base06", + "Base07", + "Base08", + "Base09", + "Base0A", + "Base0B", + "Base0C", + "Base0D", + "Base0E", + "Base0F" + ] } } }