Compare commits

..

1 Commits

Author SHA1 Message Date
LGUG2Z
2dbf7da249 feat(config): add default_workspace_layout opt
This commit adds a default_workspace_layout opt, which defaults to BSP
to maintain backwards compatibility. This can also be set to "None".

When set to "None" or omitted, the default behaviour for new or
undefined workspaces (i.e. on monitors without config blocks) will be
non-tiling.  Otherwise, the given value will be the default layout
applied.
2026-01-13 08:26:41 -08:00
68 changed files with 1648 additions and 10574 deletions

1
.gitattributes vendored
View File

@@ -1 +0,0 @@
*.json text diff

View File

@@ -13,8 +13,8 @@ on:
- hotfix/*
tags:
- v*
schedule:
- cron: "30 0 * * 0" # Every day at 00:30 UTC
# schedule:
# - cron: "30 0 * * 0" # Every day at 00:30 UTC
workflow_dispatch:
jobs:
@@ -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@v7
- uses: actions/upload-artifact@v5
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@v8
- uses: actions/download-artifact@v6
- 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@v8
- uses: actions/download-artifact@v6
- 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@v8
- uses: actions/download-artifact@v6
- 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

1
.gitignore vendored
View File

@@ -12,4 +12,3 @@ komorebic/applications.json
/.xwin-cache
result
/.direnv
procdump.exe

2054
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,6 @@ members = [
"komorebi",
"komorebi-client",
"komorebi-gui",
"komorebi-layouts",
"komorebic",
"komorebic-no-console",
"komorebi-bar",
@@ -30,13 +29,13 @@ lazy_static = "1"
serde = { version = "1", features = ["derive"] }
serde_json = { package = "serde_json_lenient", version = "0.2" }
serde_yaml = "0.9"
strum = { version = "0.28", features = ["derive"] }
strum = { version = "0.27", features = ["derive"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
parking_lot = "0.12"
paste = "1"
sysinfo = "0.38"
sysinfo = "0.37"
uds_windows = "1"
win32-display-data = { git = "https://github.com/LGUG2Z/win32-display-data", rev = "8c42d8db257d30fe95bc98c2e5cd8f75da861021" }
windows-numerics = { version = "0.3" }

View File

@@ -57,7 +57,17 @@ If you need help doing this you can ask on Discord.
## Note: komorebi for Mac
komorebi for Mac lives [here](https://github.com/LGUG2Z/komorebi-for-mac) :)
If you made your way to this repo looking for [komorebi for
Mac](https://github.com/KomoCorp/komorebi-for-mac), the project is currently
being developed in private with [early access available to GitHub
Sponsors](https://github.com/sponsors/LGUG2Z).
If you want to see how far along development is before signing up for early
access (spoiler: it's very far along!) there is an overview video you can watch
[here](https://www.youtube.com/watch?v=u3eJcsa_MJk).
Sponsors with early access can install komorebi for Mac either by compiling
from source, by using Homebrew, or by using the project's Nix Flake.
## Overview
@@ -79,7 +89,7 @@ Please refer to the [documentation](https://lgug2z.github.io/komorebi) for instr
to [install](https://lgug2z.github.io/komorebi/installation.html) and
[configure](https://lgug2z.github.io/komorebi/example-configurations.html)
_komorebi_, [common workflows](https://lgug2z.github.io/komorebi/common-workflows/komorebi-config-home.html), a complete
[configuration schema reference](https://komorebi-starlight.lgug2z.workers.dev/reference/komorebi-windows/) and a
[configuration schema reference](https://komorebi.lgug2z.com/schema) and a
complete [CLI reference](https://lgug2z.github.io/komorebi/cli/quickstart.html).
## Community
@@ -424,7 +434,7 @@ every `WindowManagerEvent` and `SocketMessage` handled by `komorebi` in a Rust c
Below is a simple example of how to use `komorebi-client` in a basic Rust application.
```rust
// komorebi-client = { git = "https://github.com/LGUG2Z/komorebi" }
// komorebi-client = { git = "https://github.com/LGUG2Z/komorebi", tag = "v0.1.39"}
use anyhow::Result;
use komorebi_client::Notification;

View File

@@ -50,11 +50,6 @@ 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"

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -1,218 +0,0 @@
# System Tray
The System Tray widget brings native Windows system tray icons into
`komorebi-bar`. It intercepts tray icon data by creating a hidden window that
mimics the Windows taskbar, receiving the same broadcast messages that
applications send via `Shell_NotifyIcon`.
## Basic configuration
```json
{
"right_widgets": [
{
"Systray": {
"enable": true
}
}
]
}
```
## Hiding icons
The `hidden_icons` config field accepts a list of rules. Each rule can be either
a plain string or a structured object.
A **plain string** matches the exe name (case-insensitive). This is the original
format, so existing configs continue to work without changes:
```json
"hidden_icons": [
"SecurityHealthSystray.exe",
"PhoneExperienceHost.exe"
]
```
A **structured object** matches one or more icon properties. All specified fields
must match (AND logic). By default matching is exact and case-insensitive.
```json
"hidden_icons": [
{ "exe": "svchost.exe", "tooltip": "Some Specific App" },
{ "guid": "{7820AE73-23E3-4229-82C1-E41CB67D5B9C}" },
{ "tooltip": "App I want hidden" }
]
```
The two forms can be mixed freely:
```json
"hidden_icons": [
"PhoneExperienceHost.exe",
{ "exe": "svchost.exe", "tooltip": "Specific Notification" },
{ "guid": "{7820AE73-23E3-4229-82C1-E41CB67D5B9C}" }
]
```
Available fields for structured rules:
| Field | Description |
|-----------|----------------------------------------------------------|
| `exe` | Executable name (e.g. `"SecurityHealthSystray.exe"`) |
| `tooltip` | Tooltip text shown on hover |
| `guid` | Icon GUID — most stable identifier across app restarts |
### Matching strategies
Each field can be a plain string (exact case-insensitive match) or an object
with `value` and `matching_strategy` for advanced matching. This uses the same
`MatchingStrategy` as komorebi's window rules.
```json
"hidden_icons": [
{
"exe": "explorer.exe",
"tooltip": { "value": "Network", "matching_strategy": "StartsWith" }
}
]
```
The above hides explorer.exe icons whose tooltip starts with "Network", while
leaving other explorer.exe icons visible.
Available strategies:
| Strategy | Description |
|---------------------|---------------------------------------------------|
| `Equals` | Exact match (default when using a plain string) |
| `StartsWith` | Value starts with the given text |
| `EndsWith` | Value ends with the given text |
| `Contains` | Value contains the given text |
| `Regex` | Value matches a regular expression |
| `DoesNotEqual` | Value does not exactly equal the given text |
| `DoesNotStartWith` | Value does not start with the given text |
| `DoesNotEndWith` | Value does not end with the given text |
| `DoesNotContain` | Value does not contain the given text |
All strategies except `Regex` are case-insensitive. For case-insensitive regex,
include `(?i)` in the pattern.
Plain strings and strategy objects can be mixed across fields:
```json
{
"exe": "explorer.exe",
"tooltip": { "value": "notification", "matching_strategy": "Contains" }
}
```
Run komorebi-bar with `RUST_LOG=info` to see the exe, tooltip, and GUID of every
systray icon in the log output.
## Stale icon cleanup
Some applications (e.g. Docker Desktop) may exit without properly removing their
tray icon. The widget detects these stale icons by checking whether the owning
window still exists via the Win32 `IsWindow` API.
### Automatic cleanup
By default, the widget checks for stale icons every 60 seconds. The interval
can be configured with `stale_icons_check_interval` (in seconds). The value is
clamped between 30 and 600. Set to 0 to disable automatic cleanup.
```json
"stale_icons_check_interval": 120
```
### Refresh button
A manual refresh button can be shown by setting `refresh_button`. Clicking it
immediately removes any stale icons.
- `"Visible"` — shows the button in the main icon area
- `"Overflow"` — shows the button in the hidden/overflow section (appears when
the overflow toggle is expanded)
```json
"refresh_button": "Overflow"
```
When set to `"Overflow"`, the overflow toggle arrow will appear even if there are
no hidden icons, so the refresh button remains accessible.
## Info button
An info button can be shown to open a floating panel that lists all systray icons
with their exe name, tooltip, GUID, and visibility status. This is useful for
identifying which icons to filter with `hidden_icons` rules.
- `"Visible"` — shows the button in the main icon area
- `"Overflow"` — shows the button in the hidden/overflow section
```json
"info_button": "Visible"
```
The info panel shows **all** icons, including those hidden by rules or the OS.
Each row shows the icon image, exe name, tooltip, GUID, and whether it is visible.
Copy buttons are provided on the exe, tooltip, and GUID cells for easy copying
(e.g. to paste a GUID into a filter rule).
Like the refresh button, setting `info_button` to `"Overflow"` will make the
overflow toggle arrow appear even if there are no hidden icons.
## Shortcuts button
A button that toggles komorebi-shortcuts. If the shortcuts process is running
it will be killed; otherwise it will be started.
- `"Visible"` — shows the button in the main icon area
- `"Overflow"` — shows the button in the hidden/overflow section
```json
"shortcuts_button": "Visible"
```
Like the other buttons, setting `shortcuts_button` to `"Overflow"` will make the
overflow toggle arrow appear even if there are no hidden icons.
## Mouse interactions
The widget supports left-click, right-click, middle-click, and double-click on
tray icons. Double-click sends the `LeftDoubleClick` action (via systray-util
0.2.0), which delivers `WM_LBUTTONDBLCLK` and `NIN_SELECT` messages to the icon.
## Click fallbacks
Some systray icons register a click callback but never actually respond to click
messages, effectively becoming "zombie" icons from an interaction standpoint. For
known problematic icons, the widget overrides the native click action with a
direct shell command. Fallback commands take priority — if a fallback is defined
for an icon, it always runs regardless of whether the icon reports itself as
clickable.
| Exe | Tooltip condition | Fallback command |
|--------------------------------|-------------------|---------------------------------|
| `SecurityHealthSystray.exe` | any | `start windowsdefender://` |
| `explorer.exe` | ends with `%` | `start ms-settings:apps-volume` |
| `explorer.exe` | empty | `start ms-settings:batterysaver`|
## Full example
```json
{
"Systray": {
"enable": true,
"hidden_icons": [
"SecurityHealthSystray.exe",
{ "exe": "explorer.exe", "tooltip": { "value": "Network", "matching_strategy": "StartsWith" } }
],
"stale_icons_check_interval": 60,
"refresh_button": "Overflow",
"info_button": "Visible",
"shortcuts_button": "Overflow"
}
}
```

View File

@@ -1,40 +0,0 @@
# Komorebi Bar
`komorebi-bar` is a status bar for komorebi that renders on top of the tiling
window manager. It is configured through a `komorebi.bar.json` file, either
alongside your `komorebi.json` or at the path specified in the
`bar_configurations` array.
## Widgets
Widgets are placed in the `left_widgets`, `center_widgets`, or `right_widgets`
arrays. Each widget is an object with the widget type as key and its
configuration as value.
| Widget | Description |
|--------------|--------------------------------------------------------|
| `Komorebi` | Workspaces, layout, focused window, and more |
| `Battery` | Battery level and charging status |
| `Date` | Current date in configurable format |
| `Time` | Current time in configurable format |
| `Media` | Currently playing media information |
| `Memory` | System memory usage |
| `Network` | Network activity and connection status |
| `Storage` | Disk usage information |
| `Update` | Komorebi update notification |
| `Systray` | Windows system tray icons |
Widgets with dedicated documentation pages:
- [System Tray](bar-widgets/systray.md)
> Dedicated pages for the remaining widgets will be added in the future.
## Schema
The full configuration schema is available at
[komorebi-bar.lgug2z.com/schema](https://komorebi-bar.lgug2z.com/schema).
For running a bar on each monitor, see
[Multiple Bar Instances](multiple-bar-instances.md) and
[Multi-Monitor Setup](multi-monitor-setup.md).

View File

@@ -1,337 +0,0 @@
# 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
## Layout Options Rules
You can dynamically change `layout_options` based on the number of containers on a workspace
using `layout_options_rules`. This uses the same threshold-based logic as `layout_rules`:
when the container count is greater than or equal to a threshold, the highest matching
threshold's options are used.
Rules **fully replace** the base `layout_options` when they match. If no rule matches, the
base `layout_options` is used.
### Configuration
```json
{
"monitors": [
{
"workspaces": [
{
"name": "main",
"layout": "VerticalStack",
"layout_options": {
"column_ratios": [0.6],
"row_ratios": [0.4]
},
"layout_options_rules": {
"3": { "column_ratios": [0.55] },
"5": { "column_ratios": [0.3, 0.3, 0.3], "row_ratios": [0.5] }
}
}
]
}
]
}
```
In the example above:
| Container Count | Effective `layout_options` |
|-----------------|---------------------------|
| 1-2 | Base: `column_ratios: [0.6]`, `row_ratios: [0.4]` |
| 3-4 | Rule "3": `column_ratios: [0.55]` (no row_ratios, no scrolling, no grid) |
| 5+ | Rule "5": `column_ratios: [0.3, 0.3, 0.3]`, `row_ratios: [0.5]` |
Rules can include any field that `layout_options` supports: `column_ratios`, `row_ratios`,
`scrolling`, and `grid`. When a rule matches, it completely replaces the base options. Fields
not specified in the matching rule default to their standard defaults (not the base
`layout_options` values).
### Example: Scrolling Layout with Dynamic Columns
```json
{
"layout": "Scrolling",
"layout_options": {
"scrolling": { "columns": 2 }
},
"layout_options_rules": {
"4": { "scrolling": { "columns": 3 } },
"7": { "scrolling": { "columns": 4 } }
}
}
```
This increases the visible scrolling columns as more windows are added.
## Layout Defaults
You can define global per-layout default `layout_options` and `layout_options_rules` using
the top-level `layout_defaults` setting. This avoids repeating the same configuration across
every workspace that uses the same layout.
### Configuration
```json
{
"layout_defaults": {
"VerticalStack": {
"layout_options": { "column_ratios": [0.7] },
"layout_options_rules": {
"2": { "column_ratios": [0.7] },
"3": { "column_ratios": [0.55] },
"5": { "column_ratios": [0.4] }
}
},
"Columns": {
"layout_options": { "column_ratios": [0.3, 0.4] },
"layout_options_rules": {
"4": { "column_ratios": [0.2, 0.3, 0.3] }
}
},
"HorizontalStack": {
"layout_options": { "row_ratios": [0.6] }
}
},
"monitors": [
{
"workspaces": [
{
"name": "main",
"layout": "VerticalStack"
}
]
}
]
}
```
In this example, every workspace using `VerticalStack`, `Columns`, or `HorizontalStack`
automatically gets the global `layout_options` and `layout_options_rules` without needing
to specify them per-workspace. Note that `VerticalStack` only has 2 columns (main + stack),
so only a single `column_ratios` value is meaningful, while `Columns` distributes windows
across multiple columns where additional ratios control each column's width.
### Resolution Cascade
Global defaults act as a fallback. If a workspace defines **either** `layout_options` or
`layout_options_rules`, it **completely replaces** all global `layout_defaults` for that
layout. Global defaults are only used when the workspace has **neither** setting.
Within the effective source (workspace or global):
1. Try threshold match from the rules (highest matching threshold wins)
2. If a rule matches → use it (full replacement of base options)
3. Otherwise → use the base `layout_options`
### Override Examples
| Workspace Config | Global Config | Effective Behavior |
|------------------|---------------|--------------------|
| No `layout_options`, no rules | `layout_defaults` has both | Uses global base + global rules |
| Has `layout_options` only | `layout_defaults` has both | Workspace base only (all globals ignored) |
| Has `layout_options_rules` only | `layout_defaults` has both | Workspace rules only (all globals ignored) |
| Has both | `layout_defaults` has both | All workspace (all globals ignored) |
This "complete replacement" semantic means you never get a mix of workspace and global
settings for the same layout. If you override anything at the workspace level, you take
full control of that layout's options for that workspace.
## 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

@@ -83,7 +83,7 @@ is a crude hack trying to compensate for the insistence of Microsoft Windows
design teams to make custom borders with widths that are actually visible to
the user a thing of the past and removing this capability from the Win32 API.
I know it's buggy, and I know that most of the time it sucks, but this is something
I know it's buggy, and I know that most of the it sucks, but this is something
you should be bring up with the billion dollar company and not with me, the
solo developer.

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.41/schema.bar.json",
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.40/schema.bar.json",
"font_family": "JetBrains Mono",
"theme": {
"palette": "Base16",
@@ -31,22 +31,22 @@
},
{
"Media": {
"enable": false
"enable": true
}
},
{
"Storage": {
"enable": false
"enable": true
}
},
{
"Memory": {
"enable": false
"enable": true
}
},
{
"Network": {
"enable": false,
"enable": true,
"show_activity": true,
"show_total_activity": true
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.41/schema.json",
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.40/schema.json",
"app_specific_configuration_path": "$Env:USERPROFILE/applications.json",
"window_hiding_behaviour": "Cloak",
"cross_monitor_move_behaviour": "Insert",

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-bar"
version = "0.1.42"
version = "0.1.40"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -18,17 +18,15 @@ dirs = { workspace = true }
dunce = { workspace = true }
eframe = { workspace = true }
egui-phosphor = { git = "https://github.com/amPerl/egui-phosphor", rev = "d13688738478ecd12b426e3e74c59d6577a85b59" }
egui_extras = { workspace = true }
font-loader = "0.11"
hotwatch = { workspace = true }
image = "0.25"
lazy_static = { workspace = true }
netdev = "0.41"
netdev = "0.40"
num = "0.4"
num-derive = "0.4"
num-traits = "0.2"
parking_lot = { workspace = true }
regex = "1"
random_word = { version = "0.5", features = ["en"] }
reqwest = { version = "0.12", features = ["blocking"] }
schemars = { workspace = true, optional = true }
@@ -36,8 +34,6 @@ serde = { workspace = true }
serde_json = { workspace = true }
starship-battery = "0.10"
sysinfo = { workspace = true }
systray-util = "0.2.0"
tokio = { version = "1", features = ["rt", "sync", "time"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
which = { workspace = true }

View File

@@ -18,7 +18,6 @@ use crate::render::Color32Ext;
use crate::render::Grouping;
use crate::render::RenderConfig;
use crate::render::RenderExt;
use crate::take_widget_clicked;
use crate::widgets::komorebi::Komorebi;
use crate::widgets::komorebi::MonitorInfo;
use crate::widgets::widget::BarWidget;
@@ -1083,10 +1082,6 @@ impl eframe::App for Komobar {
let frame = render_config.change_frame_on_bar(frame, &ctx.style());
CentralPanel::default().frame(frame).show(ctx, |ui| {
// Variable to store command to execute after widgets are rendered
// This allows widgets to mark clicks as consumed before bar processes them
let mut pending_command: Option<crate::config::MouseMessage> = None;
if let Some(mouse_config) = &self.config.mouse {
let command = if ui
.input(|i| i.pointer.button_double_clicked(PointerButton::Primary))
@@ -1187,9 +1182,9 @@ impl eframe::App for Komobar {
&None
};
// Store the command to execute after widgets are rendered
// This allows widgets to mark clicks as consumed
pending_command = command.clone();
if let Some(command) = command {
command.execute(self.mouse_follows_focus);
}
}
// Apply grouping logic for the bar as a whole
@@ -1321,13 +1316,6 @@ impl eframe::App for Komobar {
});
});
}
// Execute the deferred mouse command only if no widget consumed the click
if let Some(command) = pending_command
&& !take_widget_clicked()
{
command.execute(self.mouse_follows_focus);
}
});
}
}

View File

@@ -15,10 +15,10 @@ use std::path::PathBuf;
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// The `komorebi.bar.json` configuration file reference for `v0.1.42`
/// The `komorebi.bar.json` configuration file reference for `v0.1.40`
pub struct KomobarConfig {
/// Bar height
#[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))]
#[cfg_attr(feature = "schemars", schemars(extend("default" = 50.0)))]
pub height: Option<f32>,
/// Bar padding. Use one value for all sides or use a grouped padding for horizontal and/or
/// vertical definition which can each take a single value for a symmetric padding or two
@@ -621,26 +621,6 @@ extend_enum!(
AllIconsAndTextOnSelected,
});
#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Media widget display format
pub enum MediaDisplayFormat {
/// Show only the media info icon
Icon,
/// Show only the media info text (artist - title)
Text,
/// Show both icon and text
IconAndText,
/// Show only the control buttons (previous, play/pause, next)
ControlsOnly,
/// Show icon with control buttons
IconAndControls,
/// Show text with control buttons
TextAndControls,
/// Show icon, text, and control buttons
Full,
}
#[cfg(test)]
mod tests {
use serde::Deserialize;

View File

@@ -38,8 +38,6 @@ use windows::Win32::UI::WindowsAndMessaging::EnumThreadWindows;
use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId;
use windows_core::BOOL;
use std::sync::atomic::AtomicBool;
pub static MAX_LABEL_WIDTH: AtomicI32 = AtomicI32::new(400);
pub static MONITOR_LEFT: AtomicI32 = AtomicI32::new(0);
pub static MONITOR_TOP: AtomicI32 = AtomicI32::new(0);
@@ -48,20 +46,6 @@ pub static MONITOR_INDEX: AtomicUsize = AtomicUsize::new(0);
pub static BAR_HEIGHT: f32 = 50.0;
pub static DEFAULT_PADDING: f32 = 10.0;
/// Flag to indicate that a widget has consumed a click event this frame.
/// This prevents the bar's global mouse handler from also processing the click.
pub static WIDGET_CLICKED: AtomicBool = AtomicBool::new(false);
/// Mark that a widget has consumed a click event this frame.
pub fn mark_widget_clicked() {
WIDGET_CLICKED.store(true, Ordering::SeqCst);
}
/// Check if a widget has consumed a click event this frame and reset the flag.
pub fn take_widget_clicked() -> bool {
WIDGET_CLICKED.swap(false, Ordering::SeqCst)
}
pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0);
pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0);

View File

@@ -1,6 +1,4 @@
use crate::MAX_LABEL_WIDTH;
use crate::bar::Alignment;
use crate::config::MediaDisplayFormat;
use crate::render::RenderConfig;
use crate::selected_frame::SelectableFrame;
use crate::ui::CustomUi;
@@ -16,7 +14,6 @@ use serde::Deserialize;
use serde::Serialize;
use std::sync::atomic::Ordering;
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
use windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackStatus;
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
@@ -24,31 +21,24 @@ use windows::Media::Control::GlobalSystemMediaTransportControlsSessionPlaybackSt
pub struct MediaConfig {
/// Enable the Media widget
pub enable: bool,
/// Display format of the media widget (defaults to IconAndText)
pub display: Option<MediaDisplayFormat>,
}
impl From<MediaConfig> for Media {
fn from(value: MediaConfig) -> Self {
Self::new(
value.enable,
value.display.unwrap_or(MediaDisplayFormat::IconAndText),
)
Self::new(value.enable)
}
}
#[derive(Clone, Debug)]
pub struct Media {
pub enable: bool,
pub display: MediaDisplayFormat,
pub session_manager: GlobalSystemMediaTransportControlsSessionManager,
}
impl Media {
pub fn new(enable: bool, display: MediaDisplayFormat) -> Self {
pub fn new(enable: bool) -> Self {
Self {
enable,
display,
session_manager: GlobalSystemMediaTransportControlsSessionManager::RequestAsync()
.unwrap()
.join()
@@ -64,58 +54,6 @@ impl Media {
}
}
pub fn previous(&self) {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(op) = session.TrySkipPreviousAsync()
{
op.join().unwrap_or_default();
}
}
pub fn next(&self) {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(op) = session.TrySkipNextAsync()
{
op.join().unwrap_or_default();
}
}
fn is_playing(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(status) = info.PlaybackStatus()
{
return status == GlobalSystemMediaTransportControlsSessionPlaybackStatus::Playing;
}
false
}
fn is_previous_enabled(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(controls) = info.Controls()
&& let Ok(enabled) = controls.IsPreviousEnabled()
{
return enabled;
}
false
}
fn is_next_enabled(&self) -> bool {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(info) = session.GetPlaybackInfo()
&& let Ok(controls) = info.Controls()
&& let Ok(enabled) = controls.IsNextEnabled()
{
return enabled;
}
false
}
fn has_session(&self) -> bool {
self.session_manager.GetCurrentSession().is_ok()
}
fn output(&mut self) -> String {
if let Ok(session) = self.session_manager.GetCurrentSession()
&& let Ok(operation) = session.TryGetMediaPropertiesAsync()
@@ -140,96 +78,28 @@ impl Media {
impl BarWidget for Media {
fn render(&mut self, ctx: &Context, ui: &mut Ui, config: &mut RenderConfig) {
if self.enable {
// Don't render if there's no active media session
if !self.has_session() {
return;
}
let output = self.output();
let show_icon = matches!(
self.display,
MediaDisplayFormat::Icon
| MediaDisplayFormat::IconAndText
| MediaDisplayFormat::IconAndControls
| MediaDisplayFormat::Full
);
let show_text = matches!(
self.display,
MediaDisplayFormat::Text
| MediaDisplayFormat::IconAndText
| MediaDisplayFormat::TextAndControls
| MediaDisplayFormat::Full
);
let show_controls = matches!(
self.display,
MediaDisplayFormat::ControlsOnly
| MediaDisplayFormat::IconAndControls
| MediaDisplayFormat::TextAndControls
| MediaDisplayFormat::Full
);
// Don't render if there's no media info and we're not showing controls-only
if output.is_empty() && !show_controls {
return;
}
let icon_font_id = config.icon_font_id.clone();
let text_font_id = config.text_font_id.clone();
let icon_color = ctx.style().visuals.selection.stroke.color;
let text_color = ctx.style().visuals.text_color();
let mut layout_job = LayoutJob::default();
if show_icon {
layout_job = LayoutJob::simple(
if !output.is_empty() {
let mut layout_job = LayoutJob::simple(
egui_phosphor::regular::HEADPHONES.to_string(),
icon_font_id.clone(),
icon_color,
config.icon_font_id.clone(),
ctx.style().visuals.selection.stroke.color,
100.0,
);
}
if show_text {
layout_job.append(
&output,
if show_icon { 10.0 } else { 0.0 },
10.0,
TextFormat {
font_id: text_font_id,
color: text_color,
font_id: config.text_font_id.clone(),
color: ctx.style().visuals.text_color(),
valign: Align::Center,
..Default::default()
},
);
}
let is_playing = self.is_playing();
let is_previous_enabled = self.is_previous_enabled();
let is_next_enabled = self.is_next_enabled();
let disabled_color = text_color.gamma_multiply(0.5);
let is_reversed = matches!(config.alignment, Some(Alignment::Right));
let prev_color = if is_previous_enabled {
text_color
} else {
disabled_color
};
let next_color = if is_next_enabled {
text_color
} else {
disabled_color
};
let play_pause_icon = if is_playing {
egui_phosphor::regular::PAUSE
} else {
egui_phosphor::regular::PLAY
};
let show_label = |ui: &mut Ui| {
if (show_icon || show_text)
&& SelectableFrame::new(false)
config.apply_on_widget(false, ui, |ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
@@ -239,95 +109,15 @@ impl BarWidget for Media {
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(layout_job.clone()).selectable(false).truncate(),
Label::new(layout_job).selectable(false).truncate(),
)
})
.on_hover_text(&output)
.clicked()
{
self.toggle();
}
};
let show_previous = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
egui_phosphor::regular::SKIP_BACK.to_string(),
icon_font_id.clone(),
prev_color,
100.0,
))
.selectable(false),
)
})
.clicked()
&& is_previous_enabled
{
self.previous();
}
};
let show_play_pause = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
play_pause_icon.to_string(),
icon_font_id.clone(),
text_color,
100.0,
))
.selectable(false),
)
})
.on_hover_text(&output)
.clicked()
{
self.toggle();
}
};
let show_next = |ui: &mut Ui| {
if SelectableFrame::new(false)
.show(ui, |ui| {
ui.add(
Label::new(LayoutJob::simple(
egui_phosphor::regular::SKIP_FORWARD.to_string(),
icon_font_id.clone(),
next_color,
100.0,
))
.selectable(false),
)
})
.clicked()
&& is_next_enabled
{
self.next();
}
};
config.apply_on_widget(false, ui, |ui| {
if is_reversed {
// Right panel renders right-to-left, so reverse order
if show_controls {
show_next(ui);
show_play_pause(ui);
show_previous(ui);
{
self.toggle();
}
show_label(ui);
} else {
// Left/center panel renders left-to-right, normal order
show_label(ui);
if show_controls {
show_previous(ui);
show_play_pause(ui);
show_next(ui);
}
}
});
});
}
}
}
}

View File

@@ -20,8 +20,6 @@ pub mod media;
pub mod memory;
pub mod network;
pub mod storage;
#[cfg(target_os = "windows")]
pub mod systray;
pub mod time;
pub mod update;
pub mod widget;
@@ -94,16 +92,10 @@ impl IconsCache {
pub fn insert_image(&self, id: ImageIconId, image: Arc<ColorImage>) {
self.images.write().unwrap().insert(id, image);
}
/// Removes the cached image and texture for the given icon ID.
pub fn remove(&self, id: &ImageIconId) {
self.images.write().unwrap().remove(id);
self.textures.write().unwrap().1.remove(id);
}
}
#[inline]
pub(crate) fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage {
fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage {
let size = [rgba_image.width() as usize, rgba_image.height() as usize];
let pixels = rgba_image.as_flat_samples();
ColorImage::from_rgba_unmultiplied(size, pixels.as_slice())
@@ -164,8 +156,6 @@ pub enum ImageIconId {
Path(Arc<Path>),
/// Windows HWND handle.
Hwnd(isize),
/// System tray icon identifier.
SystrayIcon(String),
}
impl From<&Path> for ImageIconId {

View File

@@ -33,8 +33,6 @@ pub struct StorageConfig {
/// Show removable disks
#[cfg_attr(feature = "schemars", schemars(extend("default" = true)))]
pub show_removable_disks: Option<bool>,
/// Storage display name
pub storage_display_name: Option<StorageDisplayName>,
/// Select when the current percentage is over this value [[1-100]]
pub auto_select_over: Option<u8>,
/// Hide when the current percentage is under this value [[1-100]]
@@ -50,9 +48,6 @@ impl From<StorageConfig> for Storage {
label_prefix: value.label_prefix.unwrap_or(LabelPrefix::IconAndText),
show_read_only_disks: value.show_read_only_disks.unwrap_or(false),
show_removable_disks: value.show_removable_disks.unwrap_or(true),
storage_display_name: value
.storage_display_name
.unwrap_or(StorageDisplayName::Mount),
auto_select_over: value.auto_select_over.map(|o| o.clamp(1, 100)),
auto_hide_under: value.auto_hide_under.map(|o| o.clamp(1, 100)),
last_updated: Instant::now(),
@@ -60,19 +55,6 @@ impl From<StorageConfig> for Storage {
}
}
#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum StorageDisplayName {
/// Display label as mount point eg. C:\
Mount,
/// Display label as name eg. Local Disk
Name,
/// Display label as mount then name eg. C:\ Local Disk
MountAndName,
/// Display label as name then mount eg. Local Disk C:\
NameAndMount,
}
struct StorageDisk {
label: String,
selected: bool,
@@ -85,7 +67,6 @@ pub struct Storage {
label_prefix: LabelPrefix,
show_read_only_disks: bool,
show_removable_disks: bool,
storage_display_name: StorageDisplayName,
auto_select_over: Option<u8>,
auto_hide_under: Option<u8>,
last_updated: Instant,
@@ -109,17 +90,6 @@ impl Storage {
continue;
}
let mount = disk.mount_point();
let name = disk.name();
let display_name = match self.storage_display_name {
StorageDisplayName::Mount => mount.to_string_lossy(),
StorageDisplayName::Name => name.to_string_lossy(),
StorageDisplayName::MountAndName => {
mount.to_string_lossy() + name.to_string_lossy()
}
StorageDisplayName::NameAndMount => {
name.to_string_lossy() + mount.to_string_lossy()
}
};
let total = disk.total_space();
let available = disk.available_space();
let used = total - available;
@@ -133,7 +103,7 @@ impl Storage {
disks.push(StorageDisk {
label: match self.label_prefix {
LabelPrefix::Text | LabelPrefix::IconAndText => {
format!("{} {}%", display_name, percentage)
format!("{} {}%", mount.to_string_lossy(), percentage)
}
LabelPrefix::None | LabelPrefix::Icon => format!("{percentage}%"),
},

File diff suppressed because it is too large Load Diff

View File

@@ -19,10 +19,6 @@ use crate::widgets::network::Network;
use crate::widgets::network::NetworkConfig;
use crate::widgets::storage::Storage;
use crate::widgets::storage::StorageConfig;
#[cfg(target_os = "windows")]
use crate::widgets::systray::Systray;
#[cfg(target_os = "windows")]
use crate::widgets::systray::SystrayConfig;
use crate::widgets::time::Time;
use crate::widgets::time::TimeConfig;
use crate::widgets::update::Update;
@@ -70,10 +66,6 @@ pub enum WidgetConfig {
/// Storage widget configuration
#[cfg_attr(feature = "schemars", schemars(title = "Storage"))]
Storage(StorageConfig),
/// System Tray widget configuration (Windows only)
#[cfg(target_os = "windows")]
#[cfg_attr(feature = "schemars", schemars(title = "Systray"))]
Systray(SystrayConfig),
/// Time widget configuration
#[cfg_attr(feature = "schemars", schemars(title = "Time"))]
Time(TimeConfig),
@@ -95,8 +87,6 @@ impl WidgetConfig {
WidgetConfig::Memory(config) => Box::new(Memory::from(*config)),
WidgetConfig::Network(config) => Box::new(Network::from(*config)),
WidgetConfig::Storage(config) => Box::new(Storage::from(*config)),
#[cfg(target_os = "windows")]
WidgetConfig::Systray(config) => Box::new(Systray::from(config)),
WidgetConfig::Time(config) => Box::new(Time::from(config.clone())),
WidgetConfig::Update(config) => Box::new(Update::from(*config)),
}
@@ -122,8 +112,6 @@ impl WidgetConfig {
WidgetConfig::Memory(config) => config.enable,
WidgetConfig::Network(config) => config.enable,
WidgetConfig::Storage(config) => config.enable,
#[cfg(target_os = "windows")]
WidgetConfig::Systray(config) => config.enable,
WidgetConfig::Time(config) => config.enable,
WidgetConfig::Update(config) => config.enable,
}

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-client"
version = "0.1.42"
version = "0.1.40"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-gui"
version = "0.1.42"
version = "0.1.40"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -1,26 +0,0 @@
[package]
name = "komorebi-layouts"
version = "0.1.42"
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"]

File diff suppressed because it is too large Load Diff

View File

@@ -1,954 +0,0 @@
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 item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, 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 item in ratios.iter().take(MAX_RATIOS) {
assert_eq!(*item, 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() {
const {
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);
}
}
mod layout_options_rules_tests {
use super::*;
#[test]
fn test_hashmap_deserialization_ratios_only() {
// layout_options_rules entries with only ratios
// Note: ratios must sum to < 1.0 to avoid truncation by validate_ratios
let json = r#"{
"2": {"column_ratios": [0.7]},
"3": {"column_ratios": [0.55]},
"5": {"column_ratios": [0.3, 0.3, 0.3]}
}"#;
let rules: std::collections::HashMap<usize, LayoutOptions> =
serde_json::from_str(json).unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules[&2].column_ratios.unwrap()[0], Some(0.7));
assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.55));
let r5 = rules[&5].column_ratios.unwrap();
assert_eq!(r5[0], Some(0.3));
assert_eq!(r5[1], Some(0.3));
assert_eq!(r5[2], Some(0.3));
// No scrolling/grid in these entries
assert!(rules[&2].scrolling.is_none());
assert!(rules[&2].grid.is_none());
}
#[test]
fn test_hashmap_deserialization_full_options() {
// layout_options_rules entries with full options including scrolling/grid
let json = r#"{
"2": {"column_ratios": [0.7], "scrolling": {"columns": 3}},
"5": {"column_ratios": [0.3, 0.3, 0.3], "grid": {"rows": 2}}
}"#;
let rules: std::collections::HashMap<usize, LayoutOptions> =
serde_json::from_str(json).unwrap();
assert_eq!(rules.len(), 2);
assert_eq!(rules[&2].scrolling.unwrap().columns, 3);
assert!(rules[&2].grid.is_none());
assert!(rules[&5].scrolling.is_none());
assert_eq!(rules[&5].grid.unwrap().rows, 2);
}
#[test]
fn test_rule_entry_with_all_fields() {
let json = r#"{
"column_ratios": [0.6, 0.3],
"scrolling": {"columns": 4, "center_focused_column": true},
"grid": {"rows": 2},
"row_ratios": [0.5]
}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
let col = opts.column_ratios.unwrap();
assert_eq!(col[0], Some(0.6));
assert_eq!(col[1], Some(0.3));
let row = opts.row_ratios.unwrap();
assert_eq!(row[0], Some(0.5));
assert_eq!(opts.scrolling.unwrap().columns, 4);
assert_eq!(opts.scrolling.unwrap().center_focused_column, Some(true));
assert_eq!(opts.grid.unwrap().rows, 2);
}
#[test]
fn test_rule_entry_empty_object_gives_defaults() {
let json = r#"{}"#;
let opts: LayoutOptions = serde_json::from_str(json).unwrap();
assert!(opts.column_ratios.is_none());
assert!(opts.row_ratios.is_none());
assert!(opts.scrolling.is_none());
assert!(opts.grid.is_none());
}
}
mod layout_default_entry_tests {
use super::*;
use std::collections::HashMap;
#[test]
fn test_default_layout_as_hashmap_key() {
let mut map: HashMap<DefaultLayout, &str> = HashMap::new();
map.insert(DefaultLayout::BSP, "bsp");
map.insert(DefaultLayout::VerticalStack, "vstack");
map.insert(DefaultLayout::Columns, "cols");
assert_eq!(map.len(), 3);
assert_eq!(map[&DefaultLayout::BSP], "bsp");
assert_eq!(map[&DefaultLayout::VerticalStack], "vstack");
assert_eq!(map[&DefaultLayout::Columns], "cols");
}
#[test]
fn test_default_layout_hash_consistency() {
// Same variant inserted twice should overwrite
let mut map: HashMap<DefaultLayout, i32> = HashMap::new();
map.insert(DefaultLayout::Grid, 1);
map.insert(DefaultLayout::Grid, 2);
assert_eq!(map.len(), 1);
assert_eq!(map[&DefaultLayout::Grid], 2);
}
#[test]
fn test_layout_default_entry_deserialize_full() {
let json = r#"{
"layout_options": {"column_ratios": [0.7]},
"layout_options_rules": {
"2": {"column_ratios": [0.7]},
"3": {"column_ratios": [0.55]},
"5": {"column_ratios": [0.3, 0.3, 0.3]}
}
}"#;
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
let base = entry.layout_options.unwrap();
assert_eq!(base.column_ratios.unwrap()[0], Some(0.7));
let rules = entry.layout_options_rules.unwrap();
assert_eq!(rules.len(), 3);
assert_eq!(rules[&2].column_ratios.unwrap()[0], Some(0.7));
assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.55));
let r5 = rules[&5].column_ratios.unwrap();
assert_eq!(r5[0], Some(0.3));
assert_eq!(r5[1], Some(0.3));
assert_eq!(r5[2], Some(0.3));
}
#[test]
fn test_layout_default_entry_deserialize_only_base() {
let json = r#"{
"layout_options": {"column_ratios": [0.6]}
}"#;
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
assert!(entry.layout_options.is_some());
assert_eq!(
entry.layout_options.unwrap().column_ratios.unwrap()[0],
Some(0.6)
);
assert!(entry.layout_options_rules.is_none());
}
#[test]
fn test_layout_default_entry_deserialize_only_rules() {
let json = r#"{
"layout_options_rules": {
"3": {"column_ratios": [0.4]}
}
}"#;
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
assert!(entry.layout_options.is_none());
let rules = entry.layout_options_rules.unwrap();
assert_eq!(rules.len(), 1);
assert_eq!(rules[&3].column_ratios.unwrap()[0], Some(0.4));
}
#[test]
fn test_layout_default_entry_deserialize_empty() {
let json = r#"{}"#;
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
assert!(entry.layout_options.is_none());
assert!(entry.layout_options_rules.is_none());
}
#[test]
fn test_layout_default_entry_roundtrip() {
let json = r#"{
"layout_options": {"column_ratios": [0.7]},
"layout_options_rules": {
"2": {"column_ratios": [0.6]},
"5": {"column_ratios": [0.3, 0.3, 0.3]}
}
}"#;
let original: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
let serialized = serde_json::to_string(&original).unwrap();
let deserialized: LayoutDefaultEntry = serde_json::from_str(&serialized).unwrap();
assert_eq!(
original.layout_options.unwrap().column_ratios,
deserialized.layout_options.unwrap().column_ratios
);
let orig_rules = original.layout_options_rules.unwrap();
let deser_rules = deserialized.layout_options_rules.unwrap();
assert_eq!(orig_rules.len(), deser_rules.len());
for (key, orig_opts) in &orig_rules {
let deser_opts = &deser_rules[key];
assert_eq!(orig_opts.column_ratios, deser_opts.column_ratios);
}
}
#[test]
fn test_layout_defaults_full_config_deserialize() {
// Simulate the top-level layout_defaults field
let json = r#"{
"VerticalStack": {
"layout_options": {"column_ratios": [0.7]},
"layout_options_rules": {
"2": {"column_ratios": [0.7]},
"3": {"column_ratios": [0.55]}
}
},
"HorizontalStack": {
"layout_options": {"column_ratios": [0.6]}
},
"Columns": {
"layout_options_rules": {
"4": {"column_ratios": [0.3, 0.3, 0.3]}
}
}
}"#;
let defaults: HashMap<DefaultLayout, LayoutDefaultEntry> =
serde_json::from_str(json).unwrap();
assert_eq!(defaults.len(), 3);
// VerticalStack: has both base and rules
let vs = &defaults[&DefaultLayout::VerticalStack];
assert!(vs.layout_options.is_some());
assert_eq!(vs.layout_options_rules.as_ref().unwrap().len(), 2);
// HorizontalStack: has only base
let hs = &defaults[&DefaultLayout::HorizontalStack];
assert!(hs.layout_options.is_some());
assert!(hs.layout_options_rules.is_none());
// Columns: has only rules
let cols = &defaults[&DefaultLayout::Columns];
assert!(cols.layout_options.is_none());
assert_eq!(cols.layout_options_rules.as_ref().unwrap().len(), 1);
}
#[test]
fn test_layout_default_entry_with_scrolling_and_grid() {
let json = r#"{
"layout_options": {
"column_ratios": [0.5],
"scrolling": {"columns": 3},
"grid": {"rows": 2}
},
"layout_options_rules": {
"4": {
"scrolling": {"columns": 5, "center_focused_column": true}
}
}
}"#;
let entry: LayoutDefaultEntry = serde_json::from_str(json).unwrap();
let base = entry.layout_options.unwrap();
assert_eq!(base.scrolling.unwrap().columns, 3);
assert_eq!(base.grid.unwrap().rows, 2);
let rules = entry.layout_options_rules.unwrap();
let r4 = &rules[&4];
assert_eq!(r4.scrolling.unwrap().columns, 5);
assert_eq!(r4.scrolling.unwrap().center_focused_column, Some(true));
// Rule doesn't inherit base fields - full replacement
assert!(r4.column_ratios.is_none());
assert!(r4.grid.is_none());
}
#[test]
fn test_layout_default_entry_skip_serializing_none() {
// When both fields are None, they should not appear in output
let entry = LayoutDefaultEntry {
layout_options: None,
layout_options_rules: None,
};
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("layout_options"));
assert!(!json.contains("layout_options_rules"));
assert_eq!(json, "{}");
}
}
/// Tests for the complete-replacement cascade logic.
///
/// This mirrors the resolution algorithm in workspace.rs::update():
/// - If the workspace defines EITHER layout_options OR layout_options_rules,
/// it completely replaces the global layout_defaults for this layout.
/// - Global defaults are only used when the workspace has NEITHER setting.
/// - Within the effective source (workspace or global):
/// 1. Try threshold match from rules (highest matching threshold wins)
/// 2. If a rule matches -> use it (full replacement of base)
/// 3. Else -> use the base layout_options
///
/// Since the actual cascade is in workspace.rs (which has heavy WM dependencies),
/// we test the pure algorithm here using the same data structures.
mod cascade_resolution_tests {
use super::*;
/// Simulates the cascade resolution logic from workspace.rs::update().
/// This is a pure function equivalent of the inline code in update().
fn resolve_effective_options(
container_count: usize,
workspace_base: Option<LayoutOptions>,
workspace_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
global_base: Option<LayoutOptions>,
global_rules: &[(usize, LayoutOptions)], // sorted by threshold ascending
) -> Option<LayoutOptions> {
let has_workspace_overrides = workspace_base.is_some() || !workspace_rules.is_empty();
let (effective_base, effective_rules): (Option<LayoutOptions>, &[(usize, LayoutOptions)]) =
if has_workspace_overrides {
(workspace_base, workspace_rules)
} else {
(global_base, global_rules)
};
// Try threshold match from effective rules
let mut matched = None;
for (threshold, opts) in effective_rules {
if container_count >= *threshold {
matched = Some(*opts);
}
}
// If a rule matched, use it (full replacement); otherwise use effective base
if matched.is_some() {
matched
} else {
effective_base
}
}
fn opts_with_ratio(ratio: f32) -> LayoutOptions {
layout_options_with_column_ratios(&[ratio])
}
// --- No overrides ---
#[test]
fn test_no_workspace_no_global_returns_none() {
let result = resolve_effective_options(3, None, &[], None, &[]);
assert!(result.is_none());
}
// --- Base-only scenarios ---
#[test]
fn test_workspace_base_only() {
let ws_base = opts_with_ratio(0.7);
let result = resolve_effective_options(3, Some(ws_base), &[], None, &[]);
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
#[test]
fn test_global_base_only() {
let global_base = opts_with_ratio(0.6);
let result = resolve_effective_options(3, None, &[], Some(global_base), &[]);
assert_eq!(result.unwrap().column_ratios, global_base.column_ratios);
}
#[test]
fn test_workspace_base_overrides_all_globals() {
// Workspace has base → globals (both base and rules) are ignored entirely
let ws_base = opts_with_ratio(0.7);
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(2, opts_with_ratio(0.5))];
let result =
resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules);
// Workspace base wins; global rules are NOT used even though they would match
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
// --- Rules-only scenarios ---
#[test]
fn test_global_rules_match() {
let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))];
// 3 containers: matches threshold 2, not 4
let result = resolve_effective_options(3, None, &[], None, &global_rules);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6));
}
#[test]
fn test_global_rules_highest_matching_threshold_wins() {
let global_rules = vec![(2, opts_with_ratio(0.6)), (4, opts_with_ratio(0.5))];
// 5 containers: matches both thresholds 2 and 4; highest (4) wins
let result = resolve_effective_options(5, None, &[], None, &global_rules);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5));
}
#[test]
fn test_global_rules_no_match_falls_through_to_none() {
let global_rules = vec![(5, opts_with_ratio(0.5))];
// 3 containers: doesn't match threshold 5
let result = resolve_effective_options(3, None, &[], None, &global_rules);
assert!(result.is_none());
}
#[test]
fn test_global_rules_no_match_falls_through_to_global_base() {
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(5, opts_with_ratio(0.5))];
// 3 containers: doesn't match threshold 5, falls back to global base
let result = resolve_effective_options(3, None, &[], Some(global_base), &global_rules);
assert_eq!(result.unwrap().column_ratios, global_base.column_ratios);
}
#[test]
fn test_workspace_rules_override_global_rules() {
let ws_rules = vec![(2, opts_with_ratio(0.8))];
let global_rules = vec![(2, opts_with_ratio(0.6))];
// Workspace has rules → global rules are ignored entirely
let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
}
// --- Complete replacement: workspace having EITHER setting disables ALL globals ---
#[test]
fn test_workspace_rules_disable_global_base() {
// Workspace has rules but no base. Global has base.
// Since workspace has a setting, globals are completely replaced.
let ws_rules = vec![(2, opts_with_ratio(0.8))];
let global_base = opts_with_ratio(0.6);
// Rule matches → use it. Global base is NOT available as fallback.
let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
}
#[test]
fn test_workspace_rules_no_match_does_not_fall_to_global_base() {
// Workspace has rules (but they don't match). Global has base.
// Since workspace has a setting, globals are completely replaced → returns None.
let ws_rules = vec![(5, opts_with_ratio(0.8))];
let global_base = opts_with_ratio(0.6);
let result = resolve_effective_options(3, None, &ws_rules, Some(global_base), &[]);
// No workspace base, no rule match, globals ignored → None
assert!(result.is_none());
}
#[test]
fn test_workspace_base_disables_global_rules() {
// Workspace has base but no rules. Global has rules.
// Since workspace has a setting, globals are completely replaced.
let ws_base = opts_with_ratio(0.7);
let global_rules = vec![(2, opts_with_ratio(0.5))];
// No workspace rules → no rule match → use workspace base. Global rules ignored.
let result = resolve_effective_options(3, Some(ws_base), &[], None, &global_rules);
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
#[test]
fn test_workspace_base_disables_global_rules_and_base() {
// Workspace has base. Global has both rules and base.
// Since workspace has a setting, all globals are completely replaced.
let ws_base = opts_with_ratio(0.7);
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(2, opts_with_ratio(0.5))];
let result =
resolve_effective_options(3, Some(ws_base), &[], Some(global_base), &global_rules);
// Only workspace base is used; global rules and base are both ignored
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
#[test]
fn test_workspace_rules_disable_global_rules_and_base() {
// Workspace has rules. Global has both rules and base.
// Since workspace has a setting, all globals are completely replaced.
let ws_rules = vec![(2, opts_with_ratio(0.8))];
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(2, opts_with_ratio(0.5))];
let result =
resolve_effective_options(3, None, &ws_rules, Some(global_base), &global_rules);
// Workspace rule matches → 0.8. Global base and rules both ignored.
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
}
// --- Full replacement semantics (rule match replaces base) ---
#[test]
fn test_rule_match_is_full_replacement_not_merge() {
// When a rule matches, its options FULLY REPLACE the base.
// Fields not specified in the rule default to their standard defaults.
let ws_base = layout_options_with_ratios(&[0.7], &[0.4]);
let rule_opts = layout_options_with_column_ratios(&[0.5]);
// rule_opts has column_ratios but no row_ratios
let ws_rules = vec![(2, rule_opts)];
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
let effective = result.unwrap();
// Column ratios come from the rule
assert_eq!(effective.column_ratios.unwrap()[0], Some(0.5));
// Row ratios are NOT inherited from ws_base - they're None (full replacement)
assert!(effective.row_ratios.is_none());
}
// --- Edge cases ---
#[test]
fn test_exact_threshold_match() {
let rules = vec![(3, opts_with_ratio(0.6))];
let result = resolve_effective_options(3, None, &rules, None, &[]);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.6));
}
#[test]
fn test_container_count_one_below_threshold() {
let rules = vec![(3, opts_with_ratio(0.6))];
let result = resolve_effective_options(2, None, &rules, None, &[]);
assert!(result.is_none());
}
#[test]
fn test_zero_containers() {
let ws_base = opts_with_ratio(0.7);
let rules = vec![(1, opts_with_ratio(0.5))];
let result = resolve_effective_options(0, Some(ws_base), &rules, None, &[]);
// 0 containers doesn't match threshold 1 → falls back to workspace base
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
#[test]
fn test_many_thresholds_correct_match() {
let rules = vec![
(1, opts_with_ratio(0.8)),
(3, opts_with_ratio(0.6)),
(5, opts_with_ratio(0.4)),
(8, opts_with_ratio(0.3)),
];
// 6 containers: matches 1, 3, 5 but not 8. Highest match is 5.
let result = resolve_effective_options(6, None, &rules, None, &[]);
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.4));
}
#[test]
fn test_workspace_rules_disable_global_rules_even_if_ws_rules_dont_match() {
// Key behavior: if workspace has ANY setting, globals are entirely ignored.
// Even if workspace rules don't match, we don't fall back to global rules.
let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high
let global_rules = vec![(2, opts_with_ratio(0.5))]; // would match
let result = resolve_effective_options(3, None, &ws_rules, None, &global_rules);
// Workspace has rules → all globals ignored. WS rules don't match → None.
assert!(result.is_none());
}
#[test]
fn test_all_four_sources_present_rules_match() {
// All four sources present: workspace base, workspace rules, global base, global rules
let ws_base = opts_with_ratio(0.7);
let ws_rules = vec![(2, opts_with_ratio(0.8))];
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(2, opts_with_ratio(0.5))];
let result = resolve_effective_options(
3,
Some(ws_base),
&ws_rules,
Some(global_base),
&global_rules,
);
// Workspace has settings → uses workspace only. Rule matches → 0.8
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.8));
}
#[test]
fn test_all_four_sources_present_rules_no_match() {
// All four sources present, but workspace rules don't match
let ws_base = opts_with_ratio(0.7);
let ws_rules = vec![(10, opts_with_ratio(0.8))]; // threshold too high
let global_base = opts_with_ratio(0.6);
let global_rules = vec![(10, opts_with_ratio(0.5))]; // also too high
let result = resolve_effective_options(
3,
Some(ws_base),
&ws_rules,
Some(global_base),
&global_rules,
);
// Workspace has settings → uses workspace only. No rule match → workspace base 0.7
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
// --- Workspace with both base and rules ---
#[test]
fn test_workspace_both_rule_matches() {
let ws_base = opts_with_ratio(0.7);
let ws_rules = vec![(2, opts_with_ratio(0.5))];
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
// Rule matches → use rule (full replacement), not ws_base
assert_eq!(result.unwrap().column_ratios.unwrap()[0], Some(0.5));
}
#[test]
fn test_workspace_both_rule_no_match() {
let ws_base = opts_with_ratio(0.7);
let ws_rules = vec![(10, opts_with_ratio(0.5))];
let result = resolve_effective_options(3, Some(ws_base), &ws_rules, None, &[]);
// Rule doesn't match → fall back to ws_base
assert_eq!(result.unwrap().column_ratios, ws_base.column_ratios);
}
}

View File

@@ -1,30 +0,0 @@
#![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,31 +0,0 @@
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

@@ -1,10 +1,10 @@
[package]
name = "komorebi-themes"
version = "0.1.42"
version = "0.1.40"
edition = "2024"
[dependencies]
base16-egui-themes = { git = "https://github.com/LGUG2Z/base16-egui-themes", rev = "3f157904c641f0dc80f043449fe0214fc4182425" }
base16-egui-themes = { git = "https://github.com/LGUG2Z/base16-egui-themes", rev = "b9e26b31f7a0e7ed239b14e5317e95d1bdc544bd" }
#catppuccin-egui = { version = "5", default-features = false, features = ["egui32"] }
catppuccin-egui = { git = "https://github.com/LGUG2Z/catppuccin-egui", rev = "b2f95cbf441d1dd99f3c955ef10dcb84ce23c20a", default-features = false, features = [
"egui33",
@@ -15,7 +15,7 @@ serde = { workspace = true }
serde_variant = "0.1"
strum = { workspace = true }
hex_color = { version = "3", features = ["serde"] }
flavours = { git = "https://github.com/LGUG2Z/flavours", rev = "24518c129918fe3260aa559eded7657e50752cb1" }
flavours = { git = "https://github.com/LGUG2Z/flavours", version = "0.7.2" }
[features]
default = ["schemars"]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi"
version = "0.1.42"
version = "0.1.40"
description = "A tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"
@@ -8,7 +8,6 @@ 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"
@@ -51,8 +50,8 @@ windows-numerics = { workspace = true }
windows-implement = { workspace = true }
windows-interface = { workspace = true }
winput = "0.2"
winreg = "0.56"
serde_with = { version = "3.19", features = ["schemars_1"] }
winreg = "0.55"
serde_with = { version = "3.12", features = ["schemars_1"] }
[build-dependencies]
shadow-rs = { workspace = true }
@@ -64,4 +63,4 @@ uuid = { version = "1", features = ["v4"] }
[features]
default = ["schemars"]
deadlock_detection = ["parking_lot/deadlock_detection"]
schemars = ["dep:schemars", "komorebi-layouts/schemars"]
schemars = ["dep:schemars"]

View File

@@ -86,7 +86,6 @@ impl AnimationEngine {
{
// cancel animation
ANIMATION_MANAGER.lock().cancel(animation_key.as_str());
render_dispatcher.cleanup_on_cancel();
return Ok(());
}

View File

@@ -1,363 +0,0 @@
use color_eyre::eyre;
use crossbeam_channel::Sender;
use crossbeam_channel::bounded;
use crossbeam_channel::unbounded;
use std::sync::OnceLock;
use std::time::Duration;
use windows::Win32::Foundation::HWND;
use windows::Win32::Foundation::LPARAM;
use windows::Win32::Foundation::LRESULT;
use windows::Win32::Foundation::RECT;
use windows::Win32::Foundation::WPARAM;
use windows::Win32::Graphics::Dwm::DWM_THUMBNAIL_PROPERTIES;
use windows::Win32::Graphics::Dwm::DWM_TNP_OPACITY;
use windows::Win32::Graphics::Dwm::DWM_TNP_RECTDESTINATION;
use windows::Win32::Graphics::Dwm::DWM_TNP_SOURCECLIENTAREAONLY;
use windows::Win32::Graphics::Dwm::DWM_TNP_VISIBLE;
use windows::Win32::UI::WindowsAndMessaging::DefWindowProcW;
use windows::Win32::UI::WindowsAndMessaging::DestroyWindow;
use windows::Win32::UI::WindowsAndMessaging::DispatchMessageW;
use windows::Win32::UI::WindowsAndMessaging::HWND_TOP;
use windows::Win32::UI::WindowsAndMessaging::MSG;
use windows::Win32::UI::WindowsAndMessaging::PM_REMOVE;
use windows::Win32::UI::WindowsAndMessaging::PeekMessageW;
use windows::Win32::UI::WindowsAndMessaging::SET_WINDOW_POS_FLAGS;
use windows::Win32::UI::WindowsAndMessaging::SHOW_WINDOW_CMD;
use windows::Win32::UI::WindowsAndMessaging::SWP_NOACTIVATE;
use windows::Win32::UI::WindowsAndMessaging::SWP_NOREDRAW;
use windows::Win32::UI::WindowsAndMessaging::SWP_NOZORDER;
use windows::Win32::UI::WindowsAndMessaging::SWP_SHOWWINDOW;
use windows::Win32::UI::WindowsAndMessaging::SetWindowPos;
use windows::Win32::UI::WindowsAndMessaging::ShowWindow;
use windows::Win32::UI::WindowsAndMessaging::TranslateMessage;
use windows::Win32::UI::WindowsAndMessaging::WNDCLASSW;
use windows::core::PCWSTR;
use crate::WindowsApi;
use crate::core::Rect;
use crate::windows_api;
const GHOST_CLASS_NAME: &[u16] = &[
b'k' as u16,
b'o' as u16,
b'm' as u16,
b'o' as u16,
b'r' as u16,
b'e' as u16,
b'b' as u16,
b'i' as u16,
b'-' as u16,
b'g' as u16,
b'h' as u16,
b'o' as u16,
b's' as u16,
b't' as u16,
0,
];
enum GhostCmd {
Create {
src_hwnd: isize,
start_rect: Rect,
z_above: Option<isize>,
reply: Sender<eyre::Result<(isize, isize)>>,
},
UpdateRect {
host_hwnd: isize,
hthumb: isize,
rect: Rect,
},
Destroy {
host_hwnd: isize,
hthumb: isize,
},
}
struct GhostOwner {
cmd_tx: Sender<GhostCmd>,
}
static GHOST_OWNER: OnceLock<GhostOwner> = OnceLock::new();
fn ghost_owner() -> &'static GhostOwner {
GHOST_OWNER.get_or_init(|| {
let (tx, rx) = unbounded::<GhostCmd>();
std::thread::Builder::new()
.name("komorebi-ghost-owner".into())
.spawn(move || run_owner_loop(rx))
.expect("failed to spawn ghost owner thread");
GhostOwner { cmd_tx: tx }
})
}
/// Eagerly initialise the ghost owner thread so the first movement animation
/// doesn't pay the spawn + class-registration cost. Idempotent. No-op for
/// users who never enable ghost movement only if it isn't called; calling
/// from a code path that's gated on `GHOST_MOVEMENT_ENABLED` keeps the lazy
/// guarantee.
pub fn prewarm() {
let _ = ghost_owner();
}
extern "system" fn ghost_wnd_proc(hwnd: HWND, msg: u32, wparam: WPARAM, lparam: LPARAM) -> LRESULT {
unsafe { DefWindowProcW(hwnd, msg, wparam, lparam) }
}
fn register_ghost_class() -> eyre::Result<()> {
let h_module = WindowsApi::module_handle_w()?;
let class_name = PCWSTR(GHOST_CLASS_NAME.as_ptr());
let window_class = WNDCLASSW {
hInstance: h_module.into(),
lpszClassName: class_name,
lpfnWndProc: Some(ghost_wnd_proc),
..Default::default()
};
// RegisterClassW returns 0 on failure with ERROR_CLASS_ALREADY_EXISTS as a
// benign error if the class is already registered. We tolerate that.
let _ = WindowsApi::register_class_w(&window_class);
Ok(())
}
fn run_owner_loop(cmd_rx: crossbeam_channel::Receiver<GhostCmd>) {
if let Err(error) = register_ghost_class() {
tracing::error!("ghost owner: failed to register class: {error}");
return;
}
loop {
// Drain any pending Win32 messages (DWM/system messages destined for our hosts).
unsafe {
let mut msg = MSG::default();
while PeekMessageW(&mut msg, None, 0, 0, PM_REMOVE).as_bool() {
let _ = TranslateMessage(&msg);
DispatchMessageW(&msg);
}
}
match cmd_rx.recv_timeout(Duration::from_millis(8)) {
Ok(cmd) => handle_cmd(cmd),
Err(crossbeam_channel::RecvTimeoutError::Timeout) => continue,
Err(crossbeam_channel::RecvTimeoutError::Disconnected) => break,
}
}
}
fn handle_cmd(cmd: GhostCmd) {
match cmd {
GhostCmd::Create {
src_hwnd,
start_rect,
z_above,
reply,
} => {
let result = create_ghost(src_hwnd, start_rect, z_above);
let _ = reply.send(result);
}
GhostCmd::UpdateRect {
host_hwnd,
hthumb,
rect,
} => {
if let Err(error) = update_ghost(host_hwnd, hthumb, rect) {
tracing::trace!("ghost owner: update failed: {error}");
}
}
GhostCmd::Destroy { host_hwnd, hthumb } => {
destroy_ghost(host_hwnd, hthumb);
}
}
}
fn instance_handle() -> eyre::Result<isize> {
let h_module = WindowsApi::module_handle_w()?;
Ok(h_module.0 as isize)
}
fn create_ghost(
src_hwnd: isize,
start_rect: Rect,
z_above: Option<isize>,
) -> eyre::Result<(isize, isize)> {
let class_name = PCWSTR(GHOST_CLASS_NAME.as_ptr());
let host_hwnd = WindowsApi::create_ghost_host_window(class_name, instance_handle()?)?;
// Position the host at start_rect (Rect uses left/top + width/height).
let z_after = match z_above {
Some(hwnd) => HWND(windows_api::as_ptr!(hwnd)),
None => HWND_TOP,
};
let flags = SWP_NOACTIVATE | SWP_NOREDRAW | SWP_SHOWWINDOW;
unsafe {
let _ = SetWindowPos(
HWND(windows_api::as_ptr!(host_hwnd)),
Option::from(z_after),
start_rect.left,
start_rect.top,
start_rect.right,
start_rect.bottom,
flags,
);
}
let hthumb = match WindowsApi::dwm_register_thumbnail(host_hwnd, src_hwnd) {
Ok(h) => h,
Err(error) => {
unsafe {
let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd)));
}
return Err(error);
}
};
let props = thumbnail_properties(start_rect.right, start_rect.bottom);
if let Err(error) = WindowsApi::dwm_update_thumbnail_properties(hthumb, &props) {
let _ = WindowsApi::dwm_unregister_thumbnail(hthumb);
unsafe {
let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd)));
}
return Err(error);
}
// Make the host visible. Layered/transparent ext styles ensure no input.
unsafe {
let _ = ShowWindow(
HWND(windows_api::as_ptr!(host_hwnd)),
SHOW_WINDOW_CMD(8), // SW_SHOWNA
);
}
Ok((host_hwnd, hthumb))
}
fn update_ghost(host_hwnd: isize, hthumb: isize, rect: Rect) -> eyre::Result<()> {
let flags: SET_WINDOW_POS_FLAGS = SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOREDRAW;
unsafe {
SetWindowPos(
HWND(windows_api::as_ptr!(host_hwnd)),
None,
rect.left,
rect.top,
rect.right,
rect.bottom,
flags,
)?;
}
let props = thumbnail_properties(rect.right, rect.bottom);
WindowsApi::dwm_update_thumbnail_properties(hthumb, &props)
}
fn destroy_ghost(host_hwnd: isize, hthumb: isize) {
let _ = WindowsApi::dwm_unregister_thumbnail(hthumb);
unsafe {
let _ = DestroyWindow(HWND(windows_api::as_ptr!(host_hwnd)));
}
}
fn thumbnail_properties(width: i32, height: i32) -> DWM_THUMBNAIL_PROPERTIES {
DWM_THUMBNAIL_PROPERTIES {
dwFlags: DWM_TNP_VISIBLE
| DWM_TNP_RECTDESTINATION
| DWM_TNP_OPACITY
| DWM_TNP_SOURCECLIENTAREAONLY,
rcDestination: RECT {
left: 0,
top: 0,
right: width,
bottom: height,
},
rcSource: RECT::default(),
opacity: 255,
fVisible: true.into(),
fSourceClientAreaOnly: false.into(),
}
}
/// A live DWM-thumbnail "ghost" of a source window, used during movement
/// animations. While a ghost is active, the source window is typically cloaked
/// by the caller. The ghost is automatically disposed on drop, but callers
/// should prefer explicit `dispose()` to surface errors.
pub struct GhostWindow {
host_hwnd: isize,
hthumb: isize,
disposed: bool,
}
impl GhostWindow {
pub fn create(src_hwnd: isize, start_rect: Rect, z_above: Option<isize>) -> eyre::Result<Self> {
let (reply_tx, reply_rx) = bounded::<eyre::Result<(isize, isize)>>(1);
ghost_owner()
.cmd_tx
.send(GhostCmd::Create {
src_hwnd,
start_rect,
z_above,
reply: reply_tx,
})
.map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}"))?;
let (host_hwnd, hthumb) = reply_rx.recv()??;
Ok(Self {
host_hwnd,
hthumb,
disposed: false,
})
}
pub fn host_hwnd(&self) -> isize {
self.host_hwnd
}
pub fn update_rect(&self, rect: Rect) -> eyre::Result<()> {
ghost_owner()
.cmd_tx
.send(GhostCmd::UpdateRect {
host_hwnd: self.host_hwnd,
hthumb: self.hthumb,
rect,
})
.map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}"))
}
/// Apply an opacity change directly via `DwmUpdateThumbnailProperties` on
/// the calling thread. Unlike rect updates (which call `SetWindowPos` and
/// therefore need the owner thread), opacity-only updates don't have
/// thread affinity, and going through the channel introduces a race where
/// the next `DwmFlush()` on the caller's thread can fire before the owner
/// has processed the SetOpacity command — which collapses what should be
/// a multi-frame fade into a single visible step.
pub fn set_opacity(&self, opacity: u8) -> eyre::Result<()> {
let props = DWM_THUMBNAIL_PROPERTIES {
dwFlags: DWM_TNP_OPACITY | DWM_TNP_VISIBLE,
rcDestination: RECT::default(),
rcSource: RECT::default(),
opacity,
fVisible: true.into(),
fSourceClientAreaOnly: false.into(),
};
WindowsApi::dwm_update_thumbnail_properties(self.hthumb, &props)
}
pub fn dispose(mut self) -> eyre::Result<()> {
self.dispose_inner()
}
fn dispose_inner(&mut self) -> eyre::Result<()> {
if self.disposed {
return Ok(());
}
self.disposed = true;
ghost_owner()
.cmd_tx
.send(GhostCmd::Destroy {
host_hwnd: self.host_hwnd,
hthumb: self.hthumb,
})
.map_err(|e| eyre::eyre!("ghost owner channel send failed: {e}"))
}
}
impl Drop for GhostWindow {
fn drop(&mut self) {
let _ = self.dispose_inner();
}
}

View File

@@ -13,7 +13,6 @@ use parking_lot::Mutex;
pub use engine::AnimationEngine;
pub mod animation_manager;
pub mod engine;
pub mod ghost;
pub mod lerp;
pub mod prefix;
pub mod render_dispatcher;
@@ -60,7 +59,6 @@ pub const DEFAULT_ANIMATION_ENABLED: bool = false;
pub const DEFAULT_ANIMATION_STYLE: AnimationStyle = AnimationStyle::Linear;
pub const DEFAULT_ANIMATION_DURATION: u64 = 250;
pub const DEFAULT_ANIMATION_FPS: u64 = 60;
pub const DEFAULT_GHOST_MOVEMENT: bool = true;
lazy_static! {
pub static ref ANIMATION_MANAGER: Arc<Mutex<AnimationManager>> =
@@ -80,4 +78,3 @@ lazy_static! {
}
pub static ANIMATION_FPS: AtomicU64 = AtomicU64::new(DEFAULT_ANIMATION_FPS);
pub static GHOST_MOVEMENT_ENABLED: AtomicBool = AtomicBool::new(DEFAULT_GHOST_MOVEMENT);

View File

@@ -5,10 +5,4 @@ pub trait RenderDispatcher {
fn pre_render(&self) -> eyre::Result<()>;
fn render(&self, delta: f64) -> eyre::Result<()>;
fn post_render(&self) -> eyre::Result<()>;
/// Called by the animation engine when an in-flight animation is cancelled
/// before it could complete. Implementors should use this to release any
/// resources allocated in `pre_render` and bring the underlying window
/// back to a consistent visible state. Default: no-op.
fn cleanup_on_cancel(&self) {}
}

View File

@@ -11,9 +11,7 @@ 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;
@@ -58,7 +56,6 @@ use windows::Win32::UI::WindowsAndMessaging::GetWindowLongPtrW;
use windows::Win32::UI::WindowsAndMessaging::IDC_ARROW;
use windows::Win32::UI::WindowsAndMessaging::LoadCursorW;
use windows::Win32::UI::WindowsAndMessaging::MSG;
use windows::Win32::UI::WindowsAndMessaging::PostMessageW;
use windows::Win32::UI::WindowsAndMessaging::PostQuitMessage;
use windows::Win32::UI::WindowsAndMessaging::SM_CXVIRTUALSCREEN;
use windows::Win32::UI::WindowsAndMessaging::SetCursor;
@@ -68,21 +65,11 @@ use windows::Win32::UI::WindowsAndMessaging::WM_CREATE;
use windows::Win32::UI::WindowsAndMessaging::WM_DESTROY;
use windows::Win32::UI::WindowsAndMessaging::WM_PAINT;
use windows::Win32::UI::WindowsAndMessaging::WM_SETCURSOR;
use windows::Win32::UI::WindowsAndMessaging::WM_USER;
use windows::Win32::UI::WindowsAndMessaging::WNDCLASSW;
use windows_core::BOOL;
use windows_core::PCWSTR;
use windows_numerics::Matrix3x2;
/// Custom WM_USER message that tells the border window thread to call update_brushes() on itself,
/// avoiding a data race between the border manager thread and the border's message loop thread.
pub const WM_UPDATE_BRUSHES: u32 = WM_USER + 1;
/// Custom WM_USER message used to drive the border in lockstep with an active
/// movement animation. lparam carries a `Box<Rect>` ownership transfer that the
/// receiving WndProc reclaims and applies as the new tracked rect.
pub const WM_ANIMATE_RECT: u32 = WM_USER + 2;
pub struct RenderFactory(ID2D1Factory);
unsafe impl Sync for RenderFactory {}
unsafe impl Send for RenderFactory {}
@@ -111,98 +98,6 @@ static BRUSH_PROPERTIES: LazyLock<D2D1_BRUSH_PROPERTIES> =
transform: Matrix3x2::identity(),
});
/// Apply a new tracked rect to the border on its own message-loop thread.
/// Updates `window_rect`, calls `set_position`, and re-renders if size/position
/// changed. Used by both `EVENT_OBJECT_LOCATIONCHANGE` (real window movements)
/// and `WM_ANIMATE_RECT` (animation-driven movements while the source is cloaked).
///
/// SAFETY: caller must ensure `border_pointer` is non-null, points to a live
/// `Border`, and that we are running on the border's WndProc thread.
unsafe fn apply_tracked_rect(border_pointer: *mut Border, rect: Rect) {
unsafe {
let reference_hwnd = (*border_pointer).tracking_hwnd;
let old_rect = (*border_pointer).window_rect;
(*border_pointer).window_rect = rect;
if let Err(error) = (*border_pointer).set_position(&rect, reference_hwnd) {
tracing::error!("failed to update border position {error}");
}
if (rect.is_same_size_as(&old_rect) && rect.has_same_position_as(&old_rect))
|| (*border_pointer).render_target.is_none()
{
return;
}
// double-check destruction flag before rendering
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return;
}
let render_target = match (*border_pointer).render_target.as_ref() {
Some(rt) => rt,
None => return,
};
let border_width = (*border_pointer).width;
let border_offset = (*border_pointer).offset;
(*border_pointer).rounded_rect.rect = D2D_RECT_F {
left: (border_width / 2 - border_offset) as f32,
top: (border_width / 2 - border_offset) as f32,
right: (rect.right - border_width / 2 + border_offset) as f32,
bottom: (rect.bottom - border_width / 2 + border_offset) as f32,
};
let _ = render_target.Resize(&D2D_SIZE_U {
width: rect.right as u32,
height: rect.bottom as u32,
});
let window_kind = (*border_pointer).window_kind;
let Some(brush) = (*border_pointer).brushes.get(&window_kind) else {
return;
};
render_target.BeginDraw();
render_target.Clear(None);
let style = match (*border_pointer).style {
BorderStyle::System => {
if *WINDOWS_11 {
BorderStyle::Rounded
} else {
BorderStyle::Square
}
}
BorderStyle::Rounded => BorderStyle::Rounded,
BorderStyle::Square => BorderStyle::Square,
};
match style {
BorderStyle::Rounded => {
render_target.DrawRoundedRectangle(
&(*border_pointer).rounded_rect,
brush,
border_width as f32,
None,
);
}
BorderStyle::Square => {
render_target.DrawRectangle(
&(*border_pointer).rounded_rect.rect,
brush,
border_width as f32,
None,
);
}
_ => {}
}
let _ = render_target.EndDraw(None, None);
}
}
pub extern "system" fn border_hwnds(hwnd: HWND, lparam: LPARAM) -> BOOL {
let hwnds = unsafe { &mut *(lparam.0 as *mut Vec<isize>) };
let hwnd = hwnd.0 as isize;
@@ -231,7 +126,6 @@ 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 {
@@ -250,7 +144,6 @@ 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)),
}
}
}
@@ -299,7 +192,6 @@ 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;
@@ -421,52 +313,12 @@ impl Border {
}
pub fn destroy(&self) -> color_eyre::Result<()> {
// signal that we're destroying - prevents new render operations from starting
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));
// WM_DESTROY will clear GWLP_USERDATA and drop the render target before D2D
// frees its internal HwndPresenter during WM_NCDESTROY
WindowsApi::close_window(self.hwnd)
}
/// Post a message to the border's own message loop thread requesting a brush update.
/// This ensures update_brushes() always runs on the window thread that owns the D2D
/// render target, preventing a data race with concurrent WndProc render operations.
pub fn request_brush_update(&self) {
let _ = unsafe {
PostMessageW(
Option::from(self.hwnd()),
WM_UPDATE_BRUSHES,
WPARAM(0),
LPARAM(0),
)
};
}
/// Drive the border to follow `rect` during a movement animation. Hands
/// ownership of a boxed `Rect` to the border's message-loop thread via
/// `WM_ANIMATE_RECT`, which mirrors the redraw path normally driven by
/// `EVENT_OBJECT_LOCATIONCHANGE` on the real source window.
pub fn animate_to(&self, rect: Rect) {
let boxed = Box::new(rect);
let ptr = Box::into_raw(boxed);
let posted = unsafe {
PostMessageW(
Option::from(self.hwnd()),
WM_ANIMATE_RECT,
WPARAM(0),
LPARAM(ptr as isize),
)
};
if posted.is_err() {
// Reclaim the box on failure to avoid leaking.
unsafe {
drop(Box::from_raw(ptr));
}
// clear user data **BEFORE** closing window
// pending messages will see a null pointer and exit early
unsafe {
SetWindowLongPtrW(self.hwnd(), GWLP_USERDATA, 0);
}
WindowsApi::close_window(self.hwnd)
}
pub fn set_position(&self, rect: &Rect, reference_hwnd: isize) -> color_eyre::Result<()> {
@@ -534,29 +386,77 @@ 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;
let rect = WindowsApi::window_rect(reference_hwnd).unwrap_or_default();
apply_tracked_rect(border_pointer, rect);
LRESULT(0)
}
WM_ANIMATE_RECT => {
// lparam carries an owned Box<Rect> from the animation thread.
let rect_box = Box::from_raw(lparam.0 as *mut Rect);
let border_pointer: *mut Border = GetWindowLongPtrW(window, GWLP_USERDATA) as _;
if border_pointer.is_null() {
return LRESULT(0);
(*border_pointer).window_rect = rect;
if let Err(error) = (*border_pointer).set_position(&rect, reference_hwnd) {
tracing::error!("failed to update border position {error}");
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
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()
{
let border_width = (*border_pointer).width;
let border_offset = (*border_pointer).offset;
(*border_pointer).rounded_rect.rect = D2D_RECT_F {
left: (border_width / 2 - border_offset) as f32,
top: (border_width / 2 - border_offset) as f32,
right: (rect.right - border_width / 2 + border_offset) as f32,
bottom: (rect.bottom - border_width / 2 + border_offset) as f32,
};
let _ = render_target.Resize(&D2D_SIZE_U {
width: rect.right as u32,
height: rect.bottom as u32,
});
let window_kind = (*border_pointer).window_kind;
if let Some(brush) = (*border_pointer).brushes.get(&window_kind) {
render_target.BeginDraw();
render_target.Clear(None);
// Calculate border radius based on style
let style = match (*border_pointer).style {
BorderStyle::System => {
if *WINDOWS_11 {
BorderStyle::Rounded
} else {
BorderStyle::Square
}
}
BorderStyle::Rounded => BorderStyle::Rounded,
BorderStyle::Square => BorderStyle::Square,
};
match style {
BorderStyle::Rounded => {
render_target.DrawRoundedRectangle(
&(*border_pointer).rounded_rect,
brush,
border_width as f32,
None,
);
}
BorderStyle::Square => {
render_target.DrawRectangle(
&(*border_pointer).rounded_rect.rect,
brush,
border_width as f32,
None,
);
}
_ => {}
}
let _ = render_target.EndDraw(None, None);
}
}
apply_tracked_rect(border_pointer, *rect_box);
LRESULT(0)
}
WM_PAINT => {
@@ -568,10 +468,6 @@ 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
@@ -585,11 +481,6 @@ 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);
@@ -656,27 +547,8 @@ impl Border {
let _ = ValidateRect(Option::from(window), None);
LRESULT(0)
}
WM_UPDATE_BRUSHES => {
let border_pointer: *mut Border = GetWindowLongPtrW(window, GWLP_USERDATA) as _;
if border_pointer.is_null() {
return LRESULT(0);
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
if let Err(error) = (*border_pointer).update_brushes() {
tracing::error!("failed to update brushes: {error}");
}
(*border_pointer).invalidate();
LRESULT(0)
}
WM_DESTROY => {
let border_pointer: *mut Border = GetWindowLongPtrW(window, GWLP_USERDATA) as _;
if !border_pointer.is_null() {
(*border_pointer).render_target = None;
(*border_pointer).brushes.clear();
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
}
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
PostQuitMessage(0);
LRESULT(0)
}

View File

@@ -113,20 +113,6 @@ pub fn window_border(hwnd: isize) -> Option<BorderInfo> {
})
}
/// Drive the border that tracks `source_hwnd` to follow `rect`. No-op when no
/// border is registered for the source window. Used by movement animations to
/// keep the border visually in sync while the source window is cloaked.
pub fn animate_to(source_hwnd: isize, rect: crate::core::Rect) {
let border_id = match WINDOWS_BORDERS.lock().get(&source_hwnd).cloned() {
Some(id) => id,
None => return,
};
let state = BORDER_STATE.lock();
if let Some(border) = state.get(&border_id) {
border.animate_to(rect);
}
}
pub fn send_notification(hwnd: Option<isize>) {
if event_tx().try_send(Notification::Update(hwnd)).is_err() {
tracing::warn!("channel is full; dropping notification")
@@ -465,11 +451,8 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
} else if matches!(notification, Notification::ForceUpdate) {
// Update the border brushes if there was a forced update
// notification and this is not a new border (new border's
// already have their brushes updated on creation).
// Post to the border's own thread to avoid a data race between
// this thread dropping the old render target and the window
// thread mid-render holding a reference to it.
border.request_brush_update();
// already have their brushes updated on creation)
border.update_brushes()?;
}
border.invalidate();
@@ -633,11 +616,8 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
if forced_update && !new_border {
// Update the border brushes if there was a forced update
// notification and this is not a new border (new border's
// already have their brushes updated on creation).
// Post to the border's own thread to avoid a data race between
// this thread dropping the old render target and the window
// thread mid-render holding a reference to it.
border.request_brush_update();
// already have their brushes updated on creation)
border.update_brushes()?;
}
border.set_position(&rect, focused_window_hwnd)?;
border.invalidate();
@@ -719,11 +699,8 @@ fn handle_floating_borders(
if forced_update && !new_border {
// Update the border brushes if there was a forced update
// notification and this is not a new border (new border's
// already have their brushes updated on creation).
// Post to the border's own thread to avoid a data race between
// this thread dropping the old render target and the window
// thread mid-render holding a reference to it.
border.request_brush_update();
// already have their brushes updated on creation)
border.update_brushes()?;
}
border.set_position(&rect, window.hwnd)?;
border.invalidate();
@@ -790,6 +767,12 @@ fn remove_border(
fn destroy_border(border: Box<Border>) -> color_eyre::Result<()> {
let raw_pointer = Box::into_raw(border);
unsafe {
// release d2d resources **BEFORE** destroying window
// this drops render_target and brushes while HWND is still valid
// prevents EndDraw() from accessing freed HWND resources
(*raw_pointer).render_target = None;
(*raw_pointer).brushes.clear();
// Now safe to destroy window
(*raw_pointer).destroy()?;
}

View File

@@ -74,7 +74,7 @@ pub enum AnimationStyle {
EaseInOutBounce,
#[cfg_attr(feature = "schemars", schemars(title = "CubicBezier"))]
#[value(skip)]
/// Custom Cubic Bezier function
/// Custom Cubic Bézier function
CubicBezier(f64, f64, f64, f64),
}

View File

@@ -6,22 +6,13 @@ use serde::Serialize;
use strum::Display;
use strum::EnumString;
#[cfg(feature = "win32")]
use super::CustomLayout;
use super::DefaultLayout;
use super::Rect;
#[cfg(feature = "win32")]
use super::custom_layout::Column;
#[cfg(feature = "win32")]
use super::custom_layout::ColumnSplit;
#[cfg(feature = "win32")]
use super::custom_layout::ColumnSplitWithCapacity;
use crate::default_layout::DEFAULT_RATIO;
use crate::default_layout::DEFAULT_SECONDARY_RATIO;
use crate::default_layout::LayoutOptions;
use crate::default_layout::MAX_RATIO;
use crate::default_layout::MAX_RATIOS;
use crate::default_layout::MIN_RATIO;
pub trait Arrangement {
#[allow(clippy::too_many_arguments)]
@@ -51,23 +42,10 @@ impl Arrangement for DefaultLayout {
layout_options: Option<LayoutOptions>,
latest_layout: &[Rect],
) -> Vec<Rect> {
// Trace layout_options for debugging
if let Some(ref opts) = layout_options {
tracing::debug!(
"Layout {:?} - layout_options received: column_ratios={:?}, row_ratios={:?}",
self,
opts.column_ratios,
opts.row_ratios
);
} else {
tracing::debug!("Layout {:?} - no layout_options provided", self);
}
let len = usize::from(len);
let mut dimensions = match self {
Self::Scrolling => {
let column_count = layout_options
.as_ref()
.and_then(|o| o.scrolling.map(|s| s.columns))
.unwrap_or(3);
@@ -76,7 +54,6 @@ impl Arrangement for DefaultLayout {
let visible_columns = area.right / column_width;
let keep_centered = layout_options
.as_ref()
.and_then(|o| {
o.scrolling
.map(|s| s.center_focused_column.unwrap_or_default())
@@ -141,15 +118,6 @@ impl Arrangement for DefaultLayout {
});
}
// Last visible column absorbs any remainder from integer division
// so that visible columns tile the full area width without gaps
let width_remainder = area.right - column_width * visible_columns;
if width_remainder > 0 {
let last_visible_idx =
(first_visible as usize + visible_columns as usize - 1).min(len - 1);
layouts[last_visible_idx].right += width_remainder;
}
let adjustment = calculate_scrolling_adjustment(resize_dimensions);
layouts
.iter_mut()
@@ -163,30 +131,15 @@ impl Arrangement for DefaultLayout {
layouts
}
Self::BSP => {
let column_split_ratio = layout_options
.and_then(|o| o.column_ratios)
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
let row_split_ratio = layout_options
.and_then(|o| o.row_ratios)
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
recursive_fibonacci(
0,
len,
area,
layout_flip,
calculate_resize_adjustments(resize_dimensions),
column_split_ratio,
row_split_ratio,
)
}
Self::BSP => recursive_fibonacci(
0,
len,
area,
layout_flip,
calculate_resize_adjustments(resize_dimensions),
),
Self::Columns => {
let ratios = layout_options.and_then(|o| o.column_ratios);
let mut layouts = columns_with_ratios(area, len, ratios);
let mut layouts = columns(area, len);
let adjustment = calculate_columns_adjustment(resize_dimensions);
layouts
@@ -210,8 +163,7 @@ impl Arrangement for DefaultLayout {
layouts
}
Self::Rows => {
let ratios = layout_options.and_then(|o| o.row_ratios);
let mut layouts = rows_with_ratios(area, len, ratios);
let mut layouts = rows(area, len);
let adjustment = calculate_rows_adjustment(resize_dimensions);
layouts
@@ -237,17 +189,9 @@ impl Arrangement for DefaultLayout {
Self::VerticalStack => {
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_truncation)]
let primary_right = match len {
1 => area.right,
_ => {
let ratio = layout_options
.and_then(|o| o.column_ratios)
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
(area.right as f32 * ratio) as i32
}
_ => area.right / 2,
};
let main_left = area.left;
@@ -262,8 +206,7 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
let row_ratios = layout_options.and_then(|o| o.row_ratios);
layouts.append(&mut rows_with_ratios(
layouts.append(&mut rows(
&Rect {
left: stack_left,
top: area.top,
@@ -271,7 +214,6 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom,
},
len - 1,
row_ratios,
));
}
}
@@ -315,17 +257,9 @@ impl Arrangement for DefaultLayout {
// Shamelessly borrowed from LeftWM: https://github.com/leftwm/leftwm/commit/f673851745295ae7584a102535566f559d96a941
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_truncation)]
let primary_width = match len {
1 => area.right,
_ => {
let ratio = layout_options
.and_then(|o| o.column_ratios)
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
(area.right as f32 * ratio) as i32
}
_ => area.right / 2,
};
let primary_left = match len {
@@ -342,8 +276,7 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
let row_ratios = layout_options.and_then(|o| o.row_ratios);
layouts.append(&mut rows_with_ratios(
layouts.append(&mut rows(
&Rect {
left: area.left,
top: area.top,
@@ -351,7 +284,6 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom,
},
len - 1,
row_ratios,
));
}
}
@@ -394,17 +326,9 @@ impl Arrangement for DefaultLayout {
Self::HorizontalStack => {
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_truncation)]
let bottom = match len {
1 => area.bottom,
_ => {
let ratio = layout_options
.and_then(|o| o.row_ratios)
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
(area.bottom as f32 * ratio) as i32
}
_ => area.bottom / 2,
};
let main_top = area.top;
@@ -419,8 +343,7 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
let col_ratios = layout_options.and_then(|o| o.column_ratios);
layouts.append(&mut columns_with_ratios(
layouts.append(&mut columns(
&Rect {
left: area.left,
top: stack_top,
@@ -428,7 +351,6 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom - bottom,
},
len - 1,
col_ratios,
));
}
}
@@ -471,28 +393,15 @@ impl Arrangement for DefaultLayout {
Self::UltrawideVerticalStack => {
let mut layouts: Vec<Rect> = vec![];
// Get ratios: [0] = primary (center), [1] = secondary (left), remainder = tertiary (right)
let ratios = layout_options.and_then(|o| o.column_ratios);
let primary_ratio = ratios
.and_then(|r| r[0])
.unwrap_or(DEFAULT_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
let secondary_ratio = ratios
.and_then(|r| r[1])
.unwrap_or(DEFAULT_SECONDARY_RATIO)
.clamp(MIN_RATIO, MAX_RATIO);
#[allow(clippy::cast_possible_truncation)]
let primary_right = match len {
1 => area.right,
_ => (area.right as f32 * primary_ratio) as i32,
_ => area.right / 2,
};
#[allow(clippy::cast_possible_truncation)]
let secondary_right = match len {
1 => 0,
2 => area.right - primary_right,
_ => (area.right as f32 * secondary_ratio) as i32,
_ => (area.right - primary_right) / 2,
};
let (primary_left, secondary_left, stack_left) = match len {
@@ -529,18 +438,14 @@ impl Arrangement for DefaultLayout {
});
if len > 2 {
// Tertiary column gets remaining space after primary and secondary
let tertiary_right = area.right - primary_right - secondary_right;
let row_ratios = layout_options.and_then(|o| o.row_ratios);
layouts.append(&mut rows_with_ratios(
layouts.append(&mut rows(
&Rect {
left: stack_left,
top: area.top,
right: tertiary_right,
right: secondary_right,
bottom: area.bottom,
},
len - 2,
row_ratios,
));
}
}
@@ -609,94 +514,13 @@ impl Arrangement for DefaultLayout {
let len = len as i32;
let row_constraint = layout_options.as_ref().and_then(|o| o.grid.map(|g| g.rows));
let column_ratios = layout_options.and_then(|o| o.column_ratios);
// Count defined column ratios (already validated at deserialization to sum < 1.0)
let defined_ratios = column_ratios
.as_ref()
.map(|r| r.iter().filter(|x| x.is_some()).count())
.unwrap_or(0);
let row_constraint = layout_options.and_then(|o| o.grid.map(|g| g.rows));
let num_cols = if let Some(rows) = row_constraint {
((len as f32) / (rows as f32)).ceil() as i32
} else {
(len as f32).sqrt().ceil() as i32
};
// Pre-calculate column widths and left positions using same logic as columns_with_ratios
let mut col_widths: Vec<i32> = Vec::with_capacity(num_cols as usize);
let mut col_lefts: Vec<i32> = Vec::with_capacity(num_cols as usize);
let mut current_left = area.left;
for col in 0..num_cols {
let col_idx = col as usize;
let width = if let Some(ref ratios) = column_ratios {
// Only apply ratio if there's at least one more column after this
// The last column always gets the remaining space
let should_apply_ratio =
col_idx < MAX_RATIOS && col_idx < defined_ratios && col < num_cols - 1;
if should_apply_ratio {
if let Some(ratio) = ratios[col_idx] {
(area.right as f32 * ratio) as i32
} else {
let used: f32 = (0..col_idx).filter_map(|j| ratios[j]).sum();
let remaining_space =
area.right - (area.right as f32 * used) as i32;
let remaining_cols = num_cols - col;
remaining_space / remaining_cols
}
} else {
// Beyond defined ratios or last column - split remaining space equally
// Only count ratios that were actually applied (up to defined_ratios, but not beyond num_cols - 1)
let ratios_applied = defined_ratios.min((num_cols - 1) as usize);
let used: f32 = (0..ratios_applied).filter_map(|j| ratios[j]).sum();
let remaining_space = area.right - (area.right as f32 * used) as i32;
let remaining_cols = (num_cols as usize - ratios_applied) as i32;
if remaining_cols > 0 {
remaining_space / remaining_cols
} else {
remaining_space
}
}
} else {
area.right / num_cols
};
col_lefts.push(current_left);
col_widths.push(width);
current_left += width;
}
// Last column absorbs any remainder from integer division
// so that columns tile the full area width without gaps
let total_width: i32 = col_widths.iter().sum();
let width_remainder = area.right - total_width;
if width_remainder > 0
&& let Some(last) = col_widths.last_mut()
{
*last += width_remainder;
}
// Pre-calculate flipped column positions: same widths laid out
// in reverse order so that the last column sits at area.left
let flipped_col_lefts = if matches!(
layout_flip,
Some(Axis::Horizontal | Axis::HorizontalAndVertical)
) {
let n = num_cols as usize;
let mut flipped = vec![0i32; n];
let mut fl = area.left;
for i in (0..n).rev() {
flipped[i] = fl;
fl += col_widths[i];
}
flipped
} else {
vec![]
};
let mut iter = layouts.iter_mut().enumerate().peekable();
for col in 0..num_cols {
@@ -710,47 +534,26 @@ impl Arrangement for DefaultLayout {
remaining_windows / remaining_columns
};
// Rows within each column: base height from integer division,
// last row absorbs any remainder to cover the full area height
let base_height = area.bottom / num_rows_in_this_col;
let height_remainder = area.bottom - base_height * num_rows_in_this_col;
let col_idx = col as usize;
let win_width = col_widths[col_idx];
let col_left = col_lefts[col_idx];
let win_height = area.bottom / num_rows_in_this_col;
let win_width = area.right / num_cols;
for row in 0..num_rows_in_this_col {
if let Some((_idx, win)) = iter.next() {
let is_last_row = row == num_rows_in_this_col - 1;
let win_height = if is_last_row {
base_height + height_remainder
} else {
base_height
};
let mut left = col_left;
let mut top = area.top + base_height * row;
let mut left = area.left + win_width * col;
let mut top = area.top + win_height * row;
match layout_flip {
Some(Axis::Horizontal) => {
left = flipped_col_lefts[col_idx];
left = area.right - win_width * (col + 1) + area.left;
}
Some(Axis::Vertical) => {
top = if is_last_row {
area.top
} else {
area.top + area.bottom - base_height * (row + 1)
};
top = area.bottom - win_height * (row + 1) + area.top;
}
Some(Axis::HorizontalAndVertical) => {
left = flipped_col_lefts[col_idx];
top = if is_last_row {
area.top
} else {
area.top + area.bottom - base_height * (row + 1)
};
left = area.right - win_width * (col + 1) + area.left;
top = area.bottom - win_height * (row + 1) + area.top;
}
None => {}
None => {} // No flip
}
win.bottom = win_height;
@@ -773,7 +576,6 @@ impl Arrangement for DefaultLayout {
}
}
#[cfg(feature = "win32")]
impl Arrangement for CustomLayout {
fn calculate(
&self,
@@ -912,68 +714,14 @@ pub enum Axis {
HorizontalAndVertical,
}
#[cfg(feature = "win32")]
#[must_use]
fn columns(area: &Rect, len: usize) -> Vec<Rect> {
columns_with_ratios(area, len, None)
}
#[must_use]
fn columns_with_ratios(
area: &Rect,
len: usize,
ratios: Option<[Option<f32>; MAX_RATIOS]>,
) -> Vec<Rect> {
tracing::debug!(
"columns_with_ratios called: len={}, ratios={:?}",
len,
ratios
);
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let right = area.right / len as i32;
let mut left = 0;
// Count how many ratios are defined (already validated at deserialization to sum < 1.0)
let defined_ratios = ratios
.as_ref()
.map(|r| r.iter().filter(|x| x.is_some()).count())
.unwrap_or(0);
for i in 0..len {
#[allow(clippy::cast_possible_truncation)]
let right = if let Some(ref r) = ratios {
// Only apply ratio[i] if there's at least one more column after this (i < len - 1)
// The last column always gets the remaining space
let should_apply_ratio = i < MAX_RATIOS && i < defined_ratios && i < len - 1;
if should_apply_ratio {
if let Some(ratio) = r[i] {
(area.right as f32 * ratio) as i32
} else {
let used: f32 = (0..i).filter_map(|j| r[j]).sum();
let remaining_space = area.right - (area.right as f32 * used) as i32;
let remaining_columns = len - i;
remaining_space / remaining_columns as i32
}
} else {
// Last column or beyond defined ratios - split remaining space equally
let ratios_applied = i.min(defined_ratios).min(len.saturating_sub(1));
let used: f32 = (0..ratios_applied).filter_map(|j| r[j]).sum();
let remaining_space = area.right - (area.right as f32 * used) as i32;
let remaining_columns = len - ratios_applied;
if remaining_columns > 0 {
remaining_space / remaining_columns as i32
} else {
remaining_space
}
}
} else {
// Equal width columns (original behavior)
#[allow(clippy::cast_possible_wrap)]
{
area.right / len as i32
}
};
let mut layouts: Vec<Rect> = vec![];
for _ in 0..len {
layouts.push(Rect {
left: area.left + left,
top: area.top,
@@ -984,77 +732,17 @@ fn columns_with_ratios(
left += right;
}
// Last column absorbs any remainder from integer division
// so that columns tile the full area width without gaps
let total_width: i32 = layouts.iter().map(|r| r.right).sum();
let remainder = area.right - total_width;
if remainder > 0
&& let Some(last) = layouts.last_mut()
{
last.right += remainder;
}
layouts
}
#[cfg(feature = "win32")]
#[must_use]
fn rows(area: &Rect, len: usize) -> Vec<Rect> {
rows_with_ratios(area, len, None)
}
#[must_use]
fn rows_with_ratios(
area: &Rect,
len: usize,
ratios: Option<[Option<f32>; MAX_RATIOS]>,
) -> Vec<Rect> {
tracing::debug!("rows_with_ratios called: len={}, ratios={:?}", len, ratios);
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let bottom = area.bottom / len as i32;
let mut top = 0;
// Count how many ratios are defined (already validated at deserialization to sum < 1.0)
let defined_ratios = ratios
.as_ref()
.map(|r| r.iter().filter(|x| x.is_some()).count())
.unwrap_or(0);
for i in 0..len {
#[allow(clippy::cast_possible_truncation)]
let bottom = if let Some(ref r) = ratios {
// Only apply ratio[i] if there's at least one more row after this (i < len - 1)
// The last row always gets the remaining space
let should_apply_ratio = i < MAX_RATIOS && i < defined_ratios && i < len - 1;
if should_apply_ratio {
if let Some(ratio) = r[i] {
(area.bottom as f32 * ratio) as i32
} else {
let used: f32 = (0..i).filter_map(|j| r[j]).sum();
let remaining_space = area.bottom - (area.bottom as f32 * used) as i32;
let remaining_rows = len - i;
remaining_space / remaining_rows as i32
}
} else {
// Last row or beyond defined ratios - split remaining space equally
let ratios_applied = i.min(defined_ratios).min(len.saturating_sub(1));
let used: f32 = (0..ratios_applied).filter_map(|j| r[j]).sum();
let remaining_space = area.bottom - (area.bottom as f32 * used) as i32;
let remaining_rows = len - ratios_applied;
if remaining_rows > 0 {
remaining_space / remaining_rows as i32
} else {
remaining_space
}
}
} else {
// Equal height rows (original behavior)
#[allow(clippy::cast_possible_wrap)]
{
area.bottom / len as i32
}
};
let mut layouts: Vec<Rect> = vec![];
for _ in 0..len {
layouts.push(Rect {
left: area.left,
top: area.top + top,
@@ -1065,16 +753,6 @@ fn rows_with_ratios(
top += bottom;
}
// Last row absorbs any remainder from integer division
// so that rows tile the full area height without gaps
let total_height: i32 = layouts.iter().map(|r| r.bottom).sum();
let remainder = area.bottom - total_height;
if remainder > 0
&& let Some(last) = layouts.last_mut()
{
last.bottom += remainder;
}
layouts
}
@@ -1184,8 +862,6 @@ fn recursive_fibonacci(
area: &Rect,
layout_flip: Option<Axis>,
resize_adjustments: Vec<Option<Rect>>,
column_split_ratio: f32,
row_split_ratio: f32,
) -> Vec<Rect> {
let mut a = *area;
@@ -1199,41 +875,41 @@ fn recursive_fibonacci(
*area
};
#[allow(clippy::cast_possible_truncation)]
let primary_resized_width = (resized.right as f32 * column_split_ratio) as i32;
#[allow(clippy::cast_possible_truncation)]
let primary_resized_height = (resized.bottom as f32 * row_split_ratio) as i32;
let half_width = area.right / 2;
let half_height = area.bottom / 2;
let half_resized_width = resized.right / 2;
let half_resized_height = resized.bottom / 2;
let (main_x, alt_x, alt_y, main_y);
if let Some(flip) = layout_flip {
match flip {
Axis::Horizontal => {
main_x = resized.left + (area.right - primary_resized_width);
main_x = resized.left + half_width + (half_width - half_resized_width);
alt_x = resized.left;
alt_y = resized.top + primary_resized_height;
alt_y = resized.top + half_resized_height;
main_y = resized.top;
}
Axis::Vertical => {
main_y = resized.top + (area.bottom - primary_resized_height);
main_y = resized.top + half_height + (half_height - half_resized_height);
alt_y = resized.top;
main_x = resized.left;
alt_x = resized.left + primary_resized_width;
alt_x = resized.left + half_resized_width;
}
Axis::HorizontalAndVertical => {
main_x = resized.left + (area.right - primary_resized_width);
main_x = resized.left + half_width + (half_width - half_resized_width);
alt_x = resized.left;
main_y = resized.top + (area.bottom - primary_resized_height);
main_y = resized.top + half_height + (half_height - half_resized_height);
alt_y = resized.top;
}
}
} else {
main_x = resized.left;
alt_x = resized.left + primary_resized_width;
alt_x = resized.left + half_resized_width;
main_y = resized.top;
alt_y = resized.top + primary_resized_height;
alt_y = resized.top + half_resized_height;
}
#[allow(clippy::if_not_else)]
@@ -1251,7 +927,7 @@ fn recursive_fibonacci(
left: resized.left,
top: main_y,
right: resized.right,
bottom: primary_resized_height,
bottom: half_resized_height,
}];
res.append(&mut recursive_fibonacci(
idx + 1,
@@ -1260,19 +936,17 @@ fn recursive_fibonacci(
left: area.left,
top: alt_y,
right: area.right,
bottom: area.bottom - primary_resized_height,
bottom: area.bottom - half_resized_height,
},
layout_flip,
resize_adjustments,
column_split_ratio,
row_split_ratio,
));
res
} else {
let mut res = vec![Rect {
left: main_x,
top: resized.top,
right: primary_resized_width,
right: half_resized_width,
bottom: resized.bottom,
}];
res.append(&mut recursive_fibonacci(
@@ -1281,13 +955,11 @@ fn recursive_fibonacci(
&Rect {
left: alt_x,
top: area.top,
right: area.right - primary_resized_width,
right: area.right - half_resized_width,
bottom: area.bottom,
},
layout_flip,
resize_adjustments,
column_split_ratio,
row_split_ratio,
));
res
}
@@ -1595,7 +1267,3 @@ fn resize_top(rect: &mut Rect, resize: i32) {
fn resize_bottom(rect: &mut Rect, resize: i32) {
rect.bottom += resize / 2;
}
#[cfg(test)]
#[path = "arrangement_tests.rs"]
mod tests;

View File

@@ -1,6 +1,5 @@
use std::collections::HashMap;
use clap::ValueEnum;
use core::str::FromStr;
use serde::Deserialize;
use serde::Serialize;
use strum::Display;
@@ -10,53 +9,24 @@ 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;
/// Validates and converts a Vec of ratios into a fixed-size array.
/// - Clamps values to MIN_RATIO..MAX_RATIO range
/// - Truncates when cumulative sum reaches or exceeds 1.0
/// - Limits to MAX_RATIOS values
#[must_use]
pub fn validate_ratios(ratios: &[f32]) -> [Option<f32>; MAX_RATIOS] {
let mut arr = [None; MAX_RATIOS];
let mut cumulative_sum = 0.0_f32;
for (i, &val) in ratios.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;
}
pub fn deserialize_option_none_default_layout<'de, D>(
deserializer: D,
) -> Result<Option<DefaultLayout>, D::Error>
where
D: serde::Deserializer<'de>,
{
let s = String::deserialize(deserializer)?;
if s == "None" {
Ok(None)
} else {
<DefaultLayout as FromStr>::from_str(&s)
.map(Some)
.map_err(serde::de::Error::custom)
}
arr
}
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Hash, Display, EnumString, ValueEnum,
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// A predefined komorebi layout
@@ -159,43 +129,7 @@ pub enum DefaultLayout {
// 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| validate_ratios(&vec)))
}
/// 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)]
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for specific layouts
pub struct LayoutOptions {
@@ -203,35 +137,6 @@ pub struct LayoutOptions {
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)]
@@ -252,21 +157,6 @@ pub struct GridLayoutOptions {
pub rows: usize,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Per-layout default options entry for the `layout_defaults` global setting.
/// Contains both base layout options and threshold-based layout options rules.
pub struct LayoutDefaultEntry {
/// Default layout options for this layout
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options: Option<LayoutOptions>,
/// Threshold-based layout options rules in the format of threshold => options.
/// When container count >= threshold, the highest matching threshold's options
/// fully replace the base `layout_options`.
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options_rules: Option<HashMap<usize, LayoutOptions>>,
}
impl DefaultLayout {
pub fn leftmost_index(&self, len: usize) -> usize {
match self {
@@ -435,7 +325,3 @@ impl DefaultLayout {
}
}
}
#[cfg(test)]
#[path = "default_layout_tests.rs"]
mod tests;

View File

@@ -1,12 +1,8 @@
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;
@@ -404,7 +400,6 @@ fn grid_neighbor(
}
}
#[cfg(feature = "win32")]
impl Direction for CustomLayout {
fn index_in_direction(
&self,

View File

@@ -2,7 +2,6 @@ use serde::Deserialize;
use serde::Serialize;
use super::Arrangement;
#[cfg(feature = "win32")]
use super::CustomLayout;
use super::DefaultLayout;
use super::Direction;
@@ -11,7 +10,6 @@ use super::Direction;
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub enum Layout {
Default(DefaultLayout),
#[cfg(feature = "win32")]
Custom(CustomLayout),
}
@@ -20,7 +18,6 @@ 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()),
}
}
@@ -29,7 +26,6 @@ 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

@@ -15,45 +15,37 @@ use strum::EnumString;
use crate::KomorebiTheme;
use crate::animation::prefix::AnimationPrefix;
use crate::state::State;
// 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::LayoutDefaultEntry;
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;
pub use komorebi_layouts::validate_ratios;
// 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]
@@ -121,7 +113,6 @@ pub enum SocketMessage {
AdjustWorkspacePadding(Sizing, i32),
ChangeLayout(DefaultLayout),
CycleLayout(CycleDirection),
LayoutRatios(Option<Vec<f32>>, Option<Vec<f32>>),
ScrollingLayoutColumns(NonZeroUsize),
ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf),
FlipLayout(Axis),
@@ -258,8 +249,6 @@ pub enum SocketMessage {
StaticConfigSchema,
GenerateStaticConfig,
DebugWindow(isize),
// low level commands
ApplyState(State),
}
impl SocketMessage {
@@ -556,6 +545,32 @@ 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

@@ -1,18 +1,7 @@
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
@@ -27,7 +16,6 @@ pub struct Rect {
pub bottom: i32,
}
#[cfg(feature = "win32")]
impl From<RECT> for Rect {
fn from(rect: RECT) -> Self {
Self {
@@ -39,7 +27,6 @@ impl From<RECT> for Rect {
}
}
#[cfg(feature = "win32")]
impl From<Rect> for RECT {
fn from(rect: Rect) -> Self {
Self {
@@ -51,53 +38,6 @@ 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
@@ -156,7 +96,6 @@ impl Rect {
}
}
#[cfg(feature = "win32")]
#[must_use]
pub const fn rect(&self) -> RECT {
RECT {
@@ -166,19 +105,4 @@ 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

@@ -238,11 +238,10 @@ lazy_static! {
static ref FLOATING_WINDOW_TOGGLE_ASPECT_RATIO: Arc<Mutex<AspectRatio>> = Arc::new(Mutex::new(AspectRatio::Predefined(PredefinedAspectRatio::Widescreen)));
static ref CURRENT_VIRTUAL_DESKTOP: Arc<Mutex<Option<Vec<u8>>>> = Arc::new(Mutex::new(None));
pub static ref LAYOUT_DEFAULTS: Arc<Mutex<HashMap<DefaultLayout, LayoutDefaultEntry>>> =
Arc::new(Mutex::new(HashMap::new()));
}
pub static DEFAULT_WORKSPACE_LAYOUT: AtomicCell<Option<DefaultLayout>> =
AtomicCell::new(Some(DefaultLayout::BSP));
pub static DEFAULT_WORKSPACE_PADDING: AtomicI32 = AtomicI32::new(10);
pub static DEFAULT_CONTAINER_PADDING: AtomicI32 = AtomicI32::new(10);
pub static DEFAULT_RESIZE_DELTA: i32 = 50;
@@ -325,7 +324,7 @@ pub fn current_virtual_desktop() -> Option<Vec<u8>> {
// the latter case, if the user desires this validation after initiating the task view, komorebi
// should be restarted, and then when this // fn runs again for the first time, it will pick up
// the value of CurrentVirtualDesktop and validate against it accordingly
current.map(|current| current.to_vec())
current
}
#[derive(Clone, Debug, Serialize, Deserialize)]

View File

@@ -307,10 +307,12 @@ impl Monitor {
DefaultLayout::RightMainVerticalStack => {
workspace.add_container_to_front(container);
}
DefaultLayout::UltrawideVerticalStack
if workspace.containers().len() == 1 =>
{
workspace.insert_container_at_idx(0, container);
DefaultLayout::UltrawideVerticalStack => {
if workspace.containers().len() == 1 {
workspace.insert_container_at_idx(0, container);
} else {
workspace.add_container_to_back(container);
}
}
_ => {
workspace.add_container_to_back(container);
@@ -330,10 +332,12 @@ impl Monitor {
match layout {
DefaultLayout::RightMainVerticalStack
| DefaultLayout::UltrawideVerticalStack
if workspace.containers().len() == 1 =>
{
workspace.add_container_to_back(container);
| DefaultLayout::UltrawideVerticalStack => {
if workspace.containers().len() == 1 {
workspace.add_container_to_back(container);
} else {
workspace.insert_container_at_idx(target_index, container);
}
}
_ => {
workspace.insert_container_at_idx(target_index, container);

View File

@@ -25,7 +25,6 @@ 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;
@@ -45,10 +44,6 @@ 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();
@@ -67,40 +62,11 @@ 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();
@@ -123,41 +89,7 @@ where
F: Fn() -> I + Copy,
I: Iterator<Item = Result<win32_display_data::Device, win32_display_data::Error>>,
{
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 all_displays = display_provider().flatten().collect::<Vec<_>>();
let mut serial_id_map = HashMap::new();
@@ -271,8 +203,6 @@ 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());
@@ -416,180 +346,12 @@ where
continue 'receiver;
}
// Handle potential monitor removal with verification
let attached_devices = if initial_monitor_count > attached_devices.len() {
if initial_monitor_count > attached_devices.len() {
tracing::info!(
"potential monitor removal detected ({initial_monitor_count} vs {}), verifying in 3s",
"monitor count mismatch ({initial_monitor_count} vs {}), removing disconnected monitors",
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();
@@ -822,9 +584,7 @@ where
}
if is_focused_workspace {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
if let Some(window) = container.focused_window() {
tracing::debug!(
"restoring window: {}",
window.hwnd
@@ -836,9 +596,7 @@ where
// first window and show that one
container.focus_window(0);
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
if let Some(window) = container.focused_window() {
WindowsApi::restore_window(window.hwnd);
}
}
@@ -859,9 +617,7 @@ where
|| known_hwnds.contains_key(&window.hwnd)
{
workspace.maximized_window = None;
} else if is_focused_workspace
&& WindowsApi::is_window(window.hwnd)
{
} else if is_focused_workspace {
WindowsApi::restore_window(window.hwnd);
}
}
@@ -875,9 +631,7 @@ where
if container.windows().is_empty() {
workspace.monocle_container = None;
} else if is_focused_workspace {
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
if let Some(window) = container.focused_window() {
WindowsApi::restore_window(window.hwnd);
} else {
// If the focused window was moved or removed by
@@ -885,9 +639,7 @@ where
// first window and show that one
container.focus_window(0);
if let Some(window) = container.focused_window()
&& WindowsApi::is_window(window.hwnd)
{
if let Some(window) = container.focused_window() {
WindowsApi::restore_window(window.hwnd);
}
}
@@ -901,9 +653,7 @@ where
if is_focused_workspace {
for window in workspace.floating_windows() {
if WindowsApi::is_window(window.hwnd) {
WindowsApi::restore_window(window.hwnd);
}
WindowsApi::restore_window(window.hwnd);
}
}

View File

@@ -60,11 +60,9 @@ 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;
@@ -74,6 +72,8 @@ 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,8 +947,6 @@ impl WindowManager {
center_focused_column: Default::default(),
}),
grid: None,
column_ratios: None,
row_ratios: None,
},
};
@@ -957,29 +955,6 @@ impl WindowManager {
}
SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?,
SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?,
SocketMessage::LayoutRatios(ref columns, ref rows) => {
use crate::core::validate_ratios;
let focused_workspace = self.focused_workspace_mut()?;
let mut options = focused_workspace.layout_options.unwrap_or(LayoutOptions {
scrolling: None,
grid: None,
column_ratios: None,
row_ratios: None,
});
if let Some(cols) = columns {
options.column_ratios = Some(validate_ratios(cols));
}
if let Some(rws) = rows {
options.row_ratios = Some(validate_ratios(rws));
}
focused_workspace.layout_options = Some(options);
self.update_focused_workspace(false, false)?;
}
SocketMessage::ChangeLayoutCustom(ref path) => {
self.change_workspace_custom_layout(path)?;
}
@@ -2296,9 +2271,6 @@ if (!(Get-Process komorebi-bar -ErrorAction SilentlyContinue))
SocketMessage::Theme(ref theme) => {
theme_manager::send_notification(*theme.clone());
}
SocketMessage::ApplyState(ref state) => {
self.apply_state(state.clone());
}
// Deprecated commands
SocketMessage::AltFocusHack(_)
| SocketMessage::IdentifyBorderOverflowApplication(_, _) => {}

View File

@@ -266,33 +266,18 @@ impl WindowManager {
}
}
WindowManagerEvent::Minimize(_, window) => {
// 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 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) => {
@@ -446,24 +431,6 @@ impl WindowManager {
proceed = false;
}
// after enforce_workspace_rules() has run, check if window exists in ANY workspace
// to prevent duplication when workspace rules move windows across workspaces
if proceed {
let window_already_managed = self
.monitors()
.iter()
.flat_map(|m| m.workspaces())
.any(|ws| ws.contains_window(window.hwnd));
if window_already_managed {
tracing::debug!(
"skipping window addition, already managed after workspace rule enforcement"
);
proceed = false;
}
}
if proceed {
let behaviour = self.window_management_behaviour(
focused_monitor_idx,

View File

@@ -28,10 +28,12 @@ pub fn listen_for_movements(wm: Arc<Mutex<WindowManager>>) {
Action::Press => ignore_movement = true,
Action::Release => ignore_movement = false,
},
Event::MouseMoveRelative { .. } if !ignore_movement => {
match wm.lock().raise_window_at_cursor_pos() {
Ok(()) => {}
Err(error) => tracing::error!("{}", error),
Event::MouseMoveRelative { .. } => {
if !ignore_movement {
match wm.lock().raise_window_at_cursor_pos() {
Ok(()) => {}
Err(error) => tracing::error!("{}", error),
}
}
}
_ => {}

View File

@@ -253,9 +253,6 @@ impl From<&WindowManager> for State {
layout: workspace.layout.clone(),
layout_options: workspace.layout_options,
layout_rules: workspace.layout_rules.clone(),
layout_options_rules: workspace.layout_options_rules.clone(),
layout_defaults_cache: workspace.layout_defaults_cache.clone(),
work_area_offset_rules: workspace.work_area_offset_rules.clone(),
layout_flip: workspace.layout_flip,
workspace_padding: workspace.workspace_padding,
container_padding: workspace.container_padding,

View File

@@ -5,6 +5,7 @@ use crate::DATA_DIR;
use crate::DEFAULT_CONTAINER_PADDING;
use crate::DEFAULT_MOUSE_FOLLOWS_FOCUS;
use crate::DEFAULT_RESIZE_DELTA;
use crate::DEFAULT_WORKSPACE_LAYOUT;
use crate::DEFAULT_WORKSPACE_PADDING;
use crate::DISPLAY_INDEX_PREFERENCES;
use crate::FLOATING_APPLICATIONS;
@@ -13,7 +14,6 @@ use crate::FloatingLayerBehaviour;
use crate::HIDING_BEHAVIOUR;
use crate::IGNORE_IDENTIFIERS;
use crate::LAYERED_WHITELIST;
use crate::LAYOUT_DEFAULTS;
use crate::MANAGE_IDENTIFIERS;
use crate::MONITOR_INDEX_PREFERENCES;
use crate::NO_TITLEBAR;
@@ -39,8 +39,6 @@ use crate::animation::ANIMATION_FPS;
use crate::animation::ANIMATION_STYLE_GLOBAL;
use crate::animation::ANIMATION_STYLE_PER_ANIMATION;
use crate::animation::DEFAULT_ANIMATION_FPS;
use crate::animation::DEFAULT_GHOST_MOVEMENT;
use crate::animation::GHOST_MOVEMENT_ENABLED;
use crate::animation::PerAnimationPrefixConfig;
use crate::asc::ApplicationSpecificConfiguration;
use crate::asc::AscApplicationRulesOrSchema;
@@ -56,8 +54,6 @@ use crate::core::DefaultLayout;
use crate::core::FocusFollowsMouseImplementation;
use crate::core::HidingBehaviour;
use crate::core::Layout;
use crate::core::LayoutDefaultEntry;
use crate::core::LayoutOptions;
use crate::core::MoveBehaviour;
use crate::core::OperationBehaviour;
use crate::core::Rect;
@@ -72,6 +68,7 @@ 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;
@@ -219,12 +216,6 @@ pub struct WorkspaceConfig {
/// Layout-specific options
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options: Option<LayoutOptions>,
/// Threshold-based layout options rules in the format of threshold => options.
/// When container count >= threshold, the highest matching threshold's options
/// fully replace the base `layout_options`.
/// This follows the same threshold logic as `layout_rules`.
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options_rules: Option<HashMap<usize, LayoutOptions>>,
/// END OF LIFE FEATURE: Custom Layout
#[deprecated(note = "End of life feature")]
#[serde(skip_serializing_if = "Option::is_none")]
@@ -233,9 +224,6 @@ pub struct WorkspaceConfig {
/// Layout rules in the format of threshold => layout
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_rules: Option<HashMap<usize, DefaultLayout>>,
/// Work area offset rules in the format of threshold => Rect (default: None)
#[serde(skip_serializing_if = "Option::is_none")]
pub work_area_offset_rules: Option<HashMap<usize, Rect>>,
/// END OF LIFE FEATURE: Custom layout rules
#[deprecated(note = "End of life feature")]
#[serde(skip_serializing_if = "Option::is_none")]
@@ -300,13 +288,6 @@ impl From<&Workspace> for WorkspaceConfig {
}
let layout_rules = (!layout_rules.is_empty()).then_some(layout_rules);
let mut work_area_offset_rules = HashMap::new();
for (threshold, offset) in &value.work_area_offset_rules {
work_area_offset_rules.insert(*threshold, *offset);
}
let work_area_offset_rules =
(!work_area_offset_rules.is_empty()).then_some(work_area_offset_rules);
let mut window_container_behaviour_rules = HashMap::new();
for (threshold, behaviour) in value.window_container_behaviour_rules.iter().flatten() {
window_container_behaviour_rules.insert(*threshold, *behaviour);
@@ -345,18 +326,7 @@ impl From<&Workspace> for WorkspaceConfig {
Layout::Custom(_) => None,
})
.flatten(),
layout_options: {
tracing::debug!(
"Parsing workspace config - layout_options: {:?}",
value.layout_options
);
value.layout_options
},
layout_options_rules: if value.layout_options_rules.is_empty() {
None
} else {
Some(value.layout_options_rules.iter().copied().collect())
},
layout_options: value.layout_options,
#[allow(deprecated)]
custom_layout: value
.workspace_config
@@ -378,7 +348,6 @@ impl From<&Workspace> for WorkspaceConfig {
.workspace_config
.as_ref()
.and_then(|c| c.workspace_rules.clone()),
work_area_offset_rules,
work_area_offset: value.work_area_offset,
apply_window_based_work_area_offset: Some(value.apply_window_based_work_area_offset),
window_container_behaviour: value.window_container_behaviour,
@@ -477,7 +446,7 @@ pub enum AppSpecificConfigurationPath {
#[serde_with::serde_as]
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// The `komorebi.json` static configuration file reference for `v0.1.42`
/// The `komorebi.json` static configuration file reference for `v0.1.40`
pub struct StaticConfig {
/// DEPRECATED from v0.1.22: no longer required
#[deprecated(note = "No longer required")]
@@ -589,6 +558,11 @@ pub struct StaticConfig {
/// Individual window transparency ignore rules
#[serde(skip_serializing_if = "Option::is_none")]
pub transparency_ignore_rules: Option<Vec<MatchingRule>>,
/// Global default workspace layout for new workspaces
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = DefaultLayout::BSP)))]
#[serde(deserialize_with = "crate::default_layout::deserialize_option_none_default_layout")]
pub default_workspace_layout: Option<DefaultLayout>,
/// Global default workspace padding
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_WORKSPACE_PADDING)))]
@@ -597,11 +571,6 @@ pub struct StaticConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = DEFAULT_CONTAINER_PADDING)))]
pub default_container_padding: Option<i32>,
/// Per-layout default options and rules, keyed by layout name.
/// Applied as fallback when a workspace does not define its own layout_options or layout_options_rules.
/// If a workspace defines either setting, all global defaults for that layout are completely replaced.
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_defaults: Option<HashMap<DefaultLayout, LayoutDefaultEntry>>,
/// Monitor and workspace configurations
#[serde(skip_serializing_if = "Option::is_none")]
pub monitors: Option<Vec<MonitorConfig>>,
@@ -697,11 +666,6 @@ pub struct AnimationsConfig {
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = ANIMATION_FPS)))]
pub fps: Option<u64>,
/// Render movement animations on a GPU-composited ghost surface (recommended).
/// When false, falls back to the legacy per-frame MoveWindow path.
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "schemars", schemars(extend("default" = true)))]
pub ghost_movement: Option<bool>,
}
pub use komorebi_themes::KomorebiTheme;
@@ -916,14 +880,6 @@ impl From<&WindowManager> for StaticConfig {
default_container_padding: Option::from(
DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst),
),
layout_defaults: {
let guard = LAYOUT_DEFAULTS.lock();
if guard.is_empty() {
None
} else {
Some(guard.clone())
}
},
monitors: Option::from(monitors),
window_hiding_behaviour: Option::from(*HIDING_BEHAVIOUR.lock()),
global_work_area_offset: value.work_area_offset,
@@ -957,6 +913,7 @@ impl From<&WindowManager> for StaticConfig {
remove_titlebar_applications: Option::from(NO_TITLEBAR.lock().clone()),
floating_window_aspect_ratio: Option::from(*FLOATING_WINDOW_TOGGLE_ASPECT_RATIO.lock()),
window_handling_behaviour: Option::from(WINDOW_HANDLING_BEHAVIOUR.load()),
default_workspace_layout: DEFAULT_WORKSPACE_LAYOUT.load(),
}
}
}
@@ -1029,33 +986,18 @@ impl StaticConfig {
animations.fps.unwrap_or(DEFAULT_ANIMATION_FPS),
Ordering::SeqCst,
);
let ghost_movement_enabled =
animations.ghost_movement.unwrap_or(DEFAULT_GHOST_MOVEMENT);
GHOST_MOVEMENT_ENABLED.store(ghost_movement_enabled, Ordering::SeqCst);
if ghost_movement_enabled {
// Spawn the ghost owner thread now so the first animation
// doesn't pay the spawn + wndclass-registration cost. Lazy
// guarantee preserved: users who turn ghost_movement off
// never trigger this path, so the thread is never created.
crate::animation::ghost::prewarm();
}
}
if let Some(container) = self.default_container_padding {
DEFAULT_CONTAINER_PADDING.store(container, Ordering::SeqCst);
}
DEFAULT_WORKSPACE_LAYOUT.store(self.default_workspace_layout);
if let Some(workspace) = self.default_workspace_padding {
DEFAULT_WORKSPACE_PADDING.store(workspace, Ordering::SeqCst);
}
if let Some(defaults) = &self.layout_defaults {
*LAYOUT_DEFAULTS.lock() = defaults.clone();
} else {
LAYOUT_DEFAULTS.lock().clear();
}
if let Some(border_width) = self.border_width {
border_manager::BORDER_WIDTH.store(border_width, Ordering::SeqCst);
}
@@ -1464,7 +1406,7 @@ impl StaticConfig {
workspace_config.layout = Some(DefaultLayout::Columns);
}
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
ws.load_static_config(workspace_config)?;
}
}
@@ -1547,10 +1489,7 @@ impl StaticConfig {
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
ws.load_static_config(
workspace_config,
value.layout_defaults.as_ref(),
)?;
ws.load_static_config(workspace_config)?;
}
}
@@ -1632,7 +1571,7 @@ impl StaticConfig {
for (j, ws) in monitor.workspaces_mut().iter_mut().enumerate() {
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
ws.load_static_config(workspace_config)?;
}
}
@@ -1715,10 +1654,7 @@ impl StaticConfig {
for (j, ws) in m.workspaces_mut().iter_mut().enumerate() {
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
ws.load_static_config(
workspace_config,
value.layout_defaults.as_ref(),
)?;
ws.load_static_config(workspace_config)?;
}
}
@@ -1986,7 +1922,7 @@ mod tests {
let docs = vec![
"0.1.20", "0.1.21", "0.1.22", "0.1.23", "0.1.24", "0.1.25", "0.1.26", "0.1.27",
"0.1.28", "0.1.29", "0.1.30", "0.1.31", "0.1.32", "0.1.33", "0.1.34", "0.1.35",
"0.1.36", "0.1.37", "0.1.38", "0.1.39",
"0.1.36", "0.1.37", "0.1.38",
];
let mut versions = vec![];

View File

@@ -20,9 +20,7 @@ use crate::animation::ANIMATION_MANAGER;
use crate::animation::ANIMATION_STYLE_GLOBAL;
use crate::animation::ANIMATION_STYLE_PER_ANIMATION;
use crate::animation::AnimationEngine;
use crate::animation::GHOST_MOVEMENT_ENABLED;
use crate::animation::RenderDispatcher;
use crate::animation::ghost::GhostWindow;
use crate::animation::lerp::Lerp;
use crate::animation::prefix::AnimationPrefix;
use crate::animation::prefix::new_animation_key;
@@ -44,7 +42,6 @@ use crate::windows_api;
use crate::windows_api::WindowsApi;
use color_eyre::eyre;
use crossbeam_utils::atomic::AtomicConsume;
use parking_lot::Mutex;
use regex::Regex;
use serde::Deserialize;
use serde::Serialize;
@@ -55,7 +52,6 @@ use std::convert::TryFrom;
use std::fmt::Display;
use std::fmt::Formatter;
use std::fmt::Write as _;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::AtomicI32;
use std::sync::atomic::Ordering;
use std::thread;
@@ -169,18 +165,6 @@ struct MovementRenderDispatcher {
target_rect: Rect,
top: bool,
style: AnimationStyle,
/// Some between successful pre_render and post_render/cleanup_on_cancel when
/// ghost movement is active. None for the legacy code path.
ghost: Mutex<Option<GhostWindow>>,
/// Tracks whether the source has been cloaked so cleanup can uncloak idempotently.
cloaked: AtomicBool,
/// Last lerped logical rect actually applied; used by cleanup_on_cancel to
/// snap the real window to the position the user was last seeing.
last_animated_rect: Mutex<Rect>,
/// True when pre_render successfully repositioned the source to target_rect
/// before registering the thumbnail. In that case post_render must skip
/// the final position_window since the source is already there.
pre_painted: AtomicBool,
}
impl MovementRenderDispatcher {
@@ -199,33 +183,37 @@ impl MovementRenderDispatcher {
target_rect,
top,
style,
ghost: Mutex::new(None),
cloaked: AtomicBool::new(false),
last_animated_rect: Mutex::new(start_rect),
pre_painted: AtomicBool::new(false),
}
}
}
fn use_ghost(&self) -> bool {
GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed)
impl RenderDispatcher for MovementRenderDispatcher {
fn get_animation_key(&self) -> String {
new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string())
}
/// Chromium / Electron windows expose a top-level class beginning with
/// `Chrome_WidgetWin_`. Their renderer pipeline is suspended whenever
/// `NativeWindowOcclusionTrackerWin` reads any non-zero `DWMWA_CLOAKED`
/// state on the HWND, so the pre-paint trick (cloak → SetWindowPos →
/// capture) leaves the DComp swap chain stale and the post-uncloak frame
/// shows half-painted / black regions. For these apps we fall back to
/// capture-at-start: keep the source cloaked at start_rect for the whole
/// animation and only move it to target in post_render, where the
/// uncloak is the visibility flip that wakes Viz back up.
fn source_is_chromium_shell(&self) -> bool {
WindowsApi::real_window_class_w(self.hwnd)
.map(|class| class.starts_with("Chrome_WidgetWin_"))
.unwrap_or(false)
fn pre_render(&self) -> eyre::Result<()> {
stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst);
stackbar_manager::send_notification();
Ok(())
}
fn finalise_managers(&self) {
fn render(&self, progress: f64) -> eyre::Result<()> {
let new_rect = self.start_rect.lerp(self.target_rect, progress, self.style);
// we don't check WINDOW_HANDLING_BEHAVIOUR here because animations
// are always run on a separate thread
WindowsApi::move_window(self.hwnd, &new_rect, false)?;
WindowsApi::invalidate_rect(self.hwnd, None, false);
Ok(())
}
fn post_render(&self) -> eyre::Result<()> {
// we don't add the async_window_pos flag here because animations
// are always run on a separate thread
WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false)?;
if ANIMATION_MANAGER
.lock()
.count_in_progress(MovementRenderDispatcher::PREFIX)
@@ -240,202 +228,9 @@ impl MovementRenderDispatcher {
stackbar_manager::send_notification();
transparency_manager::send_notification();
}
}
}
impl RenderDispatcher for MovementRenderDispatcher {
fn get_animation_key(&self) -> String {
new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string())
}
fn pre_render(&self) -> eyre::Result<()> {
stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst);
stackbar_manager::send_notification();
if self.use_ghost() {
let is_chromium = self.source_is_chromium_shell();
// The ghost host is sized to the LOGICAL rect (visible content
// area). DWM thumbnails capture the source at its
// DWMWA_EXTENDED_FRAME_BOUNDS extents (visible content), not
// GetWindowRect outer extents that include the drop-shadow
// margin. Sizing the host to outer dims would stretch the
// visible-content texture by the shadow ratio.
//
// Place the ghost in z-order immediately above the source so
// multiple simultaneously animating windows (workspace switches,
// layout flips) keep the same relative stacking as their
// sources rather than all piling up at HWND_TOP in creation
// order.
//
// For non-Chromium sources we ALSO pre-position the source to
// target_rect *before* registering the thumbnail, so the
// captured pixels reflect target-dimensioned content. The ghost
// dest then animates start → target with the texture
// downscaling to native 1:1 at the end — crisp final frame
// instead of an upscaled blur. For Chromium we skip pre-paint
// (see `source_is_chromium_shell`).
//
// DwmSetWindowAttribute(DWMWA_CLOAK) is rejected with
// E_ACCESSDENIED for foreign HWNDs; the undocumented
// IApplicationView::SetCloak path used elsewhere does not have
// that restriction.
SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 2);
self.cloaked.store(true, Ordering::SeqCst);
if !is_chromium {
if let Err(error) =
WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false)
{
tracing::warn!(
"ghost movement: failed to pre-position hwnd {}: {error}",
self.hwnd
);
} else {
// No DwmFlush here. DWM thumbnails are live: once
// registered, the thumbnail surface updates as the
// source paints, so the texture catches up to
// target-dim content within the first frame or two of
// the animation. Skipping the flush avoids a ~16ms
// pre-render stall on every non-Chromium animation.
self.pre_painted.store(true, Ordering::SeqCst);
}
}
match GhostWindow::create(self.hwnd, self.start_rect, Some(self.hwnd)) {
Ok(ghost) => {
*self.ghost.lock() = Some(ghost);
}
Err(error) => {
tracing::warn!(
"ghost movement: failed to create ghost for hwnd {}: {error}; \
uncloaking and falling back to legacy path",
self.hwnd
);
SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0);
self.cloaked.store(false, Ordering::SeqCst);
}
}
}
Ok(())
}
fn render(&self, progress: f64) -> eyre::Result<()> {
let logical = self.start_rect.lerp(self.target_rect, progress, self.style);
*self.last_animated_rect.lock() = logical;
let ghost_active = self.ghost.lock().is_some();
if ghost_active {
if let Some(ghost) = self.ghost.lock().as_ref()
&& let Err(error) = ghost.update_rect(logical)
{
tracing::trace!("ghost update_rect failed: {error}");
}
border_manager::animate_to(self.hwnd, logical);
} else {
// Legacy path: animations always run on a separate thread, so we don't
// gate on WINDOW_HANDLING_BEHAVIOUR here.
WindowsApi::move_window(self.hwnd, &logical, false)?;
WindowsApi::invalidate_rect(self.hwnd, None, false);
}
Ok(())
}
fn post_render(&self) -> eyre::Result<()> {
let used_ghost = self.ghost.lock().is_some();
let pre_painted = self.pre_painted.load(Ordering::SeqCst);
// Final single SetWindowPos. For the pre-paint ghost path the source
// has already been moved to target_rect in pre_render and we skip
// this. For the Chromium ghost path (no pre-paint) the source is
// still cloaked at start_rect and needs to be moved here. For the
// legacy non-ghost path this is the original final reposition.
if !pre_painted {
WindowsApi::position_window(self.hwnd, &self.target_rect, self.top, false)?;
}
// Uncloak BEFORE crossfade so the real window's first post-resize
// frame is being composed underneath the still-visible ghost while
// we fade. This gives Chromium/Electron renderers time to produce a
// CompositorFrame at the new size — the visibility flip from
// cloaked-to-uncloaked is what nudges Viz to resume frame
// production.
if self.cloaked.swap(false, Ordering::SeqCst) {
SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0);
}
if used_ghost {
// Crossfade the ghost out over several DWM frames. This masks the
// texture mismatch (start-dim bitmap stretched vs. crisp
// target-dim repaint) and gives slow-to-repaint apps time to
// present their first post-resize frame before the overlay is
// removed. Mirrors KWin's geometry-effect crossfade.
//
// Ease-in curve (1 - t^3): opacity holds high for most of the
// fade and only drops sharply at the end. The ghost stays
// prominent while the real window's first few frames land
// underneath, so the user perceives a smooth reveal rather than
// a snap.
//
// We call set_opacity directly (synchronous DwmUpdateThumbnailProperties
// on this thread) rather than via the ghost owner channel, so
// each step is guaranteed to be visible before the following
// DwmFlush waits for the next vblank.
if let Some(ghost) = self.ghost.lock().as_ref() {
const FADE_STEPS: u32 = 8;
for step in 1..=FADE_STEPS {
let t = step as f32 / FADE_STEPS as f32;
let progress = t * t * t;
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let opacity_u8 = ((1.0 - progress) * 255.0).round().clamp(0.0, 255.0) as u8;
let _ = ghost.set_opacity(opacity_u8);
unsafe {
let _ = windows::Win32::Graphics::Dwm::DwmFlush();
}
}
}
} else {
// Legacy path: still benefit from one DWM frame's wait so the
// app's first post-move paint lands.
unsafe {
let _ = windows::Win32::Graphics::Dwm::DwmFlush();
}
}
if let Some(ghost) = self.ghost.lock().take() {
let _ = ghost.dispose();
}
self.finalise_managers();
Ok(())
}
fn cleanup_on_cancel(&self) {
// Snap the real window to wherever the ghost was last drawn so the next
// dispatcher can capture an accurate start_rect. Then uncloak and tear
// down the ghost. Mirrors post_render but uses last_animated_rect.
let target = *self.last_animated_rect.lock();
if let Err(error) = WindowsApi::position_window(self.hwnd, &target, false, false) {
tracing::warn!(
"ghost movement cancel: failed to snap hwnd {} to last rect: {error}",
self.hwnd
);
}
if self.cloaked.swap(false, Ordering::SeqCst) {
SetCloak(Window { hwnd: self.hwnd }.hwnd(), 1, 0);
}
if let Some(ghost) = self.ghost.lock().take() {
let _ = ghost.dispose();
}
self.finalise_managers();
}
}
struct TransparencyRenderDispatcher {

View File

@@ -28,7 +28,6 @@ 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;
@@ -41,6 +40,7 @@ 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;
@@ -239,30 +239,21 @@ impl WindowManager {
let mouse_follows_focus = self.mouse_follows_focus;
for (monitor_idx, monitor) in self.monitors_mut().iter_mut().enumerate() {
let mut focused_workspace = 0;
if let Some(state_monitor) = state.monitors.elements().get(monitor_idx) {
monitor
.workspaces_mut()
.resize(state_monitor.workspaces().len(), Workspace::default());
for (workspace_idx, workspace) in
monitor.workspaces_mut().iter_mut().enumerate()
for (workspace_idx, workspace) in monitor.workspaces_mut().iter_mut().enumerate() {
if let Some(state_monitor) = state.monitors.elements().get(monitor_idx)
&& let Some(state_workspace) = state_monitor.workspaces().get(workspace_idx)
{
if let Some(state_workspace) = state_monitor.workspaces().get(workspace_idx)
{
// 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;
// to make sure padding changes get applied for users after a quick restart
let container_padding = workspace.container_padding;
let workspace_padding = workspace.workspace_padding;
*workspace = state_workspace.clone();
*workspace = state_workspace.clone();
workspace.container_padding = container_padding;
workspace.workspace_padding = workspace_padding;
workspace.layout_options = layout_options;
workspace.container_padding = container_padding;
workspace.workspace_padding = workspace_padding;
if state_monitor.focused_workspace_idx() == workspace_idx {
focused_workspace = workspace_idx;
}
if state_monitor.focused_workspace_idx() == workspace_idx {
focused_workspace = workspace_idx;
}
}
}
@@ -2110,19 +2101,12 @@ impl WindowManager {
tracing::info!("focusing container");
if workspace.monocle_container.is_some() {
let cycle_direction = match direction {
OperationDirection::Left | OperationDirection::Down => CycleDirection::Previous,
OperationDirection::Right | OperationDirection::Up => CycleDirection::Next,
let new_idx =
if workspace.maximized_window.is_some() || workspace.monocle_container.is_some() {
None
} else {
workspace.new_idx_for_direction(direction)
};
return self.cycle_monocle(cycle_direction);
}
let new_idx = if workspace.maximized_window.is_some() {
None
} else {
workspace.new_idx_for_direction(direction)
};
let mut cross_monitor_monocle_or_max = false;
@@ -3107,27 +3091,6 @@ impl WindowManager {
workspace.reintegrate_monocle_container()
}
#[tracing::instrument(skip(self))]
pub fn cycle_monocle(&mut self, direction: CycleDirection) -> eyre::Result<()> {
tracing::info!("cycling monocle container");
if self.focused_workspace()?.containers().is_empty() {
return Ok(());
}
self.focused_workspace_mut()?
.cycle_monocle_container(direction)?;
for container in self.focused_workspace_mut()?.containers_mut() {
container.hide(None);
}
// borders were getting funny during cycles, can't be bothered to root cause it
border_manager::destroy_all_borders()?;
self.update_focused_workspace(true, true)
}
#[tracing::instrument(skip(self))]
pub fn toggle_maximize(&mut self) -> eyre::Result<()> {
self.handle_unmanaged_window_behaviour()?;
@@ -3376,7 +3339,7 @@ impl WindowManager {
let rules: &mut Vec<(usize, Layout)> = &mut workspace.layout_rules;
rules.retain(|pair| pair.0 != at_container_count);
rules.push((at_container_count, Layout::Default(layout)));
rules.sort_by_key(|a| a.0);
rules.sort_by(|a, b| a.0.cmp(&b.0));
// If this is the focused workspace on a non-focused screen, let's update it
if focused_monitor_idx != monitor_idx && focused_workspace_idx == workspace_idx {
@@ -3419,7 +3382,7 @@ impl WindowManager {
let rules: &mut Vec<(usize, Layout)> = &mut workspace.layout_rules;
rules.retain(|pair| pair.0 != at_container_count);
rules.push((at_container_count, Layout::Custom(layout)));
rules.sort_by_key(|a| a.0);
rules.sort_by(|a, b| a.0.cmp(&b.0));
// If this is the focused workspace on a non-focused screen, let's update it
if focused_monitor_idx != monitor_idx && focused_workspace_idx == workspace_idx {
@@ -3913,6 +3876,7 @@ impl WindowManager {
#[cfg(test)]
mod tests {
use super::*;
use crate::DEFAULT_WORKSPACE_LAYOUT;
use crate::monitor;
use crossbeam_channel::Sender;
use crossbeam_channel::bounded;
@@ -5239,6 +5203,7 @@ mod tests {
#[test]
fn test_toggle_tiling() {
let (mut wm, _context) = setup_window_manager();
DEFAULT_WORKSPACE_LAYOUT.store(Some(DefaultLayout::BSP));
{
let mut m = monitor::new(

View File

@@ -24,7 +24,6 @@ use windows::Win32::Foundation::WPARAM;
use windows::Win32::Graphics::Dwm::DWM_CLOAKED_APP;
use windows::Win32::Graphics::Dwm::DWM_CLOAKED_INHERITED;
use windows::Win32::Graphics::Dwm::DWM_CLOAKED_SHELL;
use windows::Win32::Graphics::Dwm::DWM_THUMBNAIL_PROPERTIES;
use windows::Win32::Graphics::Dwm::DWMWA_BORDER_COLOR;
use windows::Win32::Graphics::Dwm::DWMWA_CLOAKED;
use windows::Win32::Graphics::Dwm::DWMWA_COLOR_NONE;
@@ -33,10 +32,7 @@ use windows::Win32::Graphics::Dwm::DWMWA_WINDOW_CORNER_PREFERENCE;
use windows::Win32::Graphics::Dwm::DWMWCP_ROUND;
use windows::Win32::Graphics::Dwm::DWMWINDOWATTRIBUTE;
use windows::Win32::Graphics::Dwm::DwmGetWindowAttribute;
use windows::Win32::Graphics::Dwm::DwmRegisterThumbnail;
use windows::Win32::Graphics::Dwm::DwmSetWindowAttribute;
use windows::Win32::Graphics::Dwm::DwmUnregisterThumbnail;
use windows::Win32::Graphics::Dwm::DwmUpdateThumbnailProperties;
use windows::Win32::Graphics::Gdi::CreateSolidBrush;
use windows::Win32::Graphics::Gdi::EnumDisplayMonitors;
use windows::Win32::Graphics::Gdi::GetMonitorInfoW;
@@ -148,7 +144,6 @@ use windows::Win32::UI::WindowsAndMessaging::WS_DISABLED;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_NOACTIVATE;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOPMOST;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TRANSPARENT;
use windows::Win32::UI::WindowsAndMessaging::WS_POPUP;
use windows::Win32::UI::WindowsAndMessaging::WS_SYSMENU;
use windows::Win32::UI::WindowsAndMessaging::WindowFromPoint;
@@ -1348,41 +1343,6 @@ impl WindowsApi {
}
}
pub fn create_ghost_host_window(name: PCWSTR, instance: isize) -> eyre::Result<isize> {
unsafe {
CreateWindowExW(
WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE | WS_EX_TRANSPARENT,
name,
name,
WS_POPUP,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
CW_USEDEFAULT,
None,
None,
Option::from(HINSTANCE(as_ptr!(instance))),
None,
)?
}
.process()
}
pub fn dwm_register_thumbnail(dest_hwnd: isize, src_hwnd: isize) -> eyre::Result<isize> {
Ok(unsafe { DwmRegisterThumbnail(HWND(as_ptr!(dest_hwnd)), HWND(as_ptr!(src_hwnd))) }?)
}
pub fn dwm_update_thumbnail_properties(
hthumb: isize,
props: &DWM_THUMBNAIL_PROPERTIES,
) -> eyre::Result<()> {
unsafe { DwmUpdateThumbnailProperties(hthumb, props) }.map_err(Into::into)
}
pub fn dwm_unregister_thumbnail(hthumb: isize) -> eyre::Result<()> {
unsafe { DwmUnregisterThumbnail(hthumb) }.map_err(Into::into)
}
pub fn create_hidden_window(name: PCWSTR, instance: isize) -> eyre::Result<isize> {
unsafe {
CreateWindowExW(

View File

@@ -1,4 +1,3 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fmt::Display;
@@ -9,6 +8,7 @@ use std::sync::atomic::Ordering;
use crate::DATA_DIR;
use crate::DEFAULT_CONTAINER_PADDING;
use crate::DEFAULT_WORKSPACE_LAYOUT;
use crate::DEFAULT_WORKSPACE_PADDING;
use crate::FloatingLayerBehaviour;
use crate::INITIAL_CONFIGURATION_LOADED;
@@ -26,10 +26,9 @@ use crate::core::CustomLayout;
use crate::core::CycleDirection;
use crate::core::DefaultLayout;
use crate::core::Layout;
use crate::core::LayoutDefaultEntry;
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;
@@ -63,15 +62,6 @@ pub struct Workspace {
pub layout: Layout,
pub layout_options: Option<LayoutOptions>,
pub layout_rules: Vec<(usize, Layout)>,
/// Threshold-based layout options rules (container_count >= threshold -> use these options).
/// Sorted by threshold ascending at load time.
#[serde(default)]
pub layout_options_rules: Vec<(usize, LayoutOptions)>,
/// Cached per-layout defaults from the global `layout_defaults` config setting.
/// Pre-sorted at config load time; used as fallback when workspace has no overrides.
#[serde(skip)]
pub(crate) layout_defaults_cache: HashMap<DefaultLayout, CachedLayoutDefault>,
pub work_area_offset_rules: Vec<(usize, Rect)>,
pub layout_flip: Option<Axis>,
pub workspace_padding: Option<i32>,
pub container_padding: Option<i32>,
@@ -118,6 +108,8 @@ impl_ring_elements!(Workspace, Window, "floating_window");
impl Default for Workspace {
fn default() -> Self {
let default_layout = DEFAULT_WORKSPACE_LAYOUT.load();
Self {
name: None,
containers: Ring::default(),
@@ -126,18 +118,15 @@ impl Default for Workspace {
maximized_window_restore_idx: None,
monocle_container_restore_idx: None,
floating_windows: Ring::default(),
layout: Layout::Default(DefaultLayout::BSP),
layout: Layout::Default(default_layout.unwrap_or(DefaultLayout::BSP)),
layout_options: None,
layout_rules: vec![],
layout_options_rules: vec![],
layout_defaults_cache: HashMap::new(),
work_area_offset_rules: vec![],
layout_flip: None,
workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)),
container_padding: Option::from(DEFAULT_CONTAINER_PADDING.load(Ordering::SeqCst)),
latest_layout: vec![],
resize_dimensions: vec![],
tile: true,
tile: default_layout.is_some(),
work_area_offset: None,
apply_window_based_work_area_offset: true,
window_container_behaviour: None,
@@ -177,49 +166,8 @@ pub struct WorkspaceGlobals {
pub floating_layer_behaviour: Option<FloatingLayerBehaviour>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
/// Cached per-layout default options (pre-sorted rules) derived from the global `layout_defaults`.
pub(crate) struct CachedLayoutDefault {
pub layout_options: Option<LayoutOptions>,
/// Threshold-based rules, sorted by threshold ascending at load time
pub layout_options_rules: Vec<(usize, LayoutOptions)>,
}
/// Convert an optional HashMap of threshold-based layout options rules into a Vec sorted by
/// threshold ascending.
fn sorted_layout_options_rules(
rules: Option<&HashMap<usize, LayoutOptions>>,
) -> Vec<(usize, LayoutOptions)> {
match rules {
Some(rules) => {
let mut sorted: Vec<(usize, LayoutOptions)> =
rules.iter().map(|(t, o)| (*t, *o)).collect();
sorted.sort_by_key(|(t, _)| *t);
sorted
}
None => vec![],
}
}
/// Find the highest matching threshold rule for the given container count.
/// Rules must be sorted by threshold ascending.
fn resolve_threshold_match(
rules: &[(usize, LayoutOptions)],
container_count: usize,
) -> Option<LayoutOptions> {
rules
.iter()
.rev()
.find(|(threshold, _)| container_count >= *threshold)
.map(|(_, opts)| *opts)
}
impl Workspace {
pub fn load_static_config(
&mut self,
config: &WorkspaceConfig,
layout_defaults: Option<&HashMap<DefaultLayout, LayoutDefaultEntry>>,
) -> eyre::Result<()> {
pub fn load_static_config(&mut self, config: &WorkspaceConfig) -> eyre::Result<()> {
self.name = Option::from(config.name.clone());
self.container_padding = config.container_padding;
@@ -268,15 +216,6 @@ impl Workspace {
self.layout_rules = all_layout_rules;
}
let mut all_work_area_offset_rules = vec![];
if let Some(work_area_offset_rules) = &config.work_area_offset_rules {
for (count, rect) in work_area_offset_rules {
all_work_area_offset_rules.push((*count, *rect));
}
all_work_area_offset_rules.sort_by_key(|(i, _)| *i);
self.work_area_offset_rules = all_work_area_offset_rules;
}
self.work_area_offset = config.work_area_offset;
self.apply_window_based_work_area_offset =
@@ -304,78 +243,13 @@ impl Workspace {
self.layout_flip = config.layout_flip;
self.floating_layer_behaviour = config.floating_layer_behaviour;
self.wallpaper = config.wallpaper.clone();
// Load layout options directly (LayoutOptions is used in both config and runtime)
self.layout_options = config.layout_options;
// Load threshold-based layout options rules, sorted by threshold ascending
self.layout_options_rules =
sorted_layout_options_rules(config.layout_options_rules.as_ref());
tracing::debug!(
"Workspace '{}' loaded layout_options: {:?}, layout_options_rules: {} entries",
self.name.as_deref().unwrap_or("unnamed"),
self.layout_options,
self.layout_options_rules.len(),
);
// Cache per-layout defaults from global layout_defaults, pre-sorting rules
self.layout_defaults_cache = if let Some(defaults) = layout_defaults {
defaults
.iter()
.map(|(layout, entry)| {
(
*layout,
CachedLayoutDefault {
layout_options: entry.layout_options,
layout_options_rules: sorted_layout_options_rules(
entry.layout_options_rules.as_ref(),
),
},
)
})
.collect()
} else {
HashMap::new()
};
self.workspace_config = Some(config.clone());
Ok(())
}
/// Compute effective layout options using the complete-replacement cascade:
///
/// If the workspace defines EITHER `layout_options` OR `layout_options_rules`,
/// it completely replaces the global `layout_defaults` for this layout.
/// Global defaults are only used when the workspace has NEITHER setting.
///
/// Within the effective source (workspace or global):
/// 1. Try threshold match from rules (highest matching threshold wins)
/// 2. If a rule matches -> use it (full replacement of base)
/// 3. Else -> use the base `layout_options`
fn effective_layout_options(&self) -> Option<LayoutOptions> {
let container_count = self.containers().len();
let has_workspace_overrides =
self.layout_options.is_some() || !self.layout_options_rules.is_empty();
let (effective_base, effective_rules): (Option<LayoutOptions>, &[(usize, LayoutOptions)]) =
if has_workspace_overrides {
(self.layout_options, &self.layout_options_rules)
} else {
match &self.layout {
Layout::Default(dl) => match self.layout_defaults_cache.get(dl) {
Some(entry) => (entry.layout_options, &entry.layout_options_rules),
None => (None, &[]),
},
Layout::Custom(_) => (None, &[]),
}
};
resolve_threshold_match(effective_rules, container_count).or(effective_base)
}
pub fn hide(&mut self, omit: Option<isize>) {
for window in self.floating_windows_mut().iter_mut().rev() {
let mut should_hide = omit.is_none();
@@ -602,27 +476,9 @@ impl Workspace {
let border_width = self.globals.border_width;
let border_offset = self.globals.border_offset;
let work_area = self.globals.work_area;
let work_area_offset = self.work_area_offset.or(self.globals.work_area_offset);
let window_based_work_area_offset = self.globals.window_based_work_area_offset;
let window_based_work_area_offset_limit = self.globals.window_based_work_area_offset_limit;
let mut rules_work_area_offset = None;
if !self.work_area_offset_rules.is_empty() {
let count = if self.monocle_container.is_some() {
1
} else {
self.containers().len()
};
for (threshold, work_area_offset_rule) in &self.work_area_offset_rules {
if count >= *threshold {
rules_work_area_offset = Some(*work_area_offset_rule);
}
}
};
let work_area_offset = rules_work_area_offset
.or(self.work_area_offset)
.or(self.globals.work_area_offset);
let mut adjusted_work_area = work_area_offset.map_or_else(
|| work_area,
@@ -636,6 +492,7 @@ impl Workspace {
with_offset
},
);
if (self.containers().len() <= window_based_work_area_offset_limit as usize
|| self.monocle_container.is_some() && window_based_work_area_offset_limit > 0)
&& self.apply_window_based_work_area_offset
@@ -696,15 +553,6 @@ impl Workspace {
} else if let Some(window) = &mut self.maximized_window {
window.maximize();
} else if !self.containers().is_empty() {
let effective_layout_options = self.effective_layout_options();
tracing::debug!(
"Workspace '{}' update() - effective_layout_options: {:?} (base: {:?}, rules: {})",
self.name.as_deref().unwrap_or("unnamed"),
effective_layout_options,
self.layout_options,
self.layout_options_rules.len(),
);
let mut layouts = self.layout.as_boxed_arrangement().calculate(
&adjusted_work_area,
NonZeroUsize::new(self.containers().len()).ok_or_eyre(
@@ -714,7 +562,7 @@ impl Workspace {
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
effective_layout_options,
self.layout_options,
&self.latest_layout,
);
@@ -1659,23 +1507,6 @@ impl Workspace {
Ok(())
}
pub fn cycle_monocle_container(&mut self, direction: CycleDirection) -> eyre::Result<()> {
if self.containers().is_empty() {
return Ok(());
}
self.reintegrate_monocle_container()?;
let new_idx = self
.new_idx_for_cycle_direction(direction)
.ok_or_eyre("there is no container to cycle monocle to")?;
self.focus_container(new_idx);
self.new_monocle_container()?;
Ok(())
}
pub fn new_maximized_window(&mut self) -> eyre::Result<()> {
let focused_idx = self.focused_container_idx();

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebic-no-console"
version = "0.1.42"
version = "0.1.40"
description = "The command-line interface (without a console) for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebic"
version = "0.1.42"
version = "0.1.40"
description = "The command-line interface for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -1001,16 +1001,6 @@ struct ScrollingLayoutColumns {
count: NonZeroUsize,
}
#[derive(Parser)]
struct LayoutRatios {
/// Column width ratios (space-separated values between 0.1 and 0.9)
#[clap(short, long, num_args = 1..)]
columns: Option<Vec<f32>>,
/// Row height ratios (space-separated values between 0.1 and 0.9)
#[clap(short, long, num_args = 1..)]
rows: Option<Vec<f32>>,
}
#[derive(Parser)]
struct License {
/// Email address associated with an Individual Commercial Use License
@@ -1277,8 +1267,6 @@ enum SubCommand {
/// Set the number of visible columns for the Scrolling layout on the focused workspace
#[clap(arg_required_else_help = true)]
ScrollingLayoutColumns(ScrollingLayoutColumns),
/// Set the layout column and row ratios for the focused workspace
LayoutRatios(LayoutRatios),
/// Load a custom layout from file for the focused workspace
#[clap(hide = true)]
#[clap(arg_required_else_help = true)]
@@ -1924,11 +1912,13 @@ fn main() -> eyre::Result<()> {
"Application specific configuration file path has not been set. Try running 'komorebic fetch-asc'\n"
);
}
Some(AppSpecificConfigurationPath::Single(path)) if !path.exists() => {
println!(
"Application specific configuration file path '{}' does not exist. Try running 'komorebic fetch-asc'\n",
path.display()
);
Some(AppSpecificConfigurationPath::Single(path)) => {
if !path.exists() {
println!(
"Application specific configuration file path '{}' does not exist. Try running 'komorebic fetch-asc'\n",
path.display()
);
}
}
_ => {}
}
@@ -2944,15 +2934,6 @@ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
SubCommand::ScrollingLayoutColumns(args) => {
send_message(&SocketMessage::ScrollingLayoutColumns(args.count))?;
}
SubCommand::LayoutRatios(args) => {
if args.columns.is_none() && args.rows.is_none() {
println!(
"No ratios provided, nothing to change. Use --columns or --rows to specify ratios."
);
} else {
send_message(&SocketMessage::LayoutRatios(args.columns, args.rows))?;
}
}
SubCommand::LoadCustomLayout(args) => {
send_message(&SocketMessage::ChangeLayoutCustom(args.path))?;
}

View File

@@ -81,8 +81,6 @@ nav:
- common-workflows/mouse-follows-focus.md
- common-workflows/dynamic-layout-switching.md
- common-workflows/multiple-bar-instances.md
- common-workflows/bar.md
- common-workflows/bar-widgets/systray.md
- common-workflows/multi-monitor-setup.md
- CLI reference:
- cli/quickstart.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "StaticConfig",
"description": "The `komorebi.json` static configuration file reference for `v0.1.42`",
"description": "The `komorebi.json` static configuration file reference for `v0.1.40`",
"type": "object",
"properties": {
"animation": {
@@ -152,6 +152,18 @@
"format": "int32",
"default": 10
},
"default_workspace_layout": {
"description": "Global default workspace layout for new workspaces",
"anyOf": [
{
"$ref": "#/$defs/DefaultLayout"
},
{
"type": "null"
}
],
"default": "BSP"
},
"default_workspace_padding": {
"description": "Global default workspace padding",
"type": [
@@ -304,16 +316,6 @@
"$ref": "#/$defs/MatchingRule"
}
},
"layout_defaults": {
"description": "Per-layout default options and rules, keyed by layout name.\nApplied as fallback when a workspace does not define its own layout_options or layout_options_rules.\nIf a workspace defines either setting, all global defaults for that layout are completely replaced.",
"type": [
"object",
"null"
],
"additionalProperties": {
"$ref": "#/$defs/LayoutDefaultEntry"
}
},
"manage_rules": {
"description": "Individual window force-manage rules",
"type": [
@@ -713,7 +715,7 @@
},
{
"title": "CubicBezier",
"description": "Custom Cubic Bezier function",
"description": "Custom Cubic Bézier function",
"type": "object",
"properties": {
"CubicBezier": {
@@ -778,14 +780,6 @@
"default": 60,
"minimum": 0
},
"ghost_movement": {
"description": "Render movement animations on a GPU-composited ghost surface (recommended).\nWhen false, falls back to the legacy per-frame MoveWindow path.",
"type": [
"boolean",
"null"
],
"default": true
},
"style": {
"description": "Set the animation style",
"anyOf": [
@@ -3308,57 +3302,10 @@
"colours"
]
},
"LayoutDefaultEntry": {
"description": "Per-layout default options entry for the `layout_defaults` global setting.\nContains both base layout options and threshold-based layout options rules.",
"type": "object",
"properties": {
"layout_options": {
"description": "Default layout options for this layout",
"anyOf": [
{
"$ref": "#/$defs/LayoutOptions"
},
{
"type": "null"
}
]
},
"layout_options_rules": {
"description": "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"patternProperties": {
"^\\d+$": {
"$ref": "#/$defs/LayoutOptions"
}
}
}
}
},
"LayoutOptions": {
"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": [
@@ -3370,23 +3317,6 @@
}
]
},
"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": [
@@ -4262,19 +4192,6 @@
}
]
},
"layout_options_rules": {
"description": "Threshold-based layout options rules in the format of threshold => options.\nWhen container count >= threshold, the highest matching threshold's options\nfully replace the base `layout_options`.\nThis follows the same threshold logic as `layout_rules`.",
"type": [
"object",
"null"
],
"additionalProperties": false,
"patternProperties": {
"^\\d+$": {
"$ref": "#/$defs/LayoutOptions"
}
}
},
"layout_rules": {
"description": "Layout rules in the format of threshold => layout",
"type": [
@@ -4347,19 +4264,6 @@
}
]
},
"work_area_offset_rules": {
"description": "Work area offset rules in the format of threshold => Rect (default: None)",
"type": [
"object",
"null"
],
"additionalProperties": false,
"patternProperties": {
"^\\d+$": {
"$ref": "#/$defs/Rect"
}
}
},
"workspace_padding": {
"description": "Workspace padding (default: global)",
"type": [