feat(wm): add initial_window_placement_rules for workspace-level

Adds a new initial_window_placement_rules workspace configuration option
that allows users to control where new tiled windows are inserted in the
container list. Supports placement by named strategy (Primary,
Secondary, BeforeFocused, AfterFocused, Last), fixed container index, or
per-application matching rules with OR/AND logic.
This commit is contained in:
Csaba
2026-08-22 13:52:38 -07:00
committed by LGUG2Z
parent 4ca2ff84dc
commit 83c4e11d99
10 changed files with 942 additions and 3 deletions
@@ -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
+32
View File
@@ -67,6 +67,38 @@ impl CustomLayout {
None 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<usize> {
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<usize> {
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] #[must_use]
pub fn primary_width_percentage(&self) -> Option<f32> { pub fn primary_width_percentage(&self) -> Option<f32> {
for column in self.iter() { for column in self.iter() {
+21
View File
@@ -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<usize> {
if container_count >= 2 { Some(1) } else { None }
}
#[must_use] #[must_use]
#[allow(clippy::cast_precision_loss, clippy::only_used_in_recursion)] #[allow(clippy::cast_precision_loss, clippy::only_used_in_recursion)]
pub fn resize( pub fn resize(
+21
View File
@@ -33,4 +33,25 @@ impl Layout {
Layout::Custom(layout) => Box::new(layout.clone()), 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<usize> {
match self {
Layout::Default(layout) => layout.secondary_index(container_count),
#[cfg(feature = "win32")]
Layout::Custom(layout) => layout.secondary_container_index(container_count),
}
}
} }
+169
View File
@@ -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<Self, Self::Err> {
// Try parsing as a WindowPlacement variant first
if let Ok(placement) = <WindowPlacement as std::str::FromStr>::from_str(s) {
return Ok(PlacementTarget::Placement(placement));
}
// Then try parsing as a 1-based index
if let Ok(idx) = s.parse::<usize>() {
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<std::cmp::Ordering> {
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<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
impl<'de> Deserialize<'de> for PlacementTarget {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
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<PlacementTarget, PlacementMatchingRules>),
}
/// 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<config_generation::MatchingRule>),
}
#[derive( #[derive(
Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Display, EnumString, ValueEnum, Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, Display, EnumString, ValueEnum,
)] )]
+3
View File
@@ -283,6 +283,9 @@ impl From<&WindowManager> for State {
workspace_config: None, workspace_config: None,
preselected_container_idx: None, preselected_container_idx: None,
promotion_swap_container_idx: None, promotion_swap_container_idx: None,
initial_window_placement_rules: workspace
.initial_window_placement_rules
.clone(),
}) })
.collect::<VecDeque<_>>(); .collect::<VecDeque<_>>();
ws.focus(monitor.workspaces.focused_idx()); ws.focus(monitor.workspaces.focused_idx());
+7
View File
@@ -55,6 +55,7 @@ use crate::core::BorderStyle;
use crate::core::DefaultLayout; use crate::core::DefaultLayout;
use crate::core::FocusFollowsMouseImplementation; use crate::core::FocusFollowsMouseImplementation;
use crate::core::HidingBehaviour; use crate::core::HidingBehaviour;
use crate::core::InitialWindowPlacementRules;
use crate::core::Layout; use crate::core::Layout;
use crate::core::LayoutDefaultEntry; use crate::core::LayoutDefaultEntry;
use crate::core::LayoutOptions; use crate::core::LayoutOptions;
@@ -286,6 +287,11 @@ pub struct WorkspaceConfig {
/// Specify a wallpaper for this workspace /// Specify a wallpaper for this workspace
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub wallpaper: Option<Wallpaper>, pub wallpaper: Option<Wallpaper>,
/// 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<InitialWindowPlacementRules>,
} }
impl From<&Workspace> for WorkspaceConfig { impl From<&Workspace> for WorkspaceConfig {
@@ -389,6 +395,7 @@ impl From<&Workspace> for WorkspaceConfig {
layout_flip: value.layout_flip, layout_flip: value.layout_flip,
floating_layer_behaviour: value.floating_layer_behaviour, floating_layer_behaviour: value.floating_layer_behaviour,
wallpaper: None, wallpaper: None,
initial_window_placement_rules: value.initial_window_placement_rules.clone(),
} }
} }
} }
+123 -1
View File
@@ -25,14 +25,19 @@ use crate::core::Axis;
use crate::core::CustomLayout; use crate::core::CustomLayout;
use crate::core::CycleDirection; use crate::core::CycleDirection;
use crate::core::DefaultLayout; use crate::core::DefaultLayout;
use crate::core::InitialWindowPlacementRules;
use crate::core::Layout; use crate::core::Layout;
use crate::core::LayoutDefaultEntry; use crate::core::LayoutDefaultEntry;
use crate::core::LayoutOptions; use crate::core::LayoutOptions;
use crate::core::OperationDirection; use crate::core::OperationDirection;
use crate::core::PlacementMatchingRules;
use crate::core::PlacementTarget;
use crate::core::Rect; use crate::core::Rect;
use crate::core::WindowPlacement;
use crate::lockable_sequence::LockableSequence; use crate::lockable_sequence::LockableSequence;
use crate::ring::Ring; use crate::ring::Ring;
use crate::should_act; use crate::should_act;
use crate::should_act_individual;
use crate::stackbar_manager; use crate::stackbar_manager;
use crate::stackbar_manager::STACKBAR_TAB_HEIGHT; use crate::stackbar_manager::STACKBAR_TAB_HEIGHT;
use crate::static_config::WorkspaceConfig; use crate::static_config::WorkspaceConfig;
@@ -94,6 +99,9 @@ pub struct Workspace {
pub preselected_container_idx: Option<usize>, pub preselected_container_idx: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub promotion_swap_container_idx: Option<usize>, pub promotion_swap_container_idx: Option<usize>,
/// 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<InitialWindowPlacementRules>,
} }
#[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Default, Copy, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -150,6 +158,7 @@ impl Default for Workspace {
wallpaper: None, wallpaper: None,
preselected_container_idx: None, preselected_container_idx: None,
promotion_swap_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) // Load layout options directly (LayoutOptions is used in both config and runtime)
self.layout_options = config.layout_options; 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 // Load threshold-based layout options rules, sorted by threshold ascending
self.layout_options_rules = self.layout_options_rules =
@@ -1266,7 +1276,7 @@ impl Workspace {
} else if self.containers().is_empty() { } else if self.containers().is_empty() {
0 0
} else { } else {
self.focused_container_idx() + 1 self.resolve_placement_index(&window)
}; };
let mut container = Container::default(); let mut container = Container::default();
@@ -1275,6 +1285,118 @@ impl Workspace {
self.insert_container_at_idx(next_idx, container); 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,
&regex_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, &regex_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<()> { pub fn new_floating_window(&mut self) -> eyre::Result<()> {
let window = if let Some(maximized_window) = self.maximized_window { let window = if let Some(maximized_window) = self.maximized_window {
let window = maximized_window; let window = maximized_window;
+165 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "KomobarConfig", "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", "type": "object",
"properties": { "properties": {
"center_widgets": { "center_widgets": {
@@ -3151,6 +3151,22 @@
"right" "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": { "KeyboardConfig": {
"description": "Keyboard widget configuration", "description": "Keyboard widget configuration",
"type": "object", "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": { "MouseConfig": {
"description": "Mouse configuration", "description": "Mouse configuration",
"type": "object", "type": "object",
@@ -4922,6 +4953,55 @@
"description": "A file system path. Environment variables like %VAR%, $Env:VAR, or $VAR are automatically resolved.", "description": "A file system path. Environment variables like %VAR%, $Env:VAR, or $VAR are automatically resolved.",
"type": "string" "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": { "Position": {
"description": "Position", "description": "Position",
"type": "object", "type": "object",
@@ -6010,6 +6090,34 @@
"content" "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", "type": "object",
"properties": { "properties": {
@@ -8826,6 +8934,9 @@
"monitors": { "monitors": {
"$ref": "#/$defs/Ring" "$ref": "#/$defs/Ring"
}, },
"monocle_focus_behaviour": {
"$ref": "#/$defs/MonocleFocusBehaviour"
},
"mouse_follows_focus": { "mouse_follows_focus": {
"type": "boolean" "type": "boolean"
}, },
@@ -8870,6 +8981,7 @@
"new_window_behaviour", "new_window_behaviour",
"float_override", "float_override",
"cross_monitor_move_behaviour", "cross_monitor_move_behaviour",
"monocle_focus_behaviour",
"unmanaged_window_operation_behaviour", "unmanaged_window_operation_behaviour",
"mouse_follows_focus", "mouse_follows_focus",
"has_pending_raise_op" "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": { "Workspace": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -9677,6 +9819,17 @@
"floating_windows": { "floating_windows": {
"$ref": "#/$defs/Ring4" "$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": { "latest_layout": {
"type": "array", "type": "array",
"items": { "items": {
@@ -9987,6 +10140,17 @@
], ],
"default": "Tile" "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": { "initial_workspace_rules": {
"description": "Initial workspace application rules", "description": "Initial workspace application rules",
"type": [ "type": [
+134 -1
View File
@@ -363,6 +363,18 @@
"$ref": "#/$defs/MonitorConfig" "$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": { "mouse_follows_focus": {
"description": "Enable or disable mouse follows focus", "description": "Enable or disable mouse follows focus",
"type": [ "type": [
@@ -784,7 +796,7 @@
"boolean", "boolean",
"null" "null"
], ],
"default": true "default": false
}, },
"style": { "style": {
"description": "Set the animation style", "description": "Set the animation style",
@@ -2859,6 +2871,22 @@
"id" "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": { "KomorebiTheme": {
"description": "Komorebi theme", "description": "Komorebi theme",
"oneOf": [ "oneOf": [
@@ -3557,6 +3585,21 @@
"workspaces" "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": { "MoveBehaviour": {
"description": "Move behaviour when the operation works across a monitor boundary", "description": "Move behaviour when the operation works across a monitor boundary",
"oneOf": [ "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": { "PredefinedAspectRatio": {
"description": "Predefined aspect ratio", "description": "Predefined aspect ratio",
"oneOf": [ "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": { "WorkspaceConfig": {
"description": "Workspace configuration", "description": "Workspace configuration",
"type": "object", "type": "object",
@@ -4218,6 +4340,17 @@
], ],
"default": "Tile" "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": { "initial_workspace_rules": {
"description": "Initial workspace application rules", "description": "Initial workspace application rules",
"type": [ "type": [