Compare commits

...

10 Commits

Author SHA1 Message Date
dependabot[bot]
0e79c58be3 chore(deps): bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-07 15:46:39 -08:00
dependabot[bot]
09205bfd83 chore(deps): bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-07 15:46:31 -08:00
Rejdukien
98122bd9d4 fix(wm): prevent window-removal race after display changes
This prevents a race where OS-initiated minimizes prematurely
remove windows that should be transiently restored.

Problem: a display-change can trigger a a fast reconciliation path (same
count), and shortly after Windows may emit a SystemMinimizeStart for
affected windows.  The minimize handler treated those as user-initiated
and removed the window, making later reconciliation unable to restore
it.

Fix: timestamp display-change notifications and add a
display_change_in_progress(period) check to the minimize handler.  While
that grace period is active the minimize handler skips remove_window(),
preserving windows so the reconciliator can restore them.
2026-02-07 15:28:46 -08:00
Rejdukien
9741b387a7 fix(wm): add verification step in monitor reconciliator to handle noisy Win32 events
This change adds verification steps and robustness improvements
to the monitor reconciliator.

- Retry display enumeration if it fails, up to 5 times, instead of silently
  ignoring errors.
- When a potential monitor removal is detected, drop locks and wait briefly,
  then re-query the Win32 display API. If the monitor reappears, treat the
  event as transient and do not remove the monitor.
- Re-acquire locks when needed, so the verification wait doesn't hold
  internal state locks.

Why:
- Win32 device/display notifications are noisy and not always immediately
  available; enumeration can fail (e.g. "Invalid monitor handle" / HRESULT
  0x80070585), or multiple DBT_DEVICEARRIVAL/DBT_DEVICEREMOVECOMPLETE events
  can arrive in quick succession.
- Without verification the reconciliator could treat transient events (for
  example, power-plan–induced monitor sleep where Removes+Adds occur within
  milliseconds) as real removals, resulting in fewer detected monitors than
  are actually present.
2026-02-07 15:28:40 -08:00
LGUG2Z
1dad13106a refactor(layouts): add darwin feature gate and expand win32 feature gate
This commit builds on the newly introduced komorebi-layouts crate to
make it suitable for wholesale adoption in komorebi for Mac.
2026-02-07 15:28:40 -08:00
LGUG2Z
0b5141e7a4 refactor(layouts): extract independent komorebi-layouts crate
This commit moves layout-related code into a new workspace crate
komorebi-layouts, with the intention of re-using it all in komorebi for
Mac instead of maintaining two separate implementations.
2026-02-07 15:28:40 -08:00
Csaba
22e8a79833 feat(wm): add layout_options with ratios support
Added customizable split ratios for layouts via layout_options
configuration. Users can now specify column_ratios and row_ratios arrays
to control window sizing in various layouts.

Ratios are validated at config load time: values are clamped between 0.1
and 0.9 to prevent zero-sized windows, and arrays are automatically
truncated when their cumulative sum would reach or exceed 1.0. This
ensures there's always remaining space for additional windows.

Ratio support varies by layout:

- Columns and Rows layouts use the full arrays for each column/row width
  or height
- VerticalStack, RightMainVerticalStack, and HorizontalStack use the
  first ratio for the primary split and the remaining ratios for stack
  windows
- BSP uses the first value from each array for horizontal and vertical
  splits respectively
- Grid only supports column_ratios since row counts vary dynamically.
- UltrawideVerticalStack uses the first two column ratios for center and
  left columns.

All ratio-related values are now defined as constants in
default_layout.rs: MAX_RATIOS (5), MIN_RATIO (0.1), MAX_RATIO (0.9),
DEFAULT_RATIO (0.5), and DEFAULT_SECONDARY_RATIO (0.25 for
UltrawideVerticalStack).
2026-02-07 15:27:48 -08:00
LGUG2Z
5946caaf92 docs(quickstart): enable fewer widgets in the bar's example config 2026-02-06 17:06:36 -08:00
LGUG2Z
01d73b7d19 fix(borders): prevent use-after-free take 3
This commit attempts tofixfixes a use-after-free bug in the
border_manager that was causing crashes with exception code 0xc000041d
(FATAL_USER_CALLBACK_EXCEPTION) when borders were being destroyed during
config updates.

The root cause was a race condition between the main thread destroying a
border window and the border's window thread still processing queued
window messages (EVENT_OBJECT_LOCATIONCHANGE, WM_PAINT).

The crash occurred when these callbacks invoked render_target.EndDraw(),
which internally calls HwndPresenter::Present() to present the rendered
frame to the HWND. By this point, the HWND and its associated Direct2D
surfaces had been freed by WM_DESTROY, resulting in Direct2D attempting
to dereference freed memory (0xbaadf00dbaadf00d - debug heap poison
value).

The previous attempts at fixing this issue (bdef1448, dbde351e)
addressed symptoms but not the fundamental race condition. bdef1448
attempted to release resources on the main thread before destruction,
but this created a cross-thread race. dbde351e moved resource cleanup to
WM_DESTROY, but this still allowed EVENT_OBJECT_LOCATIONCHANGE/WM_PAINT
handlers to check `render_target.is_some()`, context-switch to
WM_DESTROY which clears it, then context-switch back and call EndDraw()
on a now-invalid reference.

This commit attempts to eliminate the race condition by introducing an
atomic destruction flag that serves as a memory barrier between the
destruction path and the rendering paths:

- Added `is_destroying: Arc<AtomicBool>` field to the Border struct
- In destroy(): Sets the flag with Release ordering, sleeps 10ms to
  allow in-flight operations to complete, then proceeds with cleanup
- In EVENT_OBJECT_LOCATIONCHANGE and WM_PAINT: Checks the flag with
  Acquire ordering both at handler entry and immediately before calling
  BeginDraw/EndDraw, exiting early if destruction is in progress

The Acquire/Release memory ordering creates a synchronizes-with
relationship that ensures:

1. When the destruction flag is set, all subsequent handler checks will
   see it (no stale cached values)
2. Handlers that pass the first check but race with destruction will be
   caught by the second check before touching D2D resources
3. The 10ms sleep window allows any handler already past both checks to
   complete its EndDraw() before resources are freed

This is a lock-free solution with zero overhead on the hot rendering
path (atomic loads are nearly free) and provides defense-in-depth with
multiple barriers against the use-after-free condition.
2026-02-06 16:57:06 -08:00
LGUG2Z
9d16197825 chore(deps): cargo update 2026-02-06 16:57:06 -08:00
31 changed files with 2586 additions and 459 deletions

View File

