diff --git a/docs/common-workflows/initial-window-placement-rules.md b/docs/common-workflows/initial-window-placement-rules.md new file mode 100644 index 00000000..556b56df --- /dev/null +++ b/docs/common-workflows/initial-window-placement-rules.md @@ -0,0 +1,267 @@ +# Initial Window Placement Rules + +By default, when a new window is opened in `komorebi`, it is placed **after the +currently focused container**. The `initial_window_placement_rules` workspace +setting allows you to control where new tiled windows are placed in the container +list. + +This setting only applies when `window_container_behaviour` is set to `Create` +(the default). It does not apply when set to `Append`. + +## Configuration Forms + +The `initial_window_placement_rules` setting can be specified in two forms: + +### 1. Placement Target (string or integer) + +Apply the same placement to all new windows on the workspace. This can be either +a placement strategy name (string) or a 1-based container index (integer): + +```json +{ + "monitors": [ + { + "workspaces": [ + { + "name": "main", + "initial_window_placement_rules": "Primary" + } + ] + } + ] +} +``` + +Or with a fixed container index (1-based): + +```json +{ + "monitors": [ + { + "workspaces": [ + { + "name": "main", + "initial_window_placement_rules": 3 + } + ] + } + ] +} +``` + +This places every new window at the third container position. If the index is out +of bounds, the window falls back to the default `AfterFocused` behaviour. + +Available placement strategies: + +| Strategy | Description | +|----------|-------------| +| `Primary` | Place at the primary (largest) container position (index 0 for all built-in layouts) | +| `Secondary` | Place at the secondary container position (index 1 for all built-in layouts) | +| `BeforeFocused` | Place before the currently focused container | +| `AfterFocused` | Place after the currently focused container (default behaviour) | +| `Last` | Place at the end of the container list | + +### 2. Per-Application Rules (map) + +Assign different applications to specific container positions. Map keys can be +placement strategy names or 1-based container indices. Values are matching rules: + +```json +{ + "monitors": [ + { + "workspaces": [ + { + "name": "main", + "initial_window_placement_rules": { + "Primary": { + "kind": "Exe", + "id": "chrome.exe", + "matching_strategy": "Equals" + }, + "Secondary": { + "kind": "Title", + "id": "Microsoft Teams", + "matching_strategy": "Equals" + } + } + } + ] + } + ] +} +``` + +In this example, Chrome windows are always placed at the primary container +position, and windows with "Microsoft Teams" in the title are placed at the +secondary position. All other windows fall back to `AfterFocused`. + +## Matching Rules + +### Simple Rule + +A single matching condition: + +```json +{ + "kind": "Exe", + "id": "chrome.exe", + "matching_strategy": "Equals" +} +``` + +### Multiple Rules for the Same Placement (OR logic) + +To assign multiple different applications to the same placement target, use an +array. Each element in the array is checked independently — if **any** rule +matches, the window is placed at that target: + +```json +{ + "initial_window_placement_rules": { + "Primary": [ + { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" }, + { "kind": "Exe", "id": "firefox.exe", "matching_strategy": "Equals" } + ] + } +} +``` + +This places both Chrome and Firefox at the primary position. + +### Composite Rule (AND logic) + +To match a window that satisfies **all** conditions, wrap the conditions in an +inner array: + +```json +{ + "initial_window_placement_rules": { + "Primary": [ + [ + { "kind": "Exe", "id": "code.exe", "matching_strategy": "Equals" }, + { "kind": "Title", "id": "workspace", "matching_strategy": "Contains" } + ] + ] + } +} +``` + +This only matches windows where the executable is `code.exe` **and** the title +contains "workspace". + +### Mixing OR and AND + +You can combine independent rules and composite rules: + +```json +{ + "initial_window_placement_rules": { + "Primary": [ + { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" }, + [ + { "kind": "Exe", "id": "code.exe", "matching_strategy": "Equals" }, + { "kind": "Title", "id": "workspace", "matching_strategy": "Contains" } + ] + ] + } +} +``` + +This places at the primary position: Chrome (any window), **or** VS Code windows +whose title contains "workspace". + +## Primary and Secondary Positions + +For all built-in layouts, the primary container is always at index 0 (the +largest pane), and the secondary container is at index 1. This holds true +regardless of layout flip settings — flipping only changes the visual position +of containers on screen, not their index in the container list. + +| Layout | Primary (index 0) | Secondary (index 1) | +|--------|-------------------|---------------------| +| **BSP** | Largest split area | Second-largest split | +| **VerticalStack** | Left column | First row in right stack | +| **RightMainVerticalStack** | Right column | First row in left stack | +| **HorizontalStack** | Top row | First column in bottom stack | +| **UltrawideVerticalStack** | Center column | Left column | +| **Columns** | First column | Second column | +| **Rows** | First row | Second row | +| **Grid** | First cell | Second cell | + +For custom layouts, the primary and secondary positions are determined by the +`Column::Primary` and `Column::Secondary` definitions in the layout file. + +## Rule Evaluation Order + +When using the map form with per-application rules, rules are evaluated in +**key order** — alphabetical for placement names, numerical for indices. The +**first matching rule** determines the placement. If no rule matches, the window +falls back to `AfterFocused`. + +### Example + +```json +{ + "initial_window_placement_rules": { + "1": { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" }, + "Primary": { "kind": "Exe", "id": "firefox.exe", "matching_strategy": "Equals" }, + "Secondary": { "kind": "Title", "id": "Teams", "matching_strategy": "Contains" } + } +} +``` + +Since `BTreeMap` sorts keys lexicographically, the evaluation order is: + +1. `"1"` — numeric strings come before letters +2. `"Primary"` — alphabetical among placement names +3. `"Secondary"` + +So when a new window opens: + +- **chrome.exe** → matches `"1"` → placed at container index 1 (the first position) +- **firefox.exe** → does not match `"1"`, matches `"Primary"` → placed at the primary position (also index 0, but layout-aware) +- **A window titled "Teams Meeting"** → does not match `"1"` or `"Primary"`, matches `"Secondary"` → placed at the secondary position +- **Any other window** → no match → falls back to `AfterFocused` (after the currently focused container) + +### Key ordering detail + +Because map keys are sorted as strings, numeric keys sort lexicographically, +not numerically. This means `"10"` sorts before `"2"`. If you mix numeric and +named keys, the order is: + +| Key | Sort position | +|-----|---------------| +| `"1"` | 1st (digits before letters) | +| `"10"` | 2nd | +| `"2"` | 3rd | +| `"AfterFocused"` | 4th | +| `"Last"` | 5th | +| `"Primary"` | 6th | +| `"Secondary"` | 7th | + +In practice this rarely matters — most configs use only a few keys. But if +order is important, be aware that the first matching rule wins. + +### Out of bounds fallback + +If a rule matches but the resolved container index is out of bounds (e.g. a +rule targets container `5` but only 3 containers exist), that specific match is +ignored and the window falls back to `AfterFocused`. + +## Interaction with Other Settings + +- **`preselected_container_idx`**: Manual preselection (via keybinds) takes + priority over `initial_window_placement_rules` +- **`window_container_behaviour`**: This feature only applies when set to + `Create`. When set to `Append`, new windows are stacked into the focused + container regardless of placement rules + +## Notes + +- Container indices in the configuration are **1-based** for user-friendliness + (the first container is `1`, not `0`) +- The `Primary` and `Secondary` placement strategies resolve to container indices + based on the current layout, making rules portable across layout changes +- Focus moves to the newly placed window, consistent with the default behaviour diff --git a/komorebi-layouts/src/custom_layout.rs b/komorebi-layouts/src/custom_layout.rs index baea3e0b..ad5566e6 100644 --- a/komorebi-layouts/src/custom_layout.rs +++ b/komorebi-layouts/src/custom_layout.rs @@ -67,6 +67,38 @@ impl CustomLayout { None } + /// Returns the container index of the primary container in the custom layout. + /// + /// This converts the column index of the `Column::Primary` variant to a container index. + #[must_use] + pub fn primary_container_index(&self) -> Option { + self.primary_idx() + .map(|col_idx| self.first_container_idx(col_idx)) + } + + /// Returns the container index of the secondary container in the custom layout. + /// + /// This finds the `Column::Secondary` variant and converts its column index to a container index. + /// Returns `None` if there is no secondary column or if there are not enough containers. + #[must_use] + pub fn secondary_container_index(&self, container_count: usize) -> Option { + if container_count < 2 { + return None; + } + + for (i, column) in self.iter().enumerate() { + if let Column::Secondary(_) = column { + let idx = self.first_container_idx(i); + if idx < container_count { + return Some(idx); + } + } + } + + // Fallback: if no secondary column, return index 1 if it exists + if container_count >= 2 { Some(1) } else { None } + } + #[must_use] pub fn primary_width_percentage(&self) -> Option { for column in self.iter() { diff --git a/komorebi-layouts/src/default_layout.rs b/komorebi-layouts/src/default_layout.rs index b53a32da..3018ea34 100644 --- a/komorebi-layouts/src/default_layout.rs +++ b/komorebi-layouts/src/default_layout.rs @@ -301,6 +301,27 @@ impl DefaultLayout { } } + /// Returns the container index of the primary (largest) pane for this layout. + /// + /// For all default layouts, the primary container is always at index 0. + #[must_use] + pub fn primary_index(&self) -> usize { + 0 + } + + /// Returns the container index of the secondary pane for this layout, + /// if there are enough containers. Returns `None` if there are fewer than 2 containers. + /// + /// For all default layouts, the secondary container is at index 1. + /// - **BSP**: the second-largest split area + /// - **UltrawideVerticalStack**: the left (secondary) column + /// - **VerticalStack / HorizontalStack / RightMainVerticalStack**: the first container in the stack area + /// - **Columns / Rows / Grid / Scrolling**: simply the second container + #[must_use] + pub fn secondary_index(&self, container_count: usize) -> Option { + if container_count >= 2 { Some(1) } else { None } + } + #[must_use] #[allow(clippy::cast_precision_loss, clippy::only_used_in_recursion)] pub fn resize( diff --git a/komorebi-layouts/src/layout.rs b/komorebi-layouts/src/layout.rs index 12c0a01b..9ed85623 100644 --- a/komorebi-layouts/src/layout.rs +++ b/komorebi-layouts/src/layout.rs @@ -33,4 +33,25 @@ impl Layout { Layout::Custom(layout) => Box::new(layout.clone()), } } + + /// Returns the container index of the primary (largest) pane for this layout. + #[must_use] + pub fn primary_index(&self) -> usize { + match self { + Layout::Default(layout) => layout.primary_index(), + #[cfg(feature = "win32")] + Layout::Custom(layout) => layout.primary_container_index().unwrap_or(0), + } + } + + /// Returns the container index of the secondary pane for this layout, + /// if there are enough containers. + #[must_use] + pub fn secondary_index(&self, container_count: usize) -> Option { + match self { + Layout::Default(layout) => layout.secondary_index(container_count), + #[cfg(feature = "win32")] + Layout::Custom(layout) => layout.secondary_container_index(container_count), + } + } } diff --git a/komorebi/src/core/mod.rs b/komorebi/src/core/mod.rs index 120069f6..90a39adc 100644 --- a/komorebi/src/core/mod.rs +++ b/komorebi/src/core/mod.rs @@ -504,6 +504,175 @@ impl Placement { } } +#[derive( + Clone, + Copy, + Debug, + Default, + Serialize, + Deserialize, + Display, + EnumString, + ValueEnum, + PartialEq, + Eq, + Hash, +)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +/// Placement strategy for new windows in a workspace +pub enum WindowPlacement { + /// Place the new window at the primary (largest) container position + Primary, + /// Place the new window at the secondary container position + Secondary, + /// Place the new window before the currently focused container + BeforeFocused, + /// Place the new window after the currently focused container (default behaviour) + #[default] + AfterFocused, + /// Place the new window at the end of the container list + Last, +} + +/// A target position for window placement, used as a key in `InitialWindowPlacementRules::Rules`. +/// +/// Can be either a `WindowPlacement` variant name (e.g. `"Primary"`, `"Last"`) +/// or a 1-based container index (e.g. `"1"`, `"3"`). +/// +/// NOTE: Integer indices are 1-based in the config for user-friendliness. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub enum PlacementTarget { + /// A named placement strategy + Placement(WindowPlacement), + /// A 1-based container index + Index(usize), +} + +impl std::fmt::Display for PlacementTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PlacementTarget::Placement(p) => write!(f, "{p}"), + PlacementTarget::Index(i) => write!(f, "{i}"), + } + } +} + +impl std::str::FromStr for PlacementTarget { + type Err = String; + + fn from_str(s: &str) -> Result { + // Try parsing as a WindowPlacement variant first + if let Ok(placement) = ::from_str(s) { + return Ok(PlacementTarget::Placement(placement)); + } + // Then try parsing as a 1-based index + if let Ok(idx) = s.parse::() { + return Ok(PlacementTarget::Index(idx)); + } + Err(format!( + "'{s}' is not a valid WindowPlacement variant or integer" + )) + } +} + +impl PartialOrd for PlacementTarget { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PlacementTarget { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.to_string().cmp(&other.to_string()) + } +} + +impl Serialize for PlacementTarget { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PlacementTarget { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse().map_err(serde::de::Error::custom) + } +} + +/// Configuration for initial window placement rules on a workspace. +/// +/// This can be specified in two forms in the JSON config: +/// - A placement target (string or integer) — applies the same placement to all windows. +/// Strings are `WindowPlacement` variant names (e.g. `"Primary"`, `"AfterFocused"`), +/// integers are 1-based container indices. +/// - A map of placement targets to matching rules — keys can be `WindowPlacement` variant names +/// (e.g. `"Primary"`, `"Secondary"`) or 1-based container indices (e.g. `"1"`, `"3"`). +/// Rules are evaluated in key order; the first matching rule determines placement. +/// +/// NOTE: Container indices in the config are 1-based for user-friendliness. +/// They are converted to 0-based internally during resolution. +/// +/// NOTE: This feature currently only applies when `WindowContainerBehaviour::Create` is active. +/// Future versions may support toggling this for `Append` mode as well. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum InitialWindowPlacementRules { + /// A single placement target applied to all new windows (string or integer in config) + Target(PlacementTarget), + /// A map of placement targets to matching rules. + /// Keys can be `WindowPlacement` variant names (e.g. `"Primary"`) or 1-based container indices (e.g. `"1"`). + /// Values can be: + /// - A single `IdWithIdentifier` object (simple rule) + /// - An array containing objects and/or arrays: + /// - Each object in the array is an independent simple rule (OR logic between entries) + /// - Each inner array is a composite rule where all conditions must match (AND logic) + /// - The outer array entries are evaluated with OR logic + /// + /// Rules are evaluated in key order; the first matching rule determines placement. + Rules(std::collections::BTreeMap), +} + +/// Matching rules for a placement target. +/// +/// Can be specified in JSON as: +/// - A single `IdWithIdentifier` object — one simple rule +/// - An array of `MatchingRule`s — multiple rules with OR logic between them +/// (each element can be a simple rule object or a composite rule array with AND logic) +/// +/// Examples: +/// ```json +/// // Single rule +/// { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" } +/// +/// // Multiple rules (OR): chrome OR teams +/// [ +/// { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" }, +/// { "kind": "Title", "id": "Microsoft Teams", "matching_strategy": "Equals" } +/// ] +/// +/// // Mixed: chrome OR (code.exe AND title contains "workspace") +/// [ +/// { "kind": "Exe", "id": "chrome.exe", "matching_strategy": "Equals" }, +/// [ +/// { "kind": "Exe", "id": "code.exe", "matching_strategy": "Equals" }, +/// { "kind": "Title", "id": "workspace", "matching_strategy": "Contains" } +/// ] +/// ] +/// ``` +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[serde(untagged)] +pub enum PlacementMatchingRules { + /// A single simple matching rule + Single(config_generation::IdWithIdentifier), + /// Multiple matching rules evaluated with OR logic. + /// Each entry is a `MatchingRule`: either a simple rule (object) or composite rule (array, AND logic). + Many(Vec), +} + #[derive( Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Display, EnumString, ValueEnum, )] diff --git a/komorebi/src/state.rs b/komorebi/src/state.rs index a935d044..e43eccc8 100644 --- a/komorebi/src/state.rs +++ b/komorebi/src/state.rs @@ -283,6 +283,9 @@ impl From<&WindowManager> for State { workspace_config: None, preselected_container_idx: None, promotion_swap_container_idx: None, + initial_window_placement_rules: workspace + .initial_window_placement_rules + .clone(), }) .collect::>(); ws.focus(monitor.workspaces.focused_idx()); diff --git a/komorebi/src/static_config.rs b/komorebi/src/static_config.rs index 252f2dd2..2fa67419 100644 --- a/komorebi/src/static_config.rs +++ b/komorebi/src/static_config.rs @@ -55,6 +55,7 @@ use crate::core::BorderStyle; use crate::core::DefaultLayout; use crate::core::FocusFollowsMouseImplementation; use crate::core::HidingBehaviour; +use crate::core::InitialWindowPlacementRules; use crate::core::Layout; use crate::core::LayoutDefaultEntry; use crate::core::LayoutOptions; @@ -286,6 +287,11 @@ pub struct WorkspaceConfig { /// Specify a wallpaper for this workspace #[serde(skip_serializing_if = "Option::is_none")] pub wallpaper: Option, + /// Initial window placement rules that determine where new tiled windows are placed + /// in the container list. Can be a placement target (a `WindowPlacement` string like + /// `"Primary"` or a 1-based container index), or a map of placement targets to matching rules. + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_window_placement_rules: Option, } impl From<&Workspace> for WorkspaceConfig { @@ -389,6 +395,7 @@ impl From<&Workspace> for WorkspaceConfig { layout_flip: value.layout_flip, floating_layer_behaviour: value.floating_layer_behaviour, wallpaper: None, + initial_window_placement_rules: value.initial_window_placement_rules.clone(), } } } diff --git a/komorebi/src/workspace.rs b/komorebi/src/workspace.rs index 53e06aaf..d0ee4676 100644 --- a/komorebi/src/workspace.rs +++ b/komorebi/src/workspace.rs @@ -25,14 +25,19 @@ use crate::core::Axis; use crate::core::CustomLayout; use crate::core::CycleDirection; use crate::core::DefaultLayout; +use crate::core::InitialWindowPlacementRules; use crate::core::Layout; use crate::core::LayoutDefaultEntry; use crate::core::LayoutOptions; use crate::core::OperationDirection; +use crate::core::PlacementMatchingRules; +use crate::core::PlacementTarget; use crate::core::Rect; +use crate::core::WindowPlacement; use crate::lockable_sequence::LockableSequence; use crate::ring::Ring; use crate::should_act; +use crate::should_act_individual; use crate::stackbar_manager; use crate::stackbar_manager::STACKBAR_TAB_HEIGHT; use crate::static_config::WorkspaceConfig; @@ -94,6 +99,9 @@ pub struct Workspace { pub preselected_container_idx: Option, #[serde(skip_serializing_if = "Option::is_none")] pub promotion_swap_container_idx: Option, + /// Initial window placement rules that determine where new tiled windows are placed + #[serde(skip_serializing_if = "Option::is_none")] + pub initial_window_placement_rules: Option, } #[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -150,6 +158,7 @@ impl Default for Workspace { wallpaper: None, preselected_container_idx: None, promotion_swap_container_idx: None, + initial_window_placement_rules: None, } } } @@ -307,6 +316,7 @@ impl Workspace { // Load layout options directly (LayoutOptions is used in both config and runtime) self.layout_options = config.layout_options; + self.initial_window_placement_rules = config.initial_window_placement_rules.clone(); // Load threshold-based layout options rules, sorted by threshold ascending self.layout_options_rules = @@ -1266,7 +1276,7 @@ impl Workspace { } else if self.containers().is_empty() { 0 } else { - self.focused_container_idx() + 1 + self.resolve_placement_index(&window) }; let mut container = Container::default(); @@ -1275,6 +1285,118 @@ impl Workspace { self.insert_container_at_idx(next_idx, container); } + /// Resolves the container index at which a new window should be placed, + /// based on the `initial_window_placement_rules` configuration. + /// + /// Falls back to the default placement (currently `AfterFocused`, + /// i.e. `focused_container_idx() + 1`) when: + /// - No rules are configured + /// - A `Rules` map has no matching rule for the window + /// - A resolved index is out of bounds + fn resolve_placement_index(&self, window: &Window) -> usize { + let fallback_idx = self.focused_container_idx() + 1; + + let Some(rules) = &self.initial_window_placement_rules else { + return fallback_idx; + }; + + match rules { + InitialWindowPlacementRules::Target(target) => { + self.resolve_placement_target(target, fallback_idx) + } + InitialWindowPlacementRules::Rules(rules_map) => { + let Ok(title) = window.title() else { + return fallback_idx; + }; + let Ok(exe_name) = window.exe() else { + return fallback_idx; + }; + let Ok(class) = window.class() else { + return fallback_idx; + }; + let Ok(path) = window.path() else { + return fallback_idx; + }; + + let regex_identifiers = REGEX_IDENTIFIERS.lock().clone(); + + // BTreeMap iterates in key order + for (target, placement_rules) in rules_map { + let matched = match placement_rules { + PlacementMatchingRules::Single(id) => should_act_individual( + &title, + &exe_name, + &class, + &path, + id, + ®ex_identifiers, + ), + PlacementMatchingRules::Many(rules) => { + // OR logic: any matching rule triggers placement at this target. + // Each MatchingRule handles its own Simple/Composite (AND) logic + // internally via should_act. + should_act(&title, &exe_name, &class, &path, rules, ®ex_identifiers) + .is_some() + } + }; + + if matched { + return self.resolve_placement_target(target, fallback_idx); + } + } + + // No matching rule found + fallback_idx + } + } + } + + /// Resolves a `WindowPlacement` variant to a concrete container index. + fn resolve_window_placement(&self, placement: &WindowPlacement, fallback_idx: usize) -> usize { + match placement { + WindowPlacement::AfterFocused => self.focused_container_idx() + 1, + WindowPlacement::BeforeFocused => self.focused_container_idx(), + WindowPlacement::Primary => { + let idx = self.layout.primary_index(); + if idx <= self.containers().len() { + idx + } else { + fallback_idx + } + } + WindowPlacement::Secondary => { + if let Some(idx) = self.layout.secondary_index(self.containers().len()) { + if idx <= self.containers().len() { + idx + } else { + fallback_idx + } + } else { + fallback_idx + } + } + WindowPlacement::Last => self.containers().len(), + } + } + + /// Resolves a `PlacementTarget` (either a named placement or a 1-based index) to a concrete container index. + fn resolve_placement_target(&self, target: &PlacementTarget, fallback_idx: usize) -> usize { + match target { + PlacementTarget::Placement(placement) => { + self.resolve_window_placement(placement, fallback_idx) + } + PlacementTarget::Index(idx) => { + // Config indices are 1-based; convert to 0-based + let zero_based = idx.saturating_sub(1); + if zero_based <= self.containers().len() { + zero_based + } else { + fallback_idx + } + } + } + } + pub fn new_floating_window(&mut self) -> eyre::Result<()> { let window = if let Some(maximized_window) = self.maximized_window { let window = maximized_window; diff --git a/schema.bar.json b/schema.bar.json index 0b463297..ad99e4dc 100644 --- a/schema.bar.json +++ b/schema.bar.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "title": "KomobarConfig", - "description": "The `komorebi.bar.json` configuration file reference for `v0.1.41`", + "description": "The `komorebi.bar.json` configuration file reference for `v0.1.42`", "type": "object", "properties": { "center_widgets": { @@ -3151,6 +3151,22 @@ "right" ] }, + "InitialWindowPlacementRules": { + "description": "Configuration for initial window placement rules on a workspace.\n\nThis can be specified in two forms in the JSON config:\n- A placement target (string or integer) ÔÇö applies the same placement to all windows.\n Strings are `WindowPlacement` variant names (e.g. `\"Primary\"`, `\"AfterFocused\"`),\n integers are 1-based container indices.\n- A map of placement targets to matching rules ÔÇö keys can be `WindowPlacement` variant names\n (e.g. `\"Primary\"`, `\"Secondary\"`) or 1-based container indices (e.g. `\"1\"`, `\"3\"`).\n Rules are evaluated in key order; the first matching rule determines placement.\n\nNOTE: Container indices in the config are 1-based for user-friendliness.\nThey are converted to 0-based internally during resolution.\n\nNOTE: This feature currently only applies when `WindowContainerBehaviour::Create` is active.\nFuture versions may support toggling this for `Append` mode as well.", + "anyOf": [ + { + "description": "A single placement target applied to all new windows (string or integer in config)", + "$ref": "#/$defs/PlacementTarget" + }, + { + "description": "A map of placement targets to matching rules.\nKeys can be `WindowPlacement` variant names (e.g. `\"Primary\"`) or 1-based container indices (e.g. `\"1\"`).\nValues can be:\n- A single `IdWithIdentifier` object (simple rule)\n- An array containing objects and/or arrays:\n - Each object in the array is an independent simple rule (OR logic between entries)\n - Each inner array is a composite rule where all conditions must match (AND logic)\n - The outer array entries are evaluated with OR logic\nRules are evaluated in key order; the first matching rule determines placement.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PlacementMatchingRules" + } + } + ] + }, "KeyboardConfig": { "description": "Keyboard widget configuration", "type": "object", @@ -4584,6 +4600,21 @@ } ] }, + "MonocleFocusBehaviour": { + "description": "Behaviour when focusing in a direction while a monocle container is active", + "oneOf": [ + { + "description": "Cycle the monocle container to the next/previous container in the workspace", + "type": "string", + "const": "Cycle" + }, + { + "description": "Do nothing, allowing focus to fall through to cross-monitor logic", + "type": "string", + "const": "NoOp" + } + ] + }, "MouseConfig": { "description": "Mouse configuration", "type": "object", @@ -4922,6 +4953,55 @@ "description": "A file system path. Environment variables like %VAR%, $Env:VAR, or $VAR are automatically resolved.", "type": "string" }, + "PlacementMatchingRules": { + "description": "Matching rules for a placement target.\n\nCan be specified in JSON as:\n- A single `IdWithIdentifier` object ÔÇö one simple rule\n- An array of `MatchingRule`s ÔÇö multiple rules with OR logic between them\n (each element can be a simple rule object or a composite rule array with AND logic)\n\nExamples:\n```json\n// Single rule\n{ \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" }\n\n// Multiple rules (OR): chrome OR teams\n[\n { \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" },\n { \"kind\": \"Title\", \"id\": \"Microsoft Teams\", \"matching_strategy\": \"Equals\" }\n]\n\n// Mixed: chrome OR (code.exe AND title contains \"workspace\")\n[\n { \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" },\n [\n { \"kind\": \"Exe\", \"id\": \"code.exe\", \"matching_strategy\": \"Equals\" },\n { \"kind\": \"Title\", \"id\": \"workspace\", \"matching_strategy\": \"Contains\" }\n ]\n]\n```", + "anyOf": [ + { + "description": "A single simple matching rule", + "$ref": "#/$defs/IdWithIdentifier" + }, + { + "description": "Multiple matching rules evaluated with OR logic.\nEach entry is a `MatchingRule`: either a simple rule (object) or composite rule (array, AND logic).", + "type": "array", + "items": { + "$ref": "#/$defs/MatchingRule" + } + } + ] + }, + "PlacementTarget": { + "description": "A target position for window placement, used as a key in `InitialWindowPlacementRules::Rules`.\n\nCan be either a `WindowPlacement` variant name (e.g. `\"Primary\"`, `\"Last\"`)\nor a 1-based container index (e.g. `\"1\"`, `\"3\"`).\n\nNOTE: Integer indices are 1-based in the config for user-friendliness.", + "oneOf": [ + { + "description": "A named placement strategy", + "type": "object", + "properties": { + "Placement": { + "$ref": "#/$defs/WindowPlacement" + } + }, + "additionalProperties": false, + "required": [ + "Placement" + ] + }, + { + "description": "A 1-based container index", + "type": "object", + "properties": { + "Index": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "Index" + ] + } + ] + }, "Position": { "description": "Position", "type": "object", @@ -6010,6 +6090,34 @@ "content" ] }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ToggleMonocleFocusBehaviour" + } + }, + "required": [ + "type" + ] + }, + { + "type": "object", + "properties": { + "content": { + "$ref": "#/$defs/MonocleFocusBehaviour" + }, + "type": { + "type": "string", + "const": "MonocleFocusBehaviour" + } + }, + "required": [ + "type", + "content" + ] + }, { "type": "object", "properties": { @@ -8826,6 +8934,9 @@ "monitors": { "$ref": "#/$defs/Ring" }, + "monocle_focus_behaviour": { + "$ref": "#/$defs/MonocleFocusBehaviour" + }, "mouse_follows_focus": { "type": "boolean" }, @@ -8870,6 +8981,7 @@ "new_window_behaviour", "float_override", "cross_monitor_move_behaviour", + "monocle_focus_behaviour", "unmanaged_window_operation_behaviour", "mouse_follows_focus", "has_pending_raise_op" @@ -9642,6 +9754,36 @@ } ] }, + "WindowPlacement": { + "description": "Placement strategy for new windows in a workspace", + "oneOf": [ + { + "description": "Place the new window at the primary (largest) container position", + "type": "string", + "const": "Primary" + }, + { + "description": "Place the new window at the secondary container position", + "type": "string", + "const": "Secondary" + }, + { + "description": "Place the new window before the currently focused container", + "type": "string", + "const": "BeforeFocused" + }, + { + "description": "Place the new window after the currently focused container (default behaviour)", + "type": "string", + "const": "AfterFocused" + }, + { + "description": "Place the new window at the end of the container list", + "type": "string", + "const": "Last" + } + ] + }, "Workspace": { "type": "object", "properties": { @@ -9677,6 +9819,17 @@ "floating_windows": { "$ref": "#/$defs/Ring4" }, + "initial_window_placement_rules": { + "description": "Initial window placement rules that determine where new tiled windows are placed", + "anyOf": [ + { + "$ref": "#/$defs/InitialWindowPlacementRules" + }, + { + "type": "null" + } + ] + }, "latest_layout": { "type": "array", "items": { @@ -9987,6 +10140,17 @@ ], "default": "Tile" }, + "initial_window_placement_rules": { + "description": "Initial window placement rules that determine where new windows are placed\nin the container list. Can be a placement strategy string, a 1-based container\nindex, or a map of 1-based container indices to matching rules.", + "anyOf": [ + { + "$ref": "#/$defs/InitialWindowPlacementRules" + }, + { + "type": "null" + } + ] + }, "initial_workspace_rules": { "description": "Initial workspace application rules", "type": [ diff --git a/schema.json b/schema.json index d61dd84e..2bedeabf 100644 --- a/schema.json +++ b/schema.json @@ -363,6 +363,18 @@ "$ref": "#/$defs/MonitorConfig" } }, + "monocle_focus_behaviour": { + "description": "Determine what happens when focusing in a direction while a monocle container is active", + "anyOf": [ + { + "$ref": "#/$defs/MonocleFocusBehaviour" + }, + { + "type": "null" + } + ], + "default": "NoOp" + }, "mouse_follows_focus": { "description": "Enable or disable mouse follows focus", "type": [ @@ -784,7 +796,7 @@ "boolean", "null" ], - "default": true + "default": false }, "style": { "description": "Set the animation style", @@ -2859,6 +2871,22 @@ "id" ] }, + "InitialWindowPlacementRules": { + "description": "Configuration for initial window placement rules on a workspace.\n\nThis can be specified in two forms in the JSON config:\n- A placement target (string or integer) ÔÇö applies the same placement to all windows.\n Strings are `WindowPlacement` variant names (e.g. `\"Primary\"`, `\"AfterFocused\"`),\n integers are 1-based container indices.\n- A map of placement targets to matching rules ÔÇö keys can be `WindowPlacement` variant names\n (e.g. `\"Primary\"`, `\"Secondary\"`) or 1-based container indices (e.g. `\"1\"`, `\"3\"`).\n Rules are evaluated in key order; the first matching rule determines placement.\n\nNOTE: Container indices in the config are 1-based for user-friendliness.\nThey are converted to 0-based internally during resolution.\n\nNOTE: This feature currently only applies when `WindowContainerBehaviour::Create` is active.\nFuture versions may support toggling this for `Append` mode as well.", + "anyOf": [ + { + "description": "A single placement target applied to all new windows (string or integer in config)", + "$ref": "#/$defs/PlacementTarget" + }, + { + "description": "A map of placement targets to matching rules.\nKeys can be `WindowPlacement` variant names (e.g. `\"Primary\"`) or 1-based container indices (e.g. `\"1\"`).\nValues can be:\n- A single `IdWithIdentifier` object (simple rule)\n- An array containing objects and/or arrays:\n - Each object in the array is an independent simple rule (OR logic between entries)\n - Each inner array is a composite rule where all conditions must match (AND logic)\n - The outer array entries are evaluated with OR logic\nRules are evaluated in key order; the first matching rule determines placement.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/PlacementMatchingRules" + } + } + ] + }, "KomorebiTheme": { "description": "Komorebi theme", "oneOf": [ @@ -3557,6 +3585,21 @@ "workspaces" ] }, + "MonocleFocusBehaviour": { + "description": "Behaviour when focusing in a direction while a monocle container is active", + "oneOf": [ + { + "description": "Cycle the monocle container to the next/previous container in the workspace", + "type": "string", + "const": "Cycle" + }, + { + "description": "Do nothing, allowing focus to fall through to cross-monitor logic", + "type": "string", + "const": "NoOp" + } + ] + }, "MoveBehaviour": { "description": "Move behaviour when the operation works across a monitor boundary", "oneOf": [ @@ -3688,6 +3731,55 @@ } ] }, + "PlacementMatchingRules": { + "description": "Matching rules for a placement target.\n\nCan be specified in JSON as:\n- A single `IdWithIdentifier` object ÔÇö one simple rule\n- An array of `MatchingRule`s ÔÇö multiple rules with OR logic between them\n (each element can be a simple rule object or a composite rule array with AND logic)\n\nExamples:\n```json\n// Single rule\n{ \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" }\n\n// Multiple rules (OR): chrome OR teams\n[\n { \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" },\n { \"kind\": \"Title\", \"id\": \"Microsoft Teams\", \"matching_strategy\": \"Equals\" }\n]\n\n// Mixed: chrome OR (code.exe AND title contains \"workspace\")\n[\n { \"kind\": \"Exe\", \"id\": \"chrome.exe\", \"matching_strategy\": \"Equals\" },\n [\n { \"kind\": \"Exe\", \"id\": \"code.exe\", \"matching_strategy\": \"Equals\" },\n { \"kind\": \"Title\", \"id\": \"workspace\", \"matching_strategy\": \"Contains\" }\n ]\n]\n```", + "anyOf": [ + { + "description": "A single simple matching rule", + "$ref": "#/$defs/IdWithIdentifier" + }, + { + "description": "Multiple matching rules evaluated with OR logic.\nEach entry is a `MatchingRule`: either a simple rule (object) or composite rule (array, AND logic).", + "type": "array", + "items": { + "$ref": "#/$defs/MatchingRule" + } + } + ] + }, + "PlacementTarget": { + "description": "A target position for window placement, used as a key in `InitialWindowPlacementRules::Rules`.\n\nCan be either a `WindowPlacement` variant name (e.g. `\"Primary\"`, `\"Last\"`)\nor a 1-based container index (e.g. `\"1\"`, `\"3\"`).\n\nNOTE: Integer indices are 1-based in the config for user-friendliness.", + "oneOf": [ + { + "description": "A named placement strategy", + "type": "object", + "properties": { + "Placement": { + "$ref": "#/$defs/WindowPlacement" + } + }, + "additionalProperties": false, + "required": [ + "Placement" + ] + }, + { + "description": "A 1-based container index", + "type": "object", + "properties": { + "Index": { + "type": "integer", + "format": "uint", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "Index" + ] + } + ] + }, "PredefinedAspectRatio": { "description": "Predefined aspect ratio", "oneOf": [ @@ -4152,6 +4244,36 @@ } ] }, + "WindowPlacement": { + "description": "Placement strategy for new windows in a workspace", + "oneOf": [ + { + "description": "Place the new window at the primary (largest) container position", + "type": "string", + "const": "Primary" + }, + { + "description": "Place the new window at the secondary container position", + "type": "string", + "const": "Secondary" + }, + { + "description": "Place the new window before the currently focused container", + "type": "string", + "const": "BeforeFocused" + }, + { + "description": "Place the new window after the currently focused container (default behaviour)", + "type": "string", + "const": "AfterFocused" + }, + { + "description": "Place the new window at the end of the container list", + "type": "string", + "const": "Last" + } + ] + }, "WorkspaceConfig": { "description": "Workspace configuration", "type": "object", @@ -4218,6 +4340,17 @@ ], "default": "Tile" }, + "initial_window_placement_rules": { + "description": "Initial window placement rules that determine where new windows are placed\nin the container list. Can be a placement strategy string, a 1-based container\nindex, or a map of 1-based container indices to matching rules.", + "anyOf": [ + { + "$ref": "#/$defs/InitialWindowPlacementRules" + }, + { + "type": "null" + } + ] + }, "initial_workspace_rules": { "description": "Initial workspace application rules", "type": [