@@ -65,7 +65,7 @@ jobs:
- run: |
cargo install cargo-wix
cargo wix --no-build -p komorebi --nocapture -I .\wix\main.wxs --target ${{ matrix.platform.target }}
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: komorebi-${{ matrix.platform.target }}-${{ github.sha }}
path: |
@@ -87,7 +87,7 @@ jobs:
fetch-depth: 0
- shell: bash
run: echo "VERSION=nightly" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
- run: |
Compress-Archive -Force ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komorebi-$Env:VERSION-x86_64-pc-windows-msvc.zip
Copy-Item ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komorebi-$Env:VERSION-x86_64.msi
@@ -136,7 +136,7 @@ jobs:
run: |
TAG=${{ github.event.release.tag_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
- run: |
Compress-Archive -Force ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komorebi-$Env:VERSION-x86_64-pc-windows-msvc.zip
Copy-Item ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komorebi-$Env:VERSION-x86_64.msi
@@ -178,7 +178,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v7
- run: |
Compress-Archive -Force ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komorebi-$Env:VERSION-x86_64-pc-windows-msvc.zip
Copy-Item ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komorebi-$Env:VERSION-x86_64.msi

49
Cargo.lock generated
View File

@@ -284,9 +284,9 @@ dependencies = [
[[package]]
name = "anyhow"
version = "1.0.100"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea"
[[package]]
name = "approx"
@@ -3134,6 +3134,7 @@ dependencies = [
"dirs 6.0.0",
"ed25519-dalek",
"hotwatch",
"komorebi-layouts",
"komorebi-themes",
"lazy_static",
"miow",
@@ -3231,6 +3232,22 @@ dependencies = [
"windows-core 0.62.2",
]
[[package]]
name = "komorebi-layouts"
version = "0.1.40"
dependencies = [
"clap",
"color-eyre",
"objc2-core-foundation",
"schemars 1.2.1",
"serde",
"serde_json_lenient",
"serde_yaml 0.9.34+deprecated",
"strum",
"tracing",
"windows 0.62.2",
]
[[package]]
name = "komorebi-shortcuts"
version = "0.1.0"
@@ -3525,9 +3542,9 @@ dependencies = [
[[package]]
name = "memchr"
version = "2.7.6"
version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "memmap2"
@@ -5013,9 +5030,9 @@ dependencies = [
[[package]]
name = "psm"
version = "0.1.29"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d"
checksum = "3852766467df634d74f0b2d7819bf8dc483a0eb2e3b0f50f756f9cfe8b0d18d8"
dependencies = [
"ar_archive_writer",
"cc",
@@ -6081,9 +6098,9 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "stacker"
version = "0.1.22"
version = "0.1.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59"
checksum = "08d74a23609d509411d10e2176dc2a4346e3b4aea2e7b1869f19fdedbc71c013"
dependencies = [
"cc",
"cfg-if 1.0.4",
@@ -6385,9 +6402,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.46"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -6408,9 +6425,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.26"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
@@ -8402,18 +8419,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.38"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961"
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.38"
version = "0.8.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75"
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
dependencies = [
"proc-macro2",
"quote",

View File

@@ -5,6 +5,7 @@ members = [
"komorebi",
"komorebi-client",
"komorebi-gui",
"komorebi-layouts",
"komorebic",
"komorebic-no-console",
"komorebi-bar",

View File

@@ -50,6 +50,11 @@ crate = "komorebi-client"
expression = "LicenseRef-Komorebi-2.0"
license-files = []
[[licenses.clarify]]
crate = "komorebi-layouts"
expression = "LicenseRef-Komorebi-2.0"
license-files = []
[[licenses.clarify]]
crate = "komorebic"
expression = "LicenseRef-Komorebi-2.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -0,0 +1,200 @@
# Layout Ratios
With `komorebi` you can customize the split ratios for various layouts using
`column_ratios` and `row_ratios` in the `layout_options` configuration.
## Before and After
BSP layout example:
**Before** (default 50/50 splits):
![Before layout ratios](../assets/layout-ratios_before.png)
**After** (with `column_ratios: [0.7]` and `row_ratios: [0.6]`):
![After layout ratios](../assets/layout-ratios_after.png)
## Configuration
```json
{
"monitors": [
{
"workspaces": [
{
"name": "main",
"layout_options": {
"column_ratios": [0.3, 0.4],
"row_ratios": [0.4, 0.3]
}
}
]
}
]
}
```
You can specify up to 5 ratio values (defined by `MAX_RATIOS` constant). Each value should be between 0.1 and 0.9
(defined by `MIN_RATIO` and `MAX_RATIO` constants). Values outside this range are automatically clamped.
Columns or rows without a specified ratio will share the remaining space equally.
## Usage by Layout
| Layout | `column_ratios` | `row_ratios` |
|--------|-----------------|--------------|
| **Columns** | Width of each column | - |
| **Rows** | - | Height of each row |
| **Grid** | Width of each column (rows are equal height) | - |
| **BSP** | `[0]` as horizontal split ratio | `[0]` as vertical split ratio |
| **VerticalStack** | `[0]` as primary column width | Stack row heights |
| **RightMainVerticalStack** | `[0]` as primary column width | Stack row heights |
| **HorizontalStack** | Stack column widths | `[0]` as primary row height |
| **UltrawideVerticalStack** | `[0]` center, `[1]` left column | Tertiary stack row heights |
## Examples
### Columns Layout with Custom Widths
Create 3 columns with 30%, 40%, and 30% widths:
```json
{
"layout_options": {
"column_ratios": [0.3, 0.4]
}
}
```
Note: The third column automatically gets the remaining 30%.
### Rows Layout with Custom Heights
Create 3 rows with 20%, 50%, and 30% heights:
```json
{
"layout_options": {
"row_ratios": [0.2, 0.5]
}
}
```
Note: The third row automatically gets the remaining 30%.
### Grid Layout with Custom Column Widths
Grid with custom column widths (rows within each column are always equal height):
```json
{
"layout_options": {
"column_ratios": [0.4, 0.6]
}
}
```
Note: The Grid layout only supports `column_ratios`. Rows within each column are always
divided equally because the number of rows per column varies dynamically based on window count.
### VerticalStack with Custom Ratios
Primary column takes 60% width, and the stack rows are split 30%/70%:
```json
{
"layout_options": {
"column_ratios": [0.6],
"row_ratios": [0.3]
}
}
```
Note: The second row automatically gets the remaining 70%.
### HorizontalStack with Custom Ratios
Primary row takes 70% height, and the stack columns are split 40%/60%:
```json
{
"layout_options": {
"row_ratios": [0.7],
"column_ratios": [0.4]
}
}
```
Note: The second column automatically gets the remaining 60%.
### UltrawideVerticalStack with Custom Ratios
Center column at 50%, left column at 25% (remaining 25% goes to tertiary stack),
with tertiary rows split 40%/60%:
```json
{
"layout_options": {
"column_ratios": [0.5, 0.25],
"row_ratios": [0.4]
}
}
```
Note: The second row automatically gets the remaining 60%.
### BSP Layout with Custom Split Ratios
Use separate ratios for horizontal (left/right) and vertical (top/bottom) splits:
```json
{
"layout_options": {
"column_ratios": [0.6],
"row_ratios": [0.3]
}
}
```
- `column_ratios[0]`: Controls all horizontal splits (left window gets 60%, right gets 40%)
- `row_ratios[0]`: Controls all vertical splits (top window gets 30%, bottom gets 70%)
Note: BSP only uses the first value (`[0]`) from each ratio array. This single ratio is applied
consistently to all splits of that type throughout the layout. Additional values in the arrays are ignored.
## Notes
- Ratios are clamped between 0.1 and 0.9 (prevents zero-sized windows and ensures space for other windows)
- Default ratio is 0.5 (50%) when not specified, except for UltrawideVerticalStack secondary column which defaults to 0.25 (25%)
- Ratios are applied **progressively** - a ratio is only used when there are more windows to place after the current one
- The **last window always takes the remaining space**, regardless of defined ratios
- **Ratios that would sum to 100% or more are automatically truncated** at config load time to ensure there's always space for additional windows
- Unspecified ratios default to sharing the remaining space equally
- You only need to specify the ratios you want to customize; trailing values can be omitted
## Progressive Ratio Behavior
Ratios are applied progressively as windows are added. For example, with `row_ratios: [0.3, 0.5]` in a VerticalStack:
| Windows in Stack | Row Heights |
|------------------|-------------|
| 1 | 100% |
| 2 | 30%, 70% (remainder) |
| 3 | 30%, 50%, 20% (remainder) |
| 4 | 30%, 50%, 10%, 10% (remainder split equally) |
| 5 | 30%, 50%, 6.67%, 6.67%, 6.67% |
## Automatic Ratio Truncation
When ratios sum to 100% (or more), they are automatically truncated at config load time.
For example, if you configure `column_ratios: [0.4, 0.3, 0.3]` (sums to 100%), the last ratio (0.3) is automatically removed, resulting in effectively `[0.4, 0.3]`. This ensures there's always remaining space for the last window.
| Configured Ratios | Effective Ratios | Reason |
|-------------------|------------------|--------|
| `[0.3, 0.4]` | `[0.3, 0.4]` | Sum is 0.7, below 1.0 |
| `[0.4, 0.3, 0.3]` | `[0.4, 0.3]` | Sum would be 1.0, last ratio truncated |
| `[0.5, 0.5]` | `[0.5]` | Sum would be 1.0, last ratio truncated |
| `[0.6, 0.5]` | `[0.6]` | Sum would exceed 1.0, last ratio truncated |
This ensures the layout always fills 100% of the available space and new windows are never placed outside the visible area.

View File

@@ -31,22 +31,22 @@
},
{
"Media": {
"enable": true
"enable": false
}
},
{
"Storage": {
"enable": true
"enable": false
}
},
{
"Memory": {
"enable": true
"enable": false
}
},
{
"Network": {
"enable": true,
"enable": false,
"show_activity": true,
"show_total_activity": true
}

View File

@@ -0,0 +1,26 @@
[package]
name = "komorebi-layouts"
version = "0.1.40"
edition = "2024"
[dependencies]
clap = { workspace = true }
color-eyre = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
strum = { workspace = true }
tracing = { workspace = true }
# Optional dependencies
schemars = { workspace = true, optional = true }
windows = { workspace = true, optional = true }
objc2-core-foundation = { version = "0.3", default-features = false, features = [
"std",
"CFCGTypes",
], optional = true }
[features]
schemars = ["dep:schemars"]
win32 = ["dep:windows"]
darwin = ["dep:objc2-core-foundation"]

View File

@@ -0,0 +1,778 @@
use clap::ValueEnum;
use serde::Deserialize;
use serde::Serialize;
use strum::Display;
use strum::EnumString;
use super::OperationDirection;
use super::Rect;
use super::Sizing;
/// Maximum number of ratio values that can be specified for column_ratios and row_ratios
pub const MAX_RATIOS: usize = 5;
/// Minimum allowed ratio value (prevents zero-sized windows)
pub const MIN_RATIO: f32 = 0.1;
/// Maximum allowed ratio value (ensures space for remaining windows)
pub const MAX_RATIO: f32 = 0.9;
/// Default ratio value when none is specified
pub const DEFAULT_RATIO: f32 = 0.5;
/// Default secondary ratio value for UltrawideVerticalStack layout
pub const DEFAULT_SECONDARY_RATIO: f32 = 0.25;
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// A predefined komorebi layout
pub enum DefaultLayout {
/// BSP Layout
///
/// ```text
/// +-------+-----+
/// | | |
/// | +--+--+
/// | | |--|
/// +-------+--+--+
/// ```
BSP,
/// Columns Layout
///
/// ```text
/// +--+--+--+--+
/// | | | | |
/// | | | | |
/// | | | | |
/// +--+--+--+--+
/// ```
Columns,
/// Rows Layout
///
/// ```text
/// +-----------+
/// |-----------|
/// |-----------|
/// |-----------|
/// +-----------+
/// ```
Rows,
/// Vertical Stack Layout
///
/// ```text
/// +-------+-----+
/// | | |
/// | +-----+
/// | | |
/// +-------+-----+
/// ```
VerticalStack,
/// Horizontal Stack Layout
///
/// ```text
/// +------+------+
/// | |
/// |------+------+
/// | | |
/// +------+------+
/// ```
HorizontalStack,
/// Ultrawide Vertical Stack Layout
///
/// ```text
/// +-----+-----------+-----+
/// | | | |
/// | | +-----+
/// | | | |
/// | | +-----+
/// | | | |
/// +-----+-----------+-----+
/// ```
UltrawideVerticalStack,
/// Grid Layout
///
/// ```text
/// +-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+
/// | | | | | | | | | | | | | | |
/// | | | | | | | | | | | | | +---+
/// +-----+-----+ | +---+---+ +---+---+---+ +---+---| |
/// | | | | | | | | | | | | | +---+
/// | | | | | | | | | | | | | | |
/// +-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+
/// 4 windows 5 windows 6 windows 7 windows
/// ```
Grid,
/// Right Main Vertical Stack Layout
///
/// ```text
/// +-----+-------+
/// | | |
/// +-----+ |
/// | | |
/// +-----+-------+
/// ```
RightMainVerticalStack,
/// Scrolling Layout
///
/// ```text
/// +--+--+--+--+--+--+
/// | | | |
/// | | | |
/// | | | |
/// +--+--+--+--+--+--+
/// ```
Scrolling,
// NOTE: If any new layout is added, please make sure to register the same in `DefaultLayout::cycle`
}
/// Helper to deserialize a variable-length array into a fixed [Option<f32>; MAX_RATIOS]
/// Ratios are truncated when their cumulative sum reaches or exceeds 1.0 to ensure
/// there's always remaining space for additional windows.
fn deserialize_ratios<'de, D>(
deserializer: D,
) -> Result<Option<[Option<f32>; MAX_RATIOS]>, D::Error>
where
D: serde::Deserializer<'de>,
{
let opt: Option<Vec<f32>> = Option::deserialize(deserializer)?;
Ok(opt.map(|vec| {
let mut arr = [None; MAX_RATIOS];
let mut cumulative_sum = 0.0_f32;
for (i, &val) in vec.iter().take(MAX_RATIOS).enumerate() {
let clamped_val = val.clamp(MIN_RATIO, MAX_RATIO);
// Only add this ratio if cumulative sum stays below 1.0
if cumulative_sum + clamped_val < 1.0 {
arr[i] = Some(clamped_val);
cumulative_sum += clamped_val;
} else {
// Stop adding ratios - cumulative sum would reach or exceed 1.0
tracing::debug!(
"Truncating ratios at index {} - cumulative sum {} + {} would reach/exceed 1.0",
i,
cumulative_sum,
clamped_val
);
break;
}
}
arr
}))
}
/// Helper to serialize [Option<f32>; MAX_RATIOS] as a compact array (without trailing nulls)
fn serialize_ratios<S>(
value: &Option<[Option<f32>; MAX_RATIOS]>,
serializer: S,
) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
match value {
None => serializer.serialize_none(),
Some(arr) => {
// Find last non-None index
let last_idx = arr
.iter()
.rposition(|x| x.is_some())
.map(|i| i + 1)
.unwrap_or(0);
let vec: Vec<f32> = arr.iter().take(last_idx).filter_map(|&x| x).collect();
serializer.serialize_some(&vec)
}
}
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for specific layouts
pub struct LayoutOptions {
/// Options related to the Scrolling layout
pub scrolling: Option<ScrollingLayoutOptions>,
/// Options related to the Grid layout
pub grid: Option<GridLayoutOptions>,
/// Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9)
///
/// - Used by Columns layout: ratios for each column width
/// - Used by Grid layout: ratios for column widths
/// - Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio
/// - Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height)
/// - Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio
///
/// Columns without a ratio share remaining space equally.
/// Example: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns
#[serde(
default,
deserialize_with = "deserialize_ratios",
serialize_with = "serialize_ratios"
)]
pub column_ratios: Option<[Option<f32>; MAX_RATIOS]>,
/// Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)
///
/// - Used by Rows layout: ratios for each row height
/// - Used by Grid layout: ratios for row heights
///
/// Rows without a ratio share remaining space equally.
/// Example: `[0.5, 0.5]` for 50%-50% rows
#[serde(
default,
deserialize_with = "deserialize_ratios",
serialize_with = "serialize_ratios"
)]
pub row_ratios: Option<[Option<f32>; MAX_RATIOS]>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for the Scrolling layout
pub struct ScrollingLayoutOptions {
/// Desired number of visible columns (default: 3)
pub columns: usize,
/// With an odd number of visible columns, keep the focused window column centered
pub center_focused_column: Option<bool>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for the Grid layout
pub struct GridLayoutOptions {
/// Maximum number of rows per grid column
pub rows: usize,
}
impl DefaultLayout {
pub fn leftmost_index(&self, len: usize) -> usize {
match self {
Self::UltrawideVerticalStack | Self::RightMainVerticalStack => match len {
n if n > 1 => 1,
_ => 0,
},
Self::Scrolling => 0,
DefaultLayout::BSP
| DefaultLayout::Columns
| DefaultLayout::Rows
| DefaultLayout::VerticalStack
| DefaultLayout::HorizontalStack
| DefaultLayout::Grid => 0,
}
}
pub fn rightmost_index(&self, len: usize) -> usize {
match self {
DefaultLayout::BSP
| DefaultLayout::Columns
| DefaultLayout::Rows
| DefaultLayout::VerticalStack
| DefaultLayout::HorizontalStack
| DefaultLayout::Grid => len.saturating_sub(1),
DefaultLayout::UltrawideVerticalStack => match len {
2 => 0,
_ => len.saturating_sub(1),
},
DefaultLayout::RightMainVerticalStack => 0,
DefaultLayout::Scrolling => len.saturating_sub(1),
}
}
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::only_used_in_recursion)]
pub fn resize(
&self,
unaltered: &Rect,
resize: &Option<Rect>,
edge: OperationDirection,
sizing: Sizing,
delta: i32,
) -> Option<Rect> {
if !matches!(
self,
Self::BSP
| Self::Columns
| Self::Rows
| Self::VerticalStack
| Self::RightMainVerticalStack
| Self::HorizontalStack
| Self::UltrawideVerticalStack
| Self::Scrolling
) {
return None;
};
let mut r = resize.unwrap_or_default();
let resize_delta = delta;
match edge {
OperationDirection::Left => match sizing {
Sizing::Increase => {
// Some final checks to make sure the user can't infinitely resize to
// the point of pushing other windows out of bounds
// Note: These checks cannot take into account the changes made to the
// edges of adjacent windows at operation time, so it is still possible
// to push windows out of bounds by maxing out an Increase Left on a
// Window with index 1, and then maxing out a Decrease Right on a Window
// with index 0. I don't think it's worth trying to defensively program
// against this; if people end up in this situation they are better off
// just hitting the retile command
let diff = ((r.left + -resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.left += -resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.left - -resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.left -= -resize_delta;
}
}
},
OperationDirection::Up => match sizing {
Sizing::Increase => {
let diff = ((r.top + resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.top += -resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.top - resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.top -= -resize_delta;
}
}
},
OperationDirection::Right => match sizing {
Sizing::Increase => {
let diff = ((r.right + resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.right += resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.right - resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.right -= resize_delta;
}
}
},
OperationDirection::Down => match sizing {
Sizing::Increase => {
let diff = ((r.bottom + resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.bottom += resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.bottom - resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.bottom -= resize_delta;
}
}
},
};
if r.eq(&Rect::default()) {
None
} else {
Option::from(r)
}
}
#[must_use]
pub const fn cycle_next(self) -> Self {
match self {
Self::BSP => Self::Columns,
Self::Columns => Self::Rows,
Self::Rows => Self::VerticalStack,
Self::VerticalStack => Self::HorizontalStack,
Self::HorizontalStack => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::Grid,
Self::Grid => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::Scrolling,
Self::Scrolling => Self::BSP,
}
}
#[must_use]
pub const fn cycle_previous(self) -> Self {
match self {
Self::Scrolling => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::Grid,
Self::Grid => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::HorizontalStack,
Self::HorizontalStack => Self::VerticalStack,
Self::VerticalStack => Self::Rows,
Self::Rows => Self::Columns,
Self::Columns => Self::BSP,
Self::BSP => Self::RightMainVerticalStack,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
// Helper to create LayoutOptions with column ratios
fn layout_options_with_column_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(arr),
row_ratios: None,
}
}
// Helper to create LayoutOptions with row ratios
fn layout_options_with_row_ratios(ratios: &[f32]) -> LayoutOptions {
let mut arr = [None; MAX_RATIOS];
for (i, &r) in ratios.iter().take(MAX_RATIOS).enumerate() {
arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: Some(arr),
}
}
// Helper to create LayoutOptions with both column and row ratios
fn layout_options_with_ratios(column_ratios: &[f32], row_ratios: &[f32]) -> LayoutOptions {
let mut col_arr = [None; MAX_RATIOS];
for (i, &r) in column_ratios.iter().take(MAX_RATIOS).enumerate() {
col_arr[i] = Some(r);
}
let mut row_arr = [None; MAX_RATIOS];
for (i, &r) in row_ratios.iter().take(MAX_RATIOS).enumerate() {
row_arr[i] = Some(r);
}
LayoutOptions {
scrolling: None,
grid: None,
column_ratios: Some(col_arr),
row_ratios: Some(row_arr),
}
}
mod deserialize_ratios_tests {
use super::*;
#[test]
fn test_deserialize_valid_ratios() {
let json = r#"{"column_ratios": [0.3, 0.4, 0.2]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.4));
assert_eq!(ratios[2], Some(0.2));
assert_eq!(ratios[3], None);
assert_eq!(ratios[4], None);
}
#[test]
fn test_deserialize_clamps_values_to_min() {
// Values below MIN_RATIO should be clamped
let json = r#"{"column_ratios": [0.05]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(MIN_RATIO)); // Clamped to 0.1
}
#[test]
fn test_deserialize_clamps_values_to_max() {
// Values above MAX_RATIO should be clamped
let json = r#"{"column_ratios": [0.95]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// 0.9 is the max, so it should be clamped
assert!(ratios[0].unwrap() <= MAX_RATIO);
}
#[test]
fn test_deserialize_truncates_when_sum_exceeds_one() {
// Sum of ratios should not reach 1.0
// [0.5, 0.4] = 0.9, then 0.3 would make it 1.2, so it should be truncated
let json = r#"{"column_ratios": [0.5, 0.4, 0.3]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
assert_eq!(ratios[0], Some(0.5));
assert_eq!(ratios[1], Some(0.4));
// Third ratio should be truncated because 0.5 + 0.4 + 0.3 >= 1.0
assert_eq!(ratios[2], None);
}
#[test]
fn test_deserialize_truncates_at_max_ratios() {
// More than MAX_RATIOS values should be truncated
let json = r#"{"column_ratios": [0.1, 0.1, 0.1, 0.1, 0.1, 0.1, 0.1]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
// Only MAX_RATIOS (5) values should be stored
for i in 0..MAX_RATIOS {
assert_eq!(ratios[i], Some(0.1));
}
}
#[test]
fn test_deserialize_empty_array() {
let json = r#"{"column_ratios": []}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.column_ratios.unwrap();
for i in 0..MAX_RATIOS {
assert_eq!(ratios[i], None);
}
}
#[test]
fn test_deserialize_null() {
let json = r#"{"column_ratios": null}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.column_ratios.is_none());
}
#[test]
fn test_deserialize_row_ratios() {
let json = r#"{"row_ratios": [0.3, 0.5]}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let ratios = opts.row_ratios.unwrap();
assert_eq!(ratios[0], Some(0.3));
assert_eq!(ratios[1], Some(0.5));
assert_eq!(ratios[2], None);
}
}
mod serialize_ratios_tests {
use super::*;
#[test]
fn test_serialize_ratios_compact() {
let opts = layout_options_with_column_ratios(&[0.3, 0.4]);
let json = serde_json::to_string(&opts).unwrap();
// Should serialize ratios as compact array without trailing nulls in the ratios array
assert!(json.contains("0.3") && json.contains("0.4"));
}
#[test]
fn test_serialize_none_ratios() {
let opts = LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: None,
};
let json = serde_json::to_string(&opts).unwrap();
// None values should serialize as null or be omitted
assert!(!json.contains("["));
}
#[test]
fn test_roundtrip_serialization() {
let original = layout_options_with_column_ratios(&[0.3, 0.4, 0.2]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
}
#[test]
fn test_serialize_row_ratios() {
let opts = layout_options_with_row_ratios(&[0.3, 0.5]);
let json = serde_json::to_string(&opts).unwrap();
assert!(json.contains("row_ratios"));
assert!(json.contains("0.3") && json.contains("0.5"));
}
#[test]
fn test_roundtrip_row_ratios() {
let original = layout_options_with_row_ratios(&[0.4, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.row_ratios, deserialized.row_ratios);
assert!(original.column_ratios.is_none());
}
#[test]
fn test_roundtrip_both_ratios() {
let original = layout_options_with_ratios(&[0.3, 0.4], &[0.5, 0.3]);
let json = serde_json::to_string(&original).unwrap();
let deserialized: LayoutOptions = serde_json::from_str(&json).unwrap();
assert_eq!(original.column_ratios, deserialized.column_ratios);
assert_eq!(original.row_ratios, deserialized.row_ratios);
}
}
mod ratio_constants_tests {
use super::*;
#[test]
fn test_constants_valid_ranges() {
assert!(MIN_RATIO > 0.0);
assert!(MIN_RATIO < MAX_RATIO);
assert!(MAX_RATIO < 1.0);
assert!(DEFAULT_RATIO >= MIN_RATIO && DEFAULT_RATIO <= MAX_RATIO);
assert!(DEFAULT_SECONDARY_RATIO >= MIN_RATIO && DEFAULT_SECONDARY_RATIO <= MAX_RATIO);
assert!(MAX_RATIOS >= 1);
}
#[test]
fn test_default_ratio_is_half() {
assert_eq!(DEFAULT_RATIO, 0.5);
}
#[test]
fn test_max_ratios_is_five() {
assert_eq!(MAX_RATIOS, 5);
}
}
mod layout_options_tests {
use super::*;
#[test]
fn test_layout_options_default_values() {
let json = r#"{}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_none());
assert!(opts.grid.is_none());
assert!(opts.column_ratios.is_none());
assert!(opts.row_ratios.is_none());
}
#[test]
fn test_layout_options_with_all_fields() {
let json = r#"{
"scrolling": {"columns": 3},
"grid": {"rows": 2},
"column_ratios": [0.3, 0.4],
"row_ratios": [0.5]
}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.scrolling.is_some());
assert_eq!(opts.scrolling.unwrap().columns, 3);
assert!(opts.grid.is_some());
assert_eq!(opts.grid.unwrap().rows, 2);
assert!(opts.column_ratios.is_some());
assert!(opts.row_ratios.is_some());
}
}
mod default_layout_tests {
use super::*;
#[test]
fn test_cycle_next_covers_all_layouts() {
let start = DefaultLayout::BSP;
let mut current = start;
let mut visited = vec![current];
loop {
current = current.cycle_next();
if current == start {
break;
}
assert!(
!visited.contains(&current),
"Cycle contains duplicate: {:?}",
current
);
visited.push(current);
}
// Should have visited all layouts
assert_eq!(visited.len(), 9); // 9 layouts total
}
#[test]
fn test_cycle_previous_is_inverse_of_next() {
// Note: cycle_previous has some inconsistencies in the current implementation
// This test documents the expected behavior for most layouts
let layouts_with_correct_inverse = [
DefaultLayout::Columns,
DefaultLayout::Rows,
DefaultLayout::VerticalStack,
DefaultLayout::HorizontalStack,
DefaultLayout::UltrawideVerticalStack,
DefaultLayout::Grid,
DefaultLayout::RightMainVerticalStack,
];
for layout in layouts_with_correct_inverse {
let next = layout.cycle_next();
assert_eq!(
next.cycle_previous(),
layout,
"cycle_previous should be inverse of cycle_next for {:?}",
layout
);
}
}
#[test]
fn test_leftmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Columns.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Rows.leftmost_index(5), 0);
assert_eq!(DefaultLayout::VerticalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::HorizontalStack.leftmost_index(5), 0);
assert_eq!(DefaultLayout::Grid.leftmost_index(5), 0);
}
#[test]
fn test_leftmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::UltrawideVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_leftmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(2), 1);
assert_eq!(DefaultLayout::RightMainVerticalStack.leftmost_index(5), 1);
}
#[test]
fn test_rightmost_index_standard_layouts() {
assert_eq!(DefaultLayout::BSP.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Columns.rightmost_index(5), 4);
assert_eq!(DefaultLayout::Rows.rightmost_index(5), 4);
assert_eq!(DefaultLayout::VerticalStack.rightmost_index(5), 4);
}
#[test]
fn test_rightmost_index_right_main() {
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::RightMainVerticalStack.rightmost_index(5), 0);
}
#[test]
fn test_rightmost_index_ultrawide() {
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(1), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(2), 0);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(3), 2);
assert_eq!(DefaultLayout::UltrawideVerticalStack.rightmost_index(5), 4);
}
}
}

View File

@@ -1,8 +1,12 @@
use super::DefaultLayout;
use super::OperationDirection;
#[cfg(feature = "win32")]
use super::custom_layout::Column;
#[cfg(feature = "win32")]
use super::custom_layout::ColumnSplit;
#[cfg(feature = "win32")]
use super::custom_layout::ColumnSplitWithCapacity;
#[cfg(feature = "win32")]
use super::custom_layout::CustomLayout;
use crate::default_layout::LayoutOptions;
@@ -400,6 +404,7 @@ fn grid_neighbor(
}
}
#[cfg(feature = "win32")]
impl Direction for CustomLayout {
fn index_in_direction(
&self,

View File

@@ -2,6 +2,7 @@ use serde::Deserialize;
use serde::Serialize;
use super::Arrangement;
#[cfg(feature = "win32")]
use super::CustomLayout;
use super::DefaultLayout;
use super::Direction;
@@ -10,6 +11,7 @@ use super::Direction;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Layout {
Default(DefaultLayout),
#[cfg(feature = "win32")]
Custom(CustomLayout),
}
@@ -18,6 +20,7 @@ impl Layout {
pub fn as_boxed_direction(&self) -> Box<dyn Direction> {
match self {
Layout::Default(layout) => Box::new(*layout),
#[cfg(feature = "win32")]
Layout::Custom(layout) => Box::new(layout.clone()),
}
}
@@ -26,6 +29,7 @@ impl Layout {
pub fn as_boxed_arrangement(&self) -> Box<dyn Arrangement> {
match self {
Layout::Default(layout) => Box::new(*layout),
#[cfg(feature = "win32")]
Layout::Custom(layout) => Box::new(layout.clone()),
}
}

View File

@@ -0,0 +1,30 @@
#![warn(clippy::all)]
#![allow(clippy::missing_errors_doc, clippy::use_self, clippy::doc_markdown)]
//! Layout system for the komorebi window manager.
//!
//! This crate provides the core layout algorithms and types for arranging windows
//! in various configurations. It includes optional Windows-specific functionality
//! behind the `win32` feature flag.
pub mod arrangement;
#[cfg(feature = "win32")]
pub mod custom_layout;
pub mod cycle_direction;
pub mod default_layout;
pub mod direction;
pub mod layout;
pub mod operation_direction;
pub mod rect;
pub mod sizing;
pub use arrangement::*;
#[cfg(feature = "win32")]
pub use custom_layout::*;
pub use cycle_direction::*;
pub use default_layout::*;
pub use direction::*;
pub use layout::*;
pub use operation_direction::*;
pub use rect::*;
pub use sizing::*;

View File

@@ -1,7 +1,18 @@
use serde::Deserialize;
use serde::Serialize;
#[cfg(feature = "win32")]
use windows::Win32::Foundation::RECT;
#[cfg(feature = "darwin")]
use objc2_core_foundation::CGFloat;
#[cfg(feature = "darwin")]
use objc2_core_foundation::CGPoint;
#[cfg(feature = "darwin")]
use objc2_core_foundation::CGRect;
#[cfg(feature = "darwin")]
use objc2_core_foundation::CGSize;
#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Rectangle dimensions
@@ -16,6 +27,7 @@ pub struct Rect {
pub bottom: i32,
}
#[cfg(feature = "win32")]
impl From<RECT> for Rect {
fn from(rect: RECT) -> Self {
Self {
@@ -27,6 +39,7 @@ impl From<RECT> for Rect {
}
}
#[cfg(feature = "win32")]
impl From<Rect> for RECT {
fn from(rect: Rect) -> Self {
Self {
@@ -38,6 +51,53 @@ impl From<Rect> for RECT {
}
}
#[cfg(feature = "darwin")]
impl From<CGSize> for Rect {
fn from(value: CGSize) -> Self {
Self {
left: 0,
top: 0,
right: value.width as i32,
bottom: value.height as i32,
}
}
}
#[cfg(feature = "darwin")]
impl From<CGRect> for Rect {
fn from(value: CGRect) -> Self {
Self {
left: value.origin.x as i32,
top: value.origin.y as i32,
right: value.size.width as i32,
bottom: value.size.height as i32,
}
}
}
#[cfg(feature = "darwin")]
impl From<&Rect> for CGRect {
fn from(value: &Rect) -> Self {
Self {
origin: CGPoint {
x: value.left as CGFloat,
y: value.top as CGFloat,
},
size: CGSize {
width: value.right as CGFloat,
height: value.bottom as CGFloat,
},
}
}
}
#[cfg(feature = "darwin")]
impl From<Rect> for CGRect {
fn from(value: Rect) -> Self {
CGRect::from(&value)
}
}
impl Rect {
pub fn is_same_size_as(&self, rhs: &Self) -> bool {
self.right == rhs.right && self.bottom == rhs.bottom
@@ -96,6 +156,7 @@ impl Rect {
}
}
#[cfg(feature = "win32")]
#[must_use]
pub const fn rect(&self) -> RECT {
RECT {
@@ -105,4 +166,19 @@ impl Rect {
bottom: self.top + self.bottom,
}
}
#[cfg(feature = "darwin")]
#[must_use]
pub fn percentage_within_horizontal_bounds(&self, other: &Rect) -> f64 {
let overlap_left = self.left.max(other.left);
let overlap_right = (self.left + self.right).min(other.left + other.right);
let overlap_width = overlap_right - overlap_left;
if overlap_width <= 0 {
0.0
} else {
(overlap_width as f64) / (other.right as f64) * 100.0
}
}
}

View File

@@ -0,0 +1,31 @@
use clap::ValueEnum;
use serde::Deserialize;
use serde::Serialize;
use strum::Display;
use strum::EnumString;
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Display, EnumString, ValueEnum)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Sizing
pub enum Sizing {
/// Increase
Increase,
/// Decrease
Decrease,
}
impl Sizing {
#[must_use]
pub const fn adjust_by(&self, value: i32, adjustment: i32) -> i32 {
match self {
Self::Increase => value + adjustment,
Self::Decrease => {
if value > 0 && value - adjustment >= 0 {
value - adjustment
} else {
value
}
}
}
}
}

View File

@@ -8,6 +8,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-layouts = { path = "../komorebi-layouts", features = ["win32"] }
komorebi-themes = { path = "../komorebi-themes" }
base64 = "0.22"
@@ -63,4 +64,4 @@ uuid = { version = "1", features = ["v4"] }
[features]
default = ["schemars"]
deadlock_detection = ["parking_lot/deadlock_detection"]
schemars = ["dep:schemars"]
schemars = ["dep:schemars", "komorebi-layouts/schemars"]

View File

@@ -11,7 +11,9 @@ use crate::core::Rect;
use crate::windows_api;
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use windows::Win32::Foundation::FALSE;
@@ -126,6 +128,7 @@ pub struct Border {
pub brush_properties: D2D1_BRUSH_PROPERTIES,
pub rounded_rect: D2D1_ROUNDED_RECT,
pub brushes: HashMap<WindowKind, ID2D1SolidColorBrush>,
pub is_destroying: Arc<AtomicBool>,
}
impl From<isize> for Border {
@@ -144,6 +147,7 @@ impl From<isize> for Border {
brush_properties: D2D1_BRUSH_PROPERTIES::default(),
rounded_rect: D2D1_ROUNDED_RECT::default(),
brushes: HashMap::new(),
is_destroying: Arc::new(AtomicBool::new(false)),
}
}
}
@@ -192,6 +196,7 @@ impl Border {
brush_properties: Default::default(),
rounded_rect: Default::default(),
brushes: HashMap::new(),
is_destroying: Arc::new(AtomicBool::new(false)),
};
let border_pointer = &raw mut border;
@@ -313,6 +318,12 @@ impl Border {
}
pub fn destroy(&self) -> color_eyre::Result<()> {
// signal that we're destroying - prevents new render operations
self.is_destroying.store(true, Ordering::Release);
// small delay to allow in-flight render operations to complete
std::thread::sleep(std::time::Duration::from_millis(10));
// clear user data **BEFORE** closing window
// pending messages will see a null pointer and exit early
unsafe {
@@ -386,6 +397,10 @@ impl Border {
return LRESULT(0);
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
let reference_hwnd = (*border_pointer).tracking_hwnd;
let old_rect = (*border_pointer).window_rect;
@@ -400,6 +415,11 @@ impl Border {
if (!rect.is_same_size_as(&old_rect) || !rect.has_same_position_as(&old_rect))
&& let Some(render_target) = (*border_pointer).render_target.as_ref()
{
// double-check destruction flag before rendering
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
let border_width = (*border_pointer).width;
let border_offset = (*border_pointer).offset;
@@ -468,6 +488,10 @@ impl Border {
return LRESULT(0);
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
let reference_hwnd = (*border_pointer).tracking_hwnd;
// Update position to update the ZOrder
@@ -481,6 +505,11 @@ impl Border {
}
if let Some(render_target) = (*border_pointer).render_target.as_ref() {
// double-check destruction flag before rendering
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
(*border_pointer).width = BORDER_WIDTH.load(Ordering::Relaxed);
(*border_pointer).offset = BORDER_OFFSET.load(Ordering::Relaxed);

View File

@@ -1,310 +0,0 @@
use clap::ValueEnum;
use serde::Deserialize;
use serde::Serialize;
use strum::Display;
use strum::EnumString;
use super::OperationDirection;
use super::Rect;
use super::Sizing;
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// A predefined komorebi layout
pub enum DefaultLayout {
/// BSP Layout
///
/// ```text
/// +-------+-----+
/// | | |
/// | +--+--+
/// | | |--|
/// +-------+--+--+
/// ```
BSP,
/// Columns Layout
///
/// ```text
/// +--+--+--+--+
/// | | | | |
/// | | | | |
/// | | | | |
/// +--+--+--+--+
/// ```
Columns,
/// Rows Layout
///
/// ```text
/// +-----------+
/// |-----------|
/// |-----------|
/// |-----------|
/// +-----------+
/// ```
Rows,
/// Vertical Stack Layout
///
/// ```text
/// +-------+-----+
/// | | |
/// | +-----+
/// | | |
/// +-------+-----+
/// ```
VerticalStack,
/// Horizontal Stack Layout
///
/// ```text
/// +------+------+
/// | |
/// |------+------+
/// | | |
/// +------+------+
/// ```
HorizontalStack,
/// Ultrawide Vertical Stack Layout
///
/// ```text
/// +-----+-----------+-----+
/// | | | |
/// | | +-----+
/// | | | |
/// | | +-----+
/// | | | |
/// +-----+-----------+-----+
/// ```
UltrawideVerticalStack,
/// Grid Layout
///
/// ```text
/// +-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+
/// | | | | | | | | | | | | | | |
/// | | | | | | | | | | | | | +---+
/// +-----+-----+ | +---+---+ +---+---+---+ +---+---| |
/// | | | | | | | | | | | | | +---+
/// | | | | | | | | | | | | | | |
/// +-----+-----+ +---+---+---+ +---+---+---+ +---+---+---+
/// 4 windows 5 windows 6 windows 7 windows
/// ```
Grid,
/// Right Main Vertical Stack Layout
///
/// ```text
/// +-----+-------+
/// | | |
/// +-----+ |
/// | | |
/// +-----+-------+
/// ```
RightMainVerticalStack,
/// Scrolling Layout
///
/// ```text
/// +--+--+--+--+--+--+
/// | | | |
/// | | | |
/// | | | |
/// +--+--+--+--+--+--+
/// ```
Scrolling,
// NOTE: If any new layout is added, please make sure to register the same in `DefaultLayout::cycle`
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for specific layouts
pub struct LayoutOptions {
/// Options related to the Scrolling layout
pub scrolling: Option<ScrollingLayoutOptions>,
/// Options related to the Grid layout
pub grid: Option<GridLayoutOptions>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for the Scrolling layout
pub struct ScrollingLayoutOptions {
/// Desired number of visible columns (default: 3)
pub columns: usize,
/// With an odd number of visible columns, keep the focused window column centered
pub center_focused_column: Option<bool>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for the Grid layout
pub struct GridLayoutOptions {
/// Maximum number of rows per grid column
pub rows: usize,
}
impl DefaultLayout {
pub fn leftmost_index(&self, len: usize) -> usize {
match self {
Self::UltrawideVerticalStack | Self::RightMainVerticalStack => match len {
n if n > 1 => 1,
_ => 0,
},
Self::Scrolling => 0,
DefaultLayout::BSP
| DefaultLayout::Columns
| DefaultLayout::Rows
| DefaultLayout::VerticalStack
| DefaultLayout::HorizontalStack
| DefaultLayout::Grid => 0,
}
}
pub fn rightmost_index(&self, len: usize) -> usize {
match self {
DefaultLayout::BSP
| DefaultLayout::Columns
| DefaultLayout::Rows
| DefaultLayout::VerticalStack
| DefaultLayout::HorizontalStack
| DefaultLayout::Grid => len.saturating_sub(1),
DefaultLayout::UltrawideVerticalStack => match len {
2 => 0,
_ => len.saturating_sub(1),
},
DefaultLayout::RightMainVerticalStack => 0,
DefaultLayout::Scrolling => len.saturating_sub(1),
}
}
#[must_use]
#[allow(clippy::cast_precision_loss, clippy::only_used_in_recursion)]
pub fn resize(
&self,
unaltered: &Rect,
resize: &Option<Rect>,
edge: OperationDirection,
sizing: Sizing,
delta: i32,
) -> Option<Rect> {
if !matches!(
self,
Self::BSP
| Self::Columns
| Self::Rows
| Self::VerticalStack
| Self::RightMainVerticalStack
| Self::HorizontalStack
| Self::UltrawideVerticalStack
| Self::Scrolling
) {
return None;
};
let mut r = resize.unwrap_or_default();
let resize_delta = delta;
match edge {
OperationDirection::Left => match sizing {
Sizing::Increase => {
// Some final checks to make sure the user can't infinitely resize to
// the point of pushing other windows out of bounds
// Note: These checks cannot take into account the changes made to the
// edges of adjacent windows at operation time, so it is still possible
// to push windows out of bounds by maxing out an Increase Left on a
// Window with index 1, and then maxing out a Decrease Right on a Window
// with index 0. I don't think it's worth trying to defensively program
// against this; if people end up in this situation they are better off
// just hitting the retile command
let diff = ((r.left + -resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.left += -resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.left - -resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.left -= -resize_delta;
}
}
},
OperationDirection::Up => match sizing {
Sizing::Increase => {
let diff = ((r.top + resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.top += -resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.top - resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.top -= -resize_delta;
}
}
},
OperationDirection::Right => match sizing {
Sizing::Increase => {
let diff = ((r.right + resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.right += resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.right - resize_delta) as f32).abs();
if diff < unaltered.right as f32 {
r.right -= resize_delta;
}
}
},
OperationDirection::Down => match sizing {
Sizing::Increase => {
let diff = ((r.bottom + resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.bottom += resize_delta;
}
}
Sizing::Decrease => {
let diff = ((r.bottom - resize_delta) as f32).abs();
if diff < unaltered.bottom as f32 {
r.bottom -= resize_delta;
}
}
},
};
if r.eq(&Rect::default()) {
None
} else {
Option::from(r)
}
}
#[must_use]
pub const fn cycle_next(self) -> Self {
match self {
Self::BSP => Self::Columns,
Self::Columns => Self::Rows,
Self::Rows => Self::VerticalStack,
Self::VerticalStack => Self::HorizontalStack,
Self::HorizontalStack => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::Grid,
Self::Grid => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::Scrolling,
Self::Scrolling => Self::BSP,
}
}
#[must_use]
pub const fn cycle_previous(self) -> Self {
match self {
Self::Scrolling => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::Grid,
Self::Grid => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::HorizontalStack,
Self::HorizontalStack => Self::VerticalStack,
Self::VerticalStack => Self::Rows,
Self::Rows => Self::Columns,
Self::Columns => Self::BSP,
Self::BSP => Self::RightMainVerticalStack,
}
}
}

View File

@@ -15,37 +15,42 @@ use strum::EnumString;
use crate::KomorebiTheme;
use crate::animation::prefix::AnimationPrefix;
// Re-export everything from komorebi-layouts
pub use komorebi_layouts::Arrangement;
pub use komorebi_layouts::Axis;
pub use komorebi_layouts::Column;
pub use komorebi_layouts::ColumnSplit;
pub use komorebi_layouts::ColumnSplitWithCapacity;
pub use komorebi_layouts::ColumnWidth;
pub use komorebi_layouts::CustomLayout;
pub use komorebi_layouts::CycleDirection;
pub use komorebi_layouts::DEFAULT_RATIO;
pub use komorebi_layouts::DEFAULT_SECONDARY_RATIO;
pub use komorebi_layouts::DefaultLayout;
pub use komorebi_layouts::Direction;
pub use komorebi_layouts::GridLayoutOptions;
pub use komorebi_layouts::Layout;
pub use komorebi_layouts::LayoutOptions;
pub use komorebi_layouts::MAX_RATIO;
pub use komorebi_layouts::MAX_RATIOS;
pub use komorebi_layouts::MIN_RATIO;
pub use komorebi_layouts::OperationDirection;
pub use komorebi_layouts::Rect;
pub use komorebi_layouts::ScrollingLayoutOptions;
pub use komorebi_layouts::Sizing;
// Local modules and exports
pub use animation::AnimationStyle;
pub use arrangement::Arrangement;
pub use arrangement::Axis;
pub use custom_layout::Column;
pub use custom_layout::ColumnSplit;
pub use custom_layout::ColumnSplitWithCapacity;
pub use custom_layout::ColumnWidth;
pub use custom_layout::CustomLayout;
pub use cycle_direction::CycleDirection;
pub use default_layout::*;
pub use direction::Direction;
pub use layout::Layout;
pub use operation_direction::OperationDirection;
pub use pathext::PathExt;
pub use pathext::ResolvedPathBuf;
pub use pathext::replace_env_in_path;
pub use pathext::resolve_option_hashmap_usize_path;
pub use rect::Rect;
pub mod animation;
pub mod arrangement;
pub mod asc;
pub mod config_generation;
pub mod custom_layout;
pub mod cycle_direction;
pub mod default_layout;
pub mod direction;
pub mod layout;
pub mod operation_direction;
pub mod pathext;
pub mod rect;
// serde_as must be before derive
#[serde_with::serde_as]
@@ -545,32 +550,6 @@ pub enum OperationBehaviour {
NoOp,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Display, EnumString, ValueEnum)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Sizing
pub enum Sizing {
/// Increase
Increase,
/// Decrease
Decrease,
}
impl Sizing {
#[must_use]
pub const fn adjust_by(&self, value: i32, adjustment: i32) -> i32 {
match self {
Self::Increase => value + adjustment,
Self::Decrease => {
if value > 0 && value - adjustment >= 0 {
value - adjustment
} else {
value
}
}
}
}
}
#[derive(
Clone, Copy, Debug, Default, Serialize, Deserialize, Display, EnumString, ValueEnum, PartialEq,
)]

View File

@@ -25,6 +25,7 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI64;
use std::sync::atomic::Ordering;
pub mod hidden;
@@ -44,6 +45,10 @@ pub enum MonitorNotification {
static ACTIVE: AtomicBool = AtomicBool::new(true);
/// Timestamp (epoch millis) of the last DisplayConnectionChange notification.
/// Used to suppress OS-initiated window minimizes during transient display events.
static LAST_DISPLAY_CHANGE_TIMESTAMP: AtomicI64 = AtomicI64::new(0);
static CHANNEL: OnceLock<(Sender<MonitorNotification>, Receiver<MonitorNotification>)> =
OnceLock::new();
@@ -62,11 +67,40 @@ fn event_rx() -> Receiver<MonitorNotification> {
}
pub fn send_notification(notification: MonitorNotification) {
if matches!(
notification,
MonitorNotification::DisplayConnectionChange
| MonitorNotification::ResumingFromSuspendedState
| MonitorNotification::SessionUnlocked
) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
LAST_DISPLAY_CHANGE_TIMESTAMP.store(now, Ordering::SeqCst);
}
if event_tx().try_send(notification).is_err() {
tracing::warn!("channel is full; dropping notification")
}
}
/// Returns true if a display connection change event was received within the
/// last `grace_period` duration. This is used by the event processor to avoid
/// treating OS-initiated minimizes (caused by transient monitor disconnects)
/// as user-initiated minimizes.
pub fn display_change_in_progress(grace_period: std::time::Duration) -> bool {
let last = LAST_DISPLAY_CHANGE_TIMESTAMP.load(Ordering::SeqCst);
if last == 0 {
return false;
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64;
(now - last) < grace_period.as_millis() as i64
}
pub fn insert_in_monitor_cache(serial_or_device_id: &str, monitor: Monitor) {
let dip = DISPLAY_INDEX_PREFERENCES.read();
let mut dip_ids = dip.values();
@@ -89,7 +123,41 @@ where
F: Fn() -> I + Copy,
I: Iterator<Item = Result<win32_display_data::Device, win32_display_data::Error>>,
{
let all_displays = display_provider().flatten().collect::<Vec<_>>();
let mut attempts = 0;
let (displays, errors) = loop {
let (displays, errors): (Vec<_>, Vec<_>) = display_provider().partition(Result::is_ok);
if errors.is_empty() {
break (displays, errors);
}
for err in &errors {
if let Err(e) = err {
tracing::warn!(
"enumerating display in reconciliator (attempt {}): {:?}",
attempts + 1,
e
);
}
}
if attempts < 5 {
attempts += 1;
std::thread::sleep(std::time::Duration::from_millis(150));
continue;
}
break (displays, errors);
};
if !errors.is_empty() {
return Err(color_eyre::eyre::eyre!(
"could not successfully enumerate all displays"
));
}
let all_displays = displays.into_iter().map(Result::unwrap).collect::<Vec<_>>();
let mut serial_id_map = HashMap::new();
@@ -203,6 +271,8 @@ where
border_manager::send_notification(None);
}
// Keep reference to Arc for potential re-locking
let wm_arc = Arc::clone(&wm);
let mut wm = wm.lock();
let initial_state = State::from(wm.as_ref());
@@ -346,12 +416,180 @@ where
continue 'receiver;
}
if initial_monitor_count > attached_devices.len() {
// Handle potential monitor removal with verification
let attached_devices = if initial_monitor_count > attached_devices.len() {
tracing::info!(
"monitor count mismatch ({initial_monitor_count} vs {}), removing disconnected monitors",
"potential monitor removal detected ({initial_monitor_count} vs {}), verifying in 3s",
attached_devices.len()
);
// Release locks before waiting
drop(wm);
drop(monitor_cache);
// Wait 3 seconds for display state to stabilize
std::thread::sleep(std::time::Duration::from_secs(3));
// Re-query the Win32 display APIs
let re_queried_devices = match attached_display_devices(display_provider) {
Ok(devices) => devices,
Err(e) => {
tracing::error!("failed to re-query display devices: {}", e);
continue 'receiver;
}
};
tracing::debug!(
"after verification: wm had {} monitors, initial query found {}, re-query found {}",
initial_monitor_count,
attached_devices.len(),
re_queried_devices.len()
);
// If monitors are back, the removal was transient (spurious event)
// Still try to restore state since windows might have been minimized
if re_queried_devices.len() >= initial_monitor_count {
tracing::info!(
"monitor removal was transient (spurious event), attempting state restoration. Initial: {}, Re-queried: {}",
initial_monitor_count,
re_queried_devices.len()
);
// Re-acquire locks for state restoration
wm = wm_arc.lock();
// Update Win32 data for all monitors
for monitor in wm.monitors_mut() {
for attached in &re_queried_devices {
let serial_number_ids_match =
if let (Some(attached_snid), Some(m_snid)) =
(&attached.serial_number_id, &monitor.serial_number_id)
{
attached_snid.eq(m_snid)
} else {
false
};
if serial_number_ids_match
|| attached.device_id.eq(&monitor.device_id)
{
monitor.id = attached.id;
monitor.device = attached.device.clone();
monitor.device_id = attached.device_id.clone();
monitor.serial_number_id = attached.serial_number_id.clone();
monitor.name = attached.name.clone();
monitor.size = attached.size;
monitor.work_area_size = attached.work_area_size;
}
}
}
// Try to restore windows that might have been minimized
let offset = wm.work_area_offset;
for monitor in wm.monitors_mut() {
let focused_workspace_idx = monitor.focused_workspace_idx();
for (idx, workspace) in monitor.workspaces_mut().iter_mut().enumerate()
{
let is_focused_workspace = idx == focused_workspace_idx;
if is_focused_workspace {
// Restore containers
for container in workspace.containers_mut() {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
tracing::debug!(
"restoring window after transient removal: {}",
window.hwnd
);
WindowsApi::restore_window(window.hwnd);
} else if let Some(window) = container.focused_window() {
tracing::debug!(
"skipping restore of invalid window: {}",
window.hwnd
);
}
}
// Restore maximized window
if let Some(window) = &workspace.maximized_window
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
}
// Restore monocle container
if let Some(container) = &workspace.monocle_container
&& let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
}
// Restore floating windows
for window in workspace.floating_windows() {
if WindowsApi::is_window(window.hwnd) {
WindowsApi::restore_window(window.hwnd);
}
}
}
}
monitor.update_focused_workspace(offset)?;
}
border_manager::send_notification(None);
continue 'receiver;
}
// If monitors are still missing, proceed with actual removal logic
tracing::info!(
"verified monitor removal ({initial_monitor_count} vs {}), removing disconnected monitors",
re_queried_devices.len()
);
// Re-acquire locks for removal processing
wm = wm_arc.lock();
monitor_cache = MONITOR_CACHE
.get_or_init(|| Mutex::new(HashMap::new()))
.lock();
// Make sure that in our state any attached displays have the latest Win32 data
// We must do this again because we dropped the lock and are working with new data
for monitor in wm.monitors_mut() {
for attached in &re_queried_devices {
let serial_number_ids_match =
if let (Some(attached_snid), Some(m_snid)) =
(&attached.serial_number_id, &monitor.serial_number_id)
{
attached_snid.eq(m_snid)
} else {
false
};
if serial_number_ids_match || attached.device_id.eq(&monitor.device_id)
{
monitor.id = attached.id;
monitor.device = attached.device.clone();
monitor.device_id = attached.device_id.clone();
monitor.serial_number_id = attached.serial_number_id.clone();
monitor.name = attached.name.clone();
monitor.size = attached.size;
monitor.work_area_size = attached.work_area_size;
}
}
}
// Use re-queried devices for remaining logic
re_queried_devices
} else {
attached_devices
};
if initial_monitor_count > attached_devices.len() {
tracing::info!("removing disconnected monitors");
// Windows to remove from `known_hwnds`
let mut windows_to_remove = Vec::new();
@@ -584,7 +822,9 @@ where
}
if is_focused_workspace {
if let Some(window) = container.focused_window() {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
tracing::debug!(
"restoring window: {}",
window.hwnd
@@ -596,7 +836,9 @@ where
// first window and show that one
container.focus_window(0);
if let Some(window) = container.focused_window() {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
}
}
@@ -617,7 +859,9 @@ where
|| known_hwnds.contains_key(&window.hwnd)
{
workspace.maximized_window = None;
} else if is_focused_workspace {
} else if is_focused_workspace
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
}
}
@@ -631,7 +875,9 @@ where
if container.windows().is_empty() {
workspace.monocle_container = None;
} else if is_focused_workspace {
if let Some(window) = container.focused_window() {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
} else {
// If the focused window was moved or removed by
@@ -639,7 +885,9 @@ where
// first window and show that one
container.focus_window(0);
if let Some(window) = container.focused_window() {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
WindowsApi::restore_window(window.hwnd);
}
}
@@ -653,7 +901,9 @@ where
if is_focused_workspace {
for window in workspace.floating_windows() {
WindowsApi::restore_window(window.hwnd);
if WindowsApi::is_window(window.hwnd) {
WindowsApi::restore_window(window.hwnd);
}
}
}

View File

@@ -60,9 +60,11 @@ use crate::core::Axis;
use crate::core::BorderImplementation;
use crate::core::FocusFollowsMouseImplementation;
use crate::core::Layout;
use crate::core::LayoutOptions;
use crate::core::MoveBehaviour;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::core::ScrollingLayoutOptions;
use crate::core::Sizing;
use crate::core::SocketMessage;
use crate::core::StateQuery;
@@ -72,8 +74,6 @@ use crate::core::config_generation::IdWithIdentifier;
use crate::core::config_generation::MatchingRule;
use crate::core::config_generation::MatchingStrategy;
use crate::current_virtual_desktop;
use crate::default_layout::LayoutOptions;
use crate::default_layout::ScrollingLayoutOptions;
use crate::monitor::MonitorInformation;
use crate::notify_subscribers;
use crate::stackbar_manager;
@@ -947,6 +947,8 @@ impl WindowManager {
center_focused_column: Default::default(),
}),
grid: None,
column_ratios: None,
row_ratios: None,
},
};

View File

@@ -266,18 +266,33 @@ impl WindowManager {
}
}
WindowManagerEvent::Minimize(_, window) => {
let mut hide = false;
// During transient display connection changes (e.g. monitor
// briefly disconnecting and reconnecting), Windows may fire
// SystemMinimizeStart for windows on the affected monitor.
// We must not treat these OS-initiated minimizes as user
// actions, otherwise the window gets removed from the
// workspace and the reconciliator cannot restore it.
if crate::monitor_reconciliator::display_change_in_progress(
std::time::Duration::from_secs(10),
) {
tracing::debug!(
"ignoring minimize during display connection change for hwnd: {}",
window.hwnd
);
} else {
let mut hide = false;
{
let programmatically_hidden_hwnds = HIDDEN_HWNDS.lock();
if !programmatically_hidden_hwnds.contains(&window.hwnd) {
hide = true;
{
let programmatically_hidden_hwnds = HIDDEN_HWNDS.lock();
if !programmatically_hidden_hwnds.contains(&window.hwnd) {
hide = true;
}
}
}
if hide {
self.focused_workspace_mut()?.remove_window(window.hwnd)?;
self.update_focused_workspace(false, false)?;
if hide {
self.focused_workspace_mut()?.remove_window(window.hwnd)?;
self.update_focused_workspace(false, false)?;
}
}
}
WindowManagerEvent::Hide(_, window) => {

View File

@@ -53,6 +53,7 @@ use crate::core::DefaultLayout;
use crate::core::FocusFollowsMouseImplementation;
use crate::core::HidingBehaviour;
use crate::core::Layout;
use crate::core::LayoutOptions;
use crate::core::MoveBehaviour;
use crate::core::OperationBehaviour;
use crate::core::Rect;
@@ -67,7 +68,6 @@ use crate::core::config_generation::ApplicationOptions;
use crate::core::config_generation::MatchingRule;
use crate::core::config_generation::MatchingStrategy;
use crate::current_virtual_desktop;
use crate::default_layout::LayoutOptions;
use crate::monitor;
use crate::monitor::Monitor;
use crate::monitor_reconciliator;
@@ -325,7 +325,13 @@ impl From<&Workspace> for WorkspaceConfig {
Layout::Custom(_) => None,
})
.flatten(),
layout_options: value.layout_options,
layout_options: {
tracing::debug!(
"Parsing workspace config - layout_options: {:?}",
value.layout_options
);
value.layout_options
},
#[allow(deprecated)]
custom_layout: value
.workspace_config

View File

@@ -28,6 +28,7 @@ use crate::animation::AnimationEngine;
use crate::core::Arrangement;
use crate::core::Axis;
use crate::core::BorderImplementation;
use crate::core::CustomLayout;
use crate::core::CycleDirection;
use crate::core::DefaultLayout;
use crate::core::FocusFollowsMouseImplementation;
@@ -40,7 +41,6 @@ use crate::core::Sizing;
use crate::core::WindowContainerBehaviour;
use crate::core::WindowManagementBehaviour;
use crate::core::config_generation::MatchingRule;
use crate::core::custom_layout::CustomLayout;
use crate::CrossBoundaryBehaviour;
use crate::DATA_DIR;
@@ -243,14 +243,16 @@ impl WindowManager {
if let Some(state_monitor) = state.monitors.elements().get(monitor_idx)
&& let Some(state_workspace) = state_monitor.workspaces().get(workspace_idx)
{
// to make sure padding changes get applied for users after a quick restart
// to make sure padding and layout_options changes get applied for users after a quick restart
let container_padding = workspace.container_padding;
let workspace_padding = workspace.workspace_padding;
let layout_options = workspace.layout_options;
*workspace = state_workspace.clone();
workspace.container_padding = container_padding;
workspace.workspace_padding = workspace_padding;
workspace.layout_options = layout_options;
if state_monitor.focused_workspace_idx() == workspace_idx {
focused_workspace = workspace_idx;

View File

@@ -25,9 +25,9 @@ use crate::core::CustomLayout;
use crate::core::CycleDirection;
use crate::core::DefaultLayout;
use crate::core::Layout;
use crate::core::LayoutOptions;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::default_layout::LayoutOptions;
use crate::lockable_sequence::LockableSequence;
use crate::ring::Ring;
use crate::should_act;
@@ -242,6 +242,12 @@ impl Workspace {
self.wallpaper = config.wallpaper.clone();
self.layout_options = config.layout_options;
tracing::debug!(
"Workspace '{}' loaded layout_options: {:?}",
self.name.as_deref().unwrap_or("unnamed"),
self.layout_options
);
self.workspace_config = Some(config.clone());
Ok(())
@@ -550,6 +556,11 @@ impl Workspace {
} else if let Some(window) = &mut self.maximized_window {
window.maximize();
} else if !self.containers().is_empty() {
tracing::debug!(
"Workspace '{}' update() - self.layout_options before calculate: {:?}",
self.name.as_deref().unwrap_or("unnamed"),
self.layout_options
);
let mut layouts = self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(

View File

@@ -59,7 +59,7 @@
"null"
],
"format": "float",
"default": 50.0
"default": 50
},
"icon_scale": {
"description": "Scale of the icons relative to the font_size [[1.0-2.0]]",
@@ -68,7 +68,7 @@
"null"
],
"format": "float",
"default": 1.399999976158142
"default": 1.4
},
"left_widgets": {
"description": "Left side widgets (ordered left-to-right)",
@@ -99,7 +99,15 @@
},
"monitor": {
"description": "The monitor index or the full monitor options",
"$ref": "#/$defs/MonitorConfigOrIndex"
"anyOf": [
{
"$ref": "#/$defs/MonitorConfigOrIndex"
},
{
"type": "null"
}
],
"default": 0
},
"mouse": {
"description": "Options for mouse interaction on the bar",
@@ -174,7 +182,6 @@
}
},
"required": [
"monitor",
"left_widgets",
"right_widgets"
],

View File

@@ -3294,6 +3294,23 @@
"description": "Options for specific layouts",
"type": "object",
"properties": {
"column_ratios": {
"description": "Column width ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Columns layout: ratios for each column width\n- Used by Grid layout: ratios for column widths\n- Used by BSP, VerticalStack, RightMainVerticalStack: column_ratios[0] as primary split ratio\n- Used by HorizontalStack: column_ratios[0] as primary split ratio (top area height)\n- Used by UltrawideVerticalStack: column_ratios[0] as center ratio, column_ratios[1] as left ratio\n\nColumns without a ratio share remaining space equally.\nExample: `[0.3, 0.4, 0.3]` for 30%-40%-30% columns",
"type": [
"array",
"null"
],
"default": null,
"items": {
"type": [
"number",
"null"
],
"format": "float"
},
"maxItems": 5,
"minItems": 5
},
"grid": {
"description": "Options related to the Grid layout",
"anyOf": [
@@ -3305,6 +3322,23 @@
}
]
},
"row_ratios": {
"description": "Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Rows layout: ratios for each row height\n- Used by Grid layout: ratios for row heights\n\nRows without a ratio share remaining space equally.\nExample: `[0.5, 0.5]` for 50%-50% rows",
"type": [
"array",
"null"
],
"default": null,
"items": {
"type": [
"number",
"null"
],
"format": "float"
},
"maxItems": 5,
"minItems": 5
},
"scrolling": {
"description": "Options related to the Scrolling layout",
"anyOf": [