Compare commits

...

47 Commits

Author SHA1 Message Date
dependabot[bot]
da7d50252a chore(deps): bump eframe from 0.33.3 to 0.34.2
Bumps [eframe](https://github.com/emilk/egui) from 0.33.3 to 0.34.2.
- [Release notes](https://github.com/emilk/egui/releases)
- [Changelog](https://github.com/emilk/egui/blob/0.34.2/CHANGELOG.md)
- [Commits](https://github.com/emilk/egui/compare/0.33.3...0.34.2)

---
updated-dependencies:
- dependency-name: eframe
  dependency-version: 0.34.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-04 18:55:49 +00:00
LGUG2Z
e2e5dbfcae feat(wm): use ghost windows for movement animations
This commit tries to render move/resize animations on a DWM-thumbnail
"ghost" window instead of calling MoveWindow per-frame on the real HWND.

The source is cloaked via IApplicationView::SetCloak, the thumbnail is
animated via DwmUpdateThumbnailProperties on a layered host owned by a
single "ghost owner" thread, the border for the source follows the
lerped rect via a new WM_ANIMATE_RECT message handled on the border's
own WndProc thread (preserving today's per-frame border tracking), and
the real SetWindowPos happens once at the end of the animation.

Apps repaint exactly once per animation instead of N times, which is a
substantial win for heavy renderers (browsers, IDEs, Office). For
non-Chromium sources the source is also pre-positioned to target_rect
before the thumbnail is registered so the captured texture is target-
sized and downscales to native 1:1 at the end of the animation rather
than upscaling to a stretched/blurry final frame.

Chromium-shell sources  skip the pre-paint step: their
NativeWindowOcclusionTrackerWin reads DWMWA_CLOAKED and treats any cloak
value as hidden, suspending the renderer; WM_SIZE while cloaked produces
no new frame and the post-uncloak swap chain shows stale or black
content.

For those apps we keep the source cloaked at start_rect for the whole
animation and do the SetWindowPos in post_render after uncloak, where
the visibility flip is what wakes Viz back up.

A short ease-in opacity crossfade in post_render masks the texture
transition for the Chromium path and gives slow renderers time to
present their first post-resize frame before the overlay is removed.
2026-05-03 16:09:28 -07:00
LGUG2Z
937b28a7d9 chore(dev): begin 0.1.42-dev 2026-05-03 16:06:46 -07:00
Csaba
24c0ce0b1d feat(wm): add global layout_defaults for per-layout default options
Adds a top-level layout_defaults setting that defines default
layout_options and layout_options_rules per layout. Workspaces without
their own layout_options or layout_options_rules automatically inherit
the global defaults. If a workspace defines either setting, all global
defaults for that layout are fully replaced.
2026-05-03 10:16:20 -07:00
Csaba
d6b17bbc7c feat(wm): add threshold-based layout_options_rules for workspaces
Adds layout_options_rules to workspace configuration, allowing
layout_options to dynamically change based on container count. Uses the
same threshold semantics as layout_rules: when container count >=
threshold, the highest matching rule fully replaces the base
layout_options.
2026-05-03 10:16:18 -07:00
LGUG2Z
e9a541d12b chore(deps): bump flavours 2026-05-01 17:10:20 -07:00
LGUG2Z
588d22a9d2 chore(cargo): address clippy warnings 2026-05-01 15:17:33 -07:00
LGUG2Z
53ad7a2224 chore(deps): cargo update 2026-05-01 14:59:04 -07:00
LGUG2Z
26b1464381 fix(borders): prevent use-after-free take 4
This commit fixes a cross-thread use-after-free crash with exception
code 0xc000041d (FATAL_USER_CALLBACK_EXCEPTION), identified via WinDbg
analysis of a minidump where rax=0xfeeefeeefeeefeee at the crash site in
d2d1!HwndPresenter::Present - the Windows heap freed-memory fill pattern
confirming Direct2D dereferenced a previously freed object.

The root cause was a data race between the border manager thread and the
border's own message loop thread. Border::create() spawns a dedicated
thread (Thread B) for the HWND message loop and sends Box<Border> back
to the border manager thread (Thread A) via a channel. After this point
both threads accessed Border::render_target concurrently without any
synchronisation:

Thread A called update_brushes() directly on the Box<Border>, replacing
render_target with a new ID2D1HwndRenderTarget and dropping the old one.
Dropping the old RenderTarget decremented the COM refcount to zero,
causing D2D to free its internal HwndPresenter.

Thread B was concurrently mid-render in a WM_PAINT or
EVENT_OBJECT_LOCATIONCHANGE handler, holding a reference to that same
old render target obtained via the GWLP_USERDATA raw pointer. Calling
EndDraw() after HwndPresenter was freed produced the crash. The process
uptime of two seconds in the dump confirmed this happened during startup
workspace initialisation, when a ForceUpdate notification triggered
update_brushes() while the newly-shown border window was processing its
first WM_PAINT.

The fix routes all brush update requests through the border's own
message loop by posting a custom WM_UPDATE_BRUSHES (WM_USER + 1) message
instead of calling update_brushes() cross-thread. The three call sites
in the border manager that previously called border.update_brushes()?
directly now call border.request_brush_update(), which posts the message
via PostMessageW. The WndProc handler for WM_UPDATE_BRUSHES calls
update_brushes() and invalidate() entirely on Thread B, eliminating the
race.

A secondary bug in destroy() was also fixed: it was clearing
GWLP_USERDATA before posting WM_CLOSE, which caused WM_DESTROY's null-
pointer guard to skip the render_target = None cleanup. This left the
ID2D1HwndRenderTarget alive past HWND destruction, and D2D freed its
HwndPresenter during WM_NCDESTROY while the COM wrapper still held a
reference - a second path to the same crash for any message queued
between WM_NCDESTROY and WM_QUIT. The premature GWLP_USERDATA clear has
been removed; WM_DESTROY already handles it correctly after releasing
the render target.
2026-03-29 19:09:13 -07:00
pro470
d7580d2271 feat(wm): add apply state socket message
This allows clients to send a state and get it applied to the wm.

By using the already there apply_state function we have all the
safety we need to apply the sent state.

This is also intentionally not open for the komorebic binary because I
think the average user doesnt need such a command and this is much safer
also to not give them the ability.
2026-03-22 20:06:43 -07:00
LGUG2Z
8a1447f543 docs(schema): update jsonschema 2026-03-22 16:11:43 -07:00
LGUG2Z
53c81c4596 fix(wm): factor in monocle mode for work area offset rules 2026-03-22 16:11:43 -07:00
omark96
d3779e5a74 feat(wm): add dynamic work area offsets
This commit adds a config setting to let you set custom
work_area_offset_rules for a workspace, following the same schema as
layout_rules.
2026-03-22 16:11:43 -07:00
1337cookie
c84fa50fc9 fear(bar): add storage_display_name option 2026-03-22 16:11:43 -07:00
bunnyDrug
41fc316a59 fix(docs): add missing word
Reading the documentation I came across what I think is a missing word
in this sentence.
2026-03-22 16:11:43 -07:00
LGUG2Z
011bcb8bd4 refactor(clippy): apply lints 2026-03-22 16:11:43 -07:00
LGUG2Z
dce3c91c22 chore(deps): various dependency bumps 2026-03-22 16:11:43 -07:00
LGUG2Z
a9a1e68169 chore(git): add procdump.exe to gitignore 2026-03-21 13:39:50 -07:00
dependabot[bot]
cb9a7542a6 chore(deps): bump actions/download-artifact from 7 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 7 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 13:10:32 -07:00
dependabot[bot]
145a0ae003 chore(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-21 13:10:22 -07:00
Melvin Tan
96e87d8ae0 fix(docs): update broken configuration schema link 2026-03-21 13:09:43 -07:00
LGUG2Z
529d93595e chore(deps): cargo update 2026-03-21 12:38:45 -07:00
Csaba
ea35d818a1 feat(bar): windows systray widget
A new System Tray widget has been added to komorebi-bar, bringing native
Windows system tray functionality directly into the bar.

Special thanks to the Zebar project and its contributors for developing
the systray-util crate library that makes Windows system tray
integration possible.

The widget intercepts system tray icon data by creating a hidden "spy"
window that mimics the Windows taskbar. When applications use the
Shell_NotifyIcon API to manage their tray icons, the widget receives the
same broadcast messages, allowing it to monitor all system tray activity
while forwarding messages to the real taskbar to avoid disruption.

Users can configure which icons to hide using flexible rules. A plain
string matches by exe name (case-insensitive). A structured object can
match on exe, tooltip, and/or GUID fields using AND logic. Each field
supports matching strategies from komorebi's window rules (Equals,
StartsWith, EndsWith, Contains, Regex, and their negated variants),
allowing precise filtering even when multiple icons share the same exe
and GUID. An info button opens a floating panel listing all icons with
their properties and copy buttons, making it easy to identify which
values to use in filter rules.

The widget fully supports mouse interactions including left-click,
right-click, middle-click, and double-click actions on tray icons.
Double-click support uses the LeftDoubleClick action from systray-util
0.2.0, which sends WM_LBUTTONDBLCLK and NIN_SELECT messages. It handles
right-aligned placement correctly by adjusting the rendering order and
toggle button arrow directions to maintain consistent visual appearance
regardless of which panel the widget is placed in.

Some system tray icons register a click callback but never actually
respond to click messages, effectively becoming "zombie" icons from an
interaction standpoint. The widget includes fallback commands for known
problematic icons that override the native click action with a direct
shell command (e.g. opening Windows Security or volume settings).

The implementation uses a background thread with its own tokio runtime
to handle the async systray events, communicating with the UI thread
through crossbeam channels. Icon images are cached efficiently using
hash-based keys that update when icons change. Rapid icon updates are
deduplicated to prevent UI freezing, and image conversion (RgbaImage to
ColorImage) is performed on the background thread to keep the UI
responsive.

The widget automatically detects and removes stale icons whose owning
process has exited, using the Win32 IsWindow API on a configurable
interval. A manual refresh button is also available for immediate
cleanup.

A shortcuts button can be configured to toggle komorebi-shortcuts by
killing the process if running or starting it otherwise. The refresh,
info, and shortcuts buttons can each be placed in the main visible area
or in the overflow section.
2026-03-21 12:35:39 -07:00
LGUG2Z
5f629e1f1a feat(wm): cycle monocle container when focusing in direction
This commit implements something I've found myself wanting while working
on the Macbook.

When I have a monocle container active, I want to quickly be able to
switch back and forth with an adjacent window in the underlying layout
without having to toggle monocle mode off and back on again.

This is now accomplished by using focus left/down to promote the
previous window in the Ring to monocle, and by using focus right/down to
focus the next window in the Ring to monocle.

Borders were being funny so I just ended up nuking them whenever we
cycle.
2026-03-08 19:49:50 -07:00
LGUG2Z
0f1854db8b ci(github): re-enable sunday cron jobs 2026-03-01 21:33:51 -08:00
Csaba
8889c3ca93 fix(wm): correct layout rounding for uneven integer division
The grid layout calculates row heights using integer division, which
truncates the result when the area height is not evenly divisible by the
number of rows in a column. Since every row received this truncated
height, columns with an odd row count (e.g. 3 rows in 800px: 266*3=798)
would fall short of the full area height, leaving a visible gap at the
bottom compared to columns with an even row count (e.g. 2 rows:
400*2=800).

The last row in each column now absorbs the remainder pixels from the
integer division. The vertical flip position calculation was also
updated so that the last row, which becomes the topmost window when
flipped, starts at the area top edge instead of being offset by the
truncation error.

The same integer division truncation also affected column widths in the
grid layout when the area width is not evenly divisible by the number of
columns. The last column now absorbs the width remainder using the same
pattern, and this correction is applied before the flipped column
positions are calculated so that horizontal flips also tile correctly.

The two shared helper functions columns_with_ratios and rows_with_ratios
had the same issue, which propagated to every layout that delegates to
them: Columns, Rows, VerticalStack stack rows, RightMainVerticalStack
stack rows, HorizontalStack stack columns, and UltrawideVerticalStack
tertiary rows. Both functions now correct the last element after the
sizing loop so that the total tiled dimension matches the area exactly.

The Scrolling layout computes a uniform column width by dividing the
area width by the visible column count, which can also leave a remainder
gap on the right edge. The last visible column in the current viewport
now absorbs this remainder. Since the layout is recalculated on every
scroll event, the correction stays accurate regardless of which column
is rightmost.
2026-03-01 21:31:36 -08:00
LGUG2Z
6ca49d4301 chore(deps): cargo update 2026-02-20 16:58:40 -08:00
Csaba
634a3e7f3b fix(wm): correct bsp/grid container positioning when flipped
The recursive_fibonacci function used formulas for main_x and main_y
that mixed unresized area dimensions with resized dimensions when
calculating flipped positions.

This caused a gap between containers proportional to resize_delta * (2 *
ratio - 1), meaning any column_ratios or row_ratios value other than the
default 0.5 would produce visible gaps or overlaps between containers
when the layout was both flipped and resized.

The fix replaces the flipped position formulas with ones that derive
main_x and main_y directly from the alt area width and height
expressions used by the recursive child call, ensuring the main
container is always positioned immediately adjacent to the alt area
regardless of ratio or resize delta.

The horizontal flip for grid layouts swapped column left positions
directly from the unflipped layout. With non-default column ratios this
caused narrow columns to receive wide column positions, producing
overlapping containers.

Precompute flipped column left positions by laying out the original
column widths in reverse order from the area left edge, ensuring
containers tile without overlap regardless of column ratio.

Separated all unit test for arrangements into a new file.

Added 4 BSP-specific regression tests (horizontal flip, vertical flip,
both axes, sweep across multiple ratios/deltas).

Added 7 adjacency tests covering ALL other layouts (Columns, Rows,
VerticalStack, RightMainVerticalStack, HorizontalStack,
UltrawideVerticalStack, Scrolling) confirming they don't have the gap
issue.

Added 2 Grid tests that verify containers don't overlap and tile the
full area when column ratios are non-default. The first test checks a
horizontal flip specifically, confirming 2 columns form with no gaps
edge-to-edge. The second test runs all three flip axes and asserts no
overlaps and full area coverage for each.
2026-02-20 16:57:21 -08:00
LGUG2Z
5b6fab0044 chore(dev): begin v0.1.41-dev 2026-02-20 16:56:51 -08:00
LGUG2Z
bed314b866 chore(release): v0.1.40 2026-02-14 12:52:25 -08:00
Csaba
c165172b5a feat(cli): new layout-ratios command
The implementation adds a new layout-ratio CLI command to komorebi that
allows users to dynamically set column and row ratios for layouts at
runtime via komorebic layout-ratio --columns 0.3 0.4 --rows 0.5.

A public validate_ratios function was added to komorebi-layouts that
clamps ratio values between 0.1 and 0.9, truncates when the cumulative
sum would reach or exceed 1.0, and limits to a maximum of 5 ratios. This
function is shared between config file deserialization and the new CLI
command, ensuring consistent validation behavior.

The SocketMessage enum in komorebi/src/core/mod.rs has a new
LayoutRatios variant. The handler in process_command.rs uses the shared
validate_ratios function to process the ratios before applying them to
the focused workspace's layout options.

When the CLI command is called without any arguments, it prints a
helpful message instead of returning an error.
2026-02-13 11:24:57 -08:00
Csaba
9977cca500 feat(bar): enhancing the media widget
The Media widget has been enhanced with a new MediaDisplayFormat
configuration option that controls how the widget is displayed. It
supports seven formats: Icon, Text, IconAndText (the default),
ControlsOnly, IconAndControls, TextAndControls, and Full.

The widget now detects whether the Previous and Next buttons are
actually available for the current media session using the Windows Media
Control API. When a button is not available, it appears dimmed at 50
percent opacity and clicking it has no effect.

Tooltips were added to improve usability. Hovering over the media info
label shows the full artist and title text, which is helpful when the
text is truncated. The Play/Pause button also shows the media info on
hover.

The rendering logic was refactored to properly handle right-aligned
widgets.

When the Media widget is placed in right_widgets, the UI renders items
from right to left, so the code now renders elements in reverse order to
ensure the visual appearance remains consistent regardless of which
panel the widget is placed in.
2026-02-12 20:39:16 -08:00
LGUG2Z
5d7a0ea9ad chore(deps): cargo update 2026-02-12 20:35:29 -08:00
dependabot[bot]
0e79c58be3 chore(deps): bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

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

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

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

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

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

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

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

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

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

Ratio support varies by layout:

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

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

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

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

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

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

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

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

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

This is a lock-free solution with zero overhead on the hot rendering
path (atomic loads are nearly free) and provides defense-in-depth with
multiple barriers against the use-after-free condition.
2026-02-06 16:57:06 -08:00
LGUG2Z
9d16197825 chore(deps): cargo update 2026-02-06 16:57:06 -08:00
LGUG2Z
fed09689b8 chore(deps): cargo update 2026-02-04 08:43:43 -08:00
LGUG2Z
c0b298c9de docs(readme): add link to komorebi for mac repo 2026-01-30 20:18:57 -08:00
LGUG2Z
5fd7017b71 fix(wm): prevent dupes when enforcing ws rules
This commit ensures that we check if a window is already managed in any
workspaces even after checking known_hwnds, because windows moved as
part of the earlier ensure_workspace_rules call can slip through the
cracks.
2026-01-30 20:18:57 -08:00
Rejdukien
dbde351e22 fix(border): release resources before destroying window to prevent access violations
When a border is destroyed, the main thread was forcefully releasing D2D
resources, while the border window thread might still be trying to use
them in its message loop.

Releasing the RenderTarget on one thread while another is calling
EndDraw on it leads to a use-after-free scenario or invalid state for
the COM object, resulting in the crash.

This commit applies a fix that moves the resource cleanup logic from the
main thread to the window thread. Now, resources are only released
during the WM_DESTROY message processing, which guarantees
synchronization with other window messages like WM_PAINT.
2026-01-30 20:18:43 -08:00
68 changed files with 10964 additions and 1856 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.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@v5
- uses: actions/upload-artifact@v7
with:
name: komorebi-${{ matrix.platform.target }}-${{ github.sha }}
path: |
@@ -87,7 +87,7 @@ jobs:
fetch-depth: 0
- shell: bash
run: echo "VERSION=nightly" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v8
- run: |
Compress-Archive -Force ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komorebi-$Env:VERSION-x86_64-pc-windows-msvc.zip
Copy-Item ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komorebi-$Env:VERSION-x86_64.msi
@@ -136,7 +136,7 @@ jobs:
run: |
TAG=${{ github.event.release.tag_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v8
- run: |
Compress-Archive -Force ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/x86_64-pc-windows-msvc/release/*.exe komorebi-$Env:VERSION-x86_64-pc-windows-msvc.zip
Copy-Item ./komorebi-x86_64-pc-windows-msvc-${{ github.sha }}/wix/*x86_64.msi -Destination ./komorebi-$Env:VERSION-x86_64.msi
@@ -178,7 +178,7 @@ jobs:
run: |
TAG=${{ github.ref_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v6
- uses: actions/download-artifact@v8
- 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,3 +12,4 @@ komorebic/applications.json
/.xwin-cache
result
/.direnv
procdump.exe

2684
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@ members = [
"komorebi",
"komorebi-client",
"komorebi-gui",
"komorebi-layouts",
"komorebic",
"komorebic-no-console",
"komorebi-bar",
@@ -19,7 +20,7 @@ chrono = "0.4"
crossbeam-channel = "0.5"
crossbeam-utils = "0.8"
color-eyre = "0.6"
eframe = "0.33"
eframe = "0.34"
egui_extras = "0.33"
dirs = "6"
dunce = "1"
@@ -29,13 +30,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.27", features = ["derive"] }
strum = { version = "0.28", 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.37"
sysinfo = "0.38"
uds_windows = "1"
win32-display-data = { git = "https://github.com/LGUG2Z/win32-display-data", rev = "8c42d8db257d30fe95bc98c2e5cd8f75da861021" }
windows-numerics = { version = "0.3" }

View File

@@ -57,17 +57,7 @@ If you need help doing this you can ask on Discord.
## Note: 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.
komorebi for Mac lives [here](https://github.com/LGUG2Z/komorebi-for-mac) :)
## Overview
@@ -89,7 +79,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.lgug2z.com/schema) and a
[configuration schema reference](https://komorebi-starlight.lgug2z.workers.dev/reference/komorebi-windows/) and a
complete [CLI reference](https://lgug2z.github.io/komorebi/cli/quickstart.html).
## Community
@@ -434,7 +424,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", tag = "v0.1.39"}
// komorebi-client = { git = "https://github.com/LGUG2Z/komorebi" }
use anyhow::Result;
use komorebi_client::Notification;

View File

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

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -0,0 +1,218 @@
# 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

@@ -0,0 +1,40 @@
# 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

@@ -0,0 +1,337 @@
# 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 it sucks, but this is something
I know it's buggy, and I know that most of the time 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.40/schema.bar.json",
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.41/schema.bar.json",
"font_family": "JetBrains Mono",
"theme": {
"palette": "Base16",
@@ -31,22 +31,22 @@
},
{
"Media": {
"enable": true
"enable": false
}
},
{
"Storage": {
"enable": true
"enable": false
}
},
{
"Memory": {
"enable": true
"enable": false
}
},
{
"Network": {
"enable": true,
"enable": false,
"show_activity": true,
"show_total_activity": true
}

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.40/schema.json",
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.41/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.40"
version = "0.1.42"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -18,15 +18,17 @@ 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.40"
netdev = "0.41"
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 }
@@ -34,6 +36,8 @@ 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,6 +18,7 @@ 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;
@@ -1082,6 +1083,10 @@ 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))
@@ -1182,9 +1187,9 @@ impl eframe::App for Komobar {
&None
};
if let Some(command) = command {
command.execute(self.mouse_follows_focus);
}
// Store the command to execute after widgets are rendered
// This allows widgets to mark clicks as consumed
pending_command = command.clone();
}
// Apply grouping logic for the bar as a whole
@@ -1316,6 +1321,13 @@ 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,7 +15,7 @@ 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.40`
/// The `komorebi.bar.json` configuration file reference for `v0.1.42`
pub struct KomobarConfig {
/// Bar height
#[cfg_attr(feature = "schemars", schemars(extend("default" = 50)))]
@@ -621,6 +621,26 @@ 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,6 +38,8 @@ 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);
@@ -46,6 +48,20 @@ 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,4 +1,6 @@
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;
@@ -14,6 +16,7 @@ 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))]
@@ -21,24 +24,31 @@ use windows::Media::Control::GlobalSystemMediaTransportControlsSessionManager;
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)
Self::new(
value.enable,
value.display.unwrap_or(MediaDisplayFormat::IconAndText),
)
}
}
#[derive(Clone, Debug)]
pub struct Media {
pub enable: bool,
pub display: MediaDisplayFormat,
pub session_manager: GlobalSystemMediaTransportControlsSessionManager,
}
impl Media {
pub fn new(enable: bool) -> Self {
pub fn new(enable: bool, display: MediaDisplayFormat) -> Self {
Self {
enable,
display,
session_manager: GlobalSystemMediaTransportControlsSessionManager::RequestAsync()
.unwrap()
.join()
@@ -54,6 +64,58 @@ 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()
@@ -78,28 +140,96 @@ 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();
if !output.is_empty() {
let mut layout_job = LayoutJob::simple(
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(
egui_phosphor::regular::HEADPHONES.to_string(),
config.icon_font_id.clone(),
ctx.style().visuals.selection.stroke.color,
icon_font_id.clone(),
icon_color,
100.0,
);
}
if show_text {
layout_job.append(
&output,
10.0,
if show_icon { 10.0 } else { 0.0 },
TextFormat {
font_id: config.text_font_id.clone(),
color: ctx.style().visuals.text_color(),
font_id: text_font_id,
color: text_color,
valign: Align::Center,
..Default::default()
},
);
}
config.apply_on_widget(false, ui, |ui| {
if SelectableFrame::new(false)
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)
.show(ui, |ui| {
let available_height = ui.available_height();
let mut custom_ui = CustomUi(ui);
@@ -109,15 +239,95 @@ impl BarWidget for Media {
MAX_LABEL_WIDTH.load(Ordering::SeqCst) as f32,
available_height,
),
Label::new(layout_job).selectable(false).truncate(),
Label::new(layout_job.clone()).selectable(false).truncate(),
)
})
.on_hover_text(&output)
.clicked()
{
self.toggle();
{
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);
}
});
}
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,6 +20,8 @@ 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;
@@ -92,10 +94,16 @@ 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]
fn rgba_to_color_image(rgba_image: &RgbaImage) -> ColorImage {
pub(crate) 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())
@@ -156,6 +164,8 @@ 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,6 +33,8 @@ 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]]
@@ -48,6 +50,9 @@ 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(),
@@ -55,6 +60,19 @@ 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,
@@ -67,6 +85,7 @@ 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,
@@ -90,6 +109,17 @@ 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;
@@ -103,7 +133,7 @@ impl Storage {
disks.push(StorageDisk {
label: match self.label_prefix {
LabelPrefix::Text | LabelPrefix::IconAndText => {
format!("{} {}%", mount.to_string_lossy(), percentage)
format!("{} {}%", display_name, percentage)
}
LabelPrefix::None | LabelPrefix::Icon => format!("{percentage}%"),
},

File diff suppressed because it is too large Load Diff

View File

@@ -19,6 +19,10 @@ 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;
@@ -66,6 +70,10 @@ 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),
@@ -87,6 +95,8 @@ 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)),
}
@@ -112,6 +122,8 @@ 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.40"
version = "0.1.42"
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.40"
version = "0.1.42"
edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

View File

@@ -0,0 +1,26 @@
[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"]

View File

@@ -6,13 +6,22 @@ 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)]
@@ -42,10 +51,23 @@ 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);
@@ -54,6 +76,7 @@ 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())
@@ -118,6 +141,15 @@ 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()
@@ -131,15 +163,30 @@ impl Arrangement for DefaultLayout {
layouts
}
Self::BSP => recursive_fibonacci(
0,
len,
area,
layout_flip,
calculate_resize_adjustments(resize_dimensions),
),
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::Columns => {
let mut layouts = columns(area, len);
let ratios = layout_options.and_then(|o| o.column_ratios);
let mut layouts = columns_with_ratios(area, len, ratios);
let adjustment = calculate_columns_adjustment(resize_dimensions);
layouts
@@ -163,7 +210,8 @@ impl Arrangement for DefaultLayout {
layouts
}
Self::Rows => {
let mut layouts = rows(area, len);
let ratios = layout_options.and_then(|o| o.row_ratios);
let mut layouts = rows_with_ratios(area, len, ratios);
let adjustment = calculate_rows_adjustment(resize_dimensions);
layouts
@@ -189,9 +237,17 @@ 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,
_ => area.right / 2,
_ => {
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
}
};
let main_left = area.left;
@@ -206,7 +262,8 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
layouts.append(&mut rows(
let row_ratios = layout_options.and_then(|o| o.row_ratios);
layouts.append(&mut rows_with_ratios(
&Rect {
left: stack_left,
top: area.top,
@@ -214,6 +271,7 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom,
},
len - 1,
row_ratios,
));
}
}
@@ -257,9 +315,17 @@ 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,
_ => area.right / 2,
_ => {
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
}
};
let primary_left = match len {
@@ -276,7 +342,8 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
layouts.append(&mut rows(
let row_ratios = layout_options.and_then(|o| o.row_ratios);
layouts.append(&mut rows_with_ratios(
&Rect {
left: area.left,
top: area.top,
@@ -284,6 +351,7 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom,
},
len - 1,
row_ratios,
));
}
}
@@ -326,9 +394,17 @@ impl Arrangement for DefaultLayout {
Self::HorizontalStack => {
let mut layouts: Vec<Rect> = vec![];
#[allow(clippy::cast_possible_truncation)]
let bottom = match len {
1 => area.bottom,
_ => area.bottom / 2,
_ => {
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
}
};
let main_top = area.top;
@@ -343,7 +419,8 @@ impl Arrangement for DefaultLayout {
});
if len > 1 {
layouts.append(&mut columns(
let col_ratios = layout_options.and_then(|o| o.column_ratios);
layouts.append(&mut columns_with_ratios(
&Rect {
left: area.left,
top: stack_top,
@@ -351,6 +428,7 @@ impl Arrangement for DefaultLayout {
bottom: area.bottom - bottom,
},
len - 1,
col_ratios,
));
}
}
@@ -393,15 +471,28 @@ 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 / 2,
_ => (area.right as f32 * primary_ratio) as i32,
};
#[allow(clippy::cast_possible_truncation)]
let secondary_right = match len {
1 => 0,
2 => area.right - primary_right,
_ => (area.right - primary_right) / 2,
_ => (area.right as f32 * secondary_ratio) as i32,
};
let (primary_left, secondary_left, stack_left) = match len {
@@ -438,14 +529,18 @@ impl Arrangement for DefaultLayout {
});
if len > 2 {
layouts.append(&mut rows(
// 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(
&Rect {
left: stack_left,
top: area.top,
right: secondary_right,
right: tertiary_right,
bottom: area.bottom,
},
len - 2,
row_ratios,
));
}
}
@@ -514,13 +609,94 @@ impl Arrangement for DefaultLayout {
let len = len as i32;
let row_constraint = layout_options.and_then(|o| o.grid.map(|g| g.rows));
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 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 {
@@ -534,26 +710,47 @@ impl Arrangement for DefaultLayout {
remaining_windows / remaining_columns
};
let win_height = area.bottom / num_rows_in_this_col;
let win_width = area.right / num_cols;
// 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];
for row in 0..num_rows_in_this_col {
if let Some((_idx, win)) = iter.next() {
let mut left = area.left + win_width * col;
let mut top = area.top + win_height * row;
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;
match layout_flip {
Some(Axis::Horizontal) => {
left = area.right - win_width * (col + 1) + area.left;
left = flipped_col_lefts[col_idx];
}
Some(Axis::Vertical) => {
top = area.bottom - win_height * (row + 1) + area.top;
top = if is_last_row {
area.top
} else {
area.top + area.bottom - base_height * (row + 1)
};
}
Some(Axis::HorizontalAndVertical) => {
left = area.right - win_width * (col + 1) + area.left;
top = area.bottom - win_height * (row + 1) + area.top;
left = flipped_col_lefts[col_idx];
top = if is_last_row {
area.top
} else {
area.top + area.bottom - base_height * (row + 1)
};
}
None => {} // No flip
None => {}
}
win.bottom = win_height;
@@ -576,6 +773,7 @@ impl Arrangement for DefaultLayout {
}
}
#[cfg(feature = "win32")]
impl Arrangement for CustomLayout {
fn calculate(
&self,
@@ -714,14 +912,68 @@ pub enum Axis {
HorizontalAndVertical,
}
#[cfg(feature = "win32")]
#[must_use]
fn columns(area: &Rect, len: usize) -> Vec<Rect> {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let right = area.right / len as i32;
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![];
let mut left = 0;
let mut layouts: Vec<Rect> = vec![];
for _ in 0..len {
// 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
}
};
layouts.push(Rect {
left: area.left + left,
top: area.top,
@@ -732,17 +984,77 @@ fn columns(area: &Rect, len: usize) -> Vec<Rect> {
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> {
#[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)]
let bottom = area.bottom / len as i32;
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![];
let mut top = 0;
let mut layouts: Vec<Rect> = vec![];
for _ in 0..len {
// 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
}
};
layouts.push(Rect {
left: area.left,
top: area.top + top,
@@ -753,6 +1065,16 @@ fn rows(area: &Rect, len: usize) -> Vec<Rect> {
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
}
@@ -862,6 +1184,8 @@ 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;
@@ -875,41 +1199,41 @@ fn recursive_fibonacci(
*area
};
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;
#[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 (main_x, alt_x, alt_y, main_y);
if let Some(flip) = layout_flip {
match flip {
Axis::Horizontal => {
main_x = resized.left + half_width + (half_width - half_resized_width);
main_x = resized.left + (area.right - primary_resized_width);
alt_x = resized.left;
alt_y = resized.top + half_resized_height;
alt_y = resized.top + primary_resized_height;
main_y = resized.top;
}
Axis::Vertical => {
main_y = resized.top + half_height + (half_height - half_resized_height);
main_y = resized.top + (area.bottom - primary_resized_height);
alt_y = resized.top;
main_x = resized.left;
alt_x = resized.left + half_resized_width;
alt_x = resized.left + primary_resized_width;
}
Axis::HorizontalAndVertical => {
main_x = resized.left + half_width + (half_width - half_resized_width);
main_x = resized.left + (area.right - primary_resized_width);
alt_x = resized.left;
main_y = resized.top + half_height + (half_height - half_resized_height);
main_y = resized.top + (area.bottom - primary_resized_height);
alt_y = resized.top;
}
}
} else {
main_x = resized.left;
alt_x = resized.left + half_resized_width;
alt_x = resized.left + primary_resized_width;
main_y = resized.top;
alt_y = resized.top + half_resized_height;
alt_y = resized.top + primary_resized_height;
}
#[allow(clippy::if_not_else)]
@@ -927,7 +1251,7 @@ fn recursive_fibonacci(
left: resized.left,
top: main_y,
right: resized.right,
bottom: half_resized_height,
bottom: primary_resized_height,
}];
res.append(&mut recursive_fibonacci(
idx + 1,
@@ -936,17 +1260,19 @@ fn recursive_fibonacci(
left: area.left,
top: alt_y,
right: area.right,
bottom: area.bottom - half_resized_height,
bottom: area.bottom - primary_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: half_resized_width,
right: primary_resized_width,
bottom: resized.bottom,
}];
res.append(&mut recursive_fibonacci(
@@ -955,11 +1281,13 @@ fn recursive_fibonacci(
&Rect {
left: alt_x,
top: area.top,
right: area.right - half_resized_width,
right: area.right - primary_resized_width,
bottom: area.bottom,
},
layout_flip,
resize_adjustments,
column_split_ratio,
row_split_ratio,
));
res
}
@@ -1267,3 +1595,7 @@ 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;

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use clap::ValueEnum;
use serde::Deserialize;
use serde::Serialize;
@@ -8,8 +10,53 @@ 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;
}
}
arr
}
#[derive(
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Display, EnumString, ValueEnum,
Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq, Hash, Display, EnumString, ValueEnum,
)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// A predefined komorebi layout
@@ -112,7 +159,43 @@ pub enum DefaultLayout {
// NOTE: If any new layout is added, please make sure to register the same in `DefaultLayout::cycle`
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
/// 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)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Options for specific layouts
pub struct LayoutOptions {
@@ -120,6 +203,35 @@ 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)]
@@ -140,6 +252,21 @@ 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 {
@@ -308,3 +435,7 @@ impl DefaultLayout {
}
}
}
#[cfg(test)]
#[path = "default_layout_tests.rs"]
mod tests;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
[package]
name = "komorebi-themes"
version = "0.1.40"
version = "0.1.42"
edition = "2024"
[dependencies]
base16-egui-themes = { git = "https://github.com/LGUG2Z/base16-egui-themes", rev = "b9e26b31f7a0e7ed239b14e5317e95d1bdc544bd" }
base16-egui-themes = { git = "https://github.com/LGUG2Z/base16-egui-themes", rev = "3f157904c641f0dc80f043449fe0214fc4182425" }
#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", version = "0.7.2" }
flavours = { git = "https://github.com/LGUG2Z/flavours", rev = "24518c129918fe3260aa559eded7657e50752cb1" }
[features]
default = ["schemars"]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi"
version = "0.1.40"
version = "0.1.42"
description = "A tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"
@@ -8,6 +8,7 @@ edition = "2024"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-layouts = { path = "../komorebi-layouts", features = ["win32"] }
komorebi-themes = { path = "../komorebi-themes" }
base64 = "0.22"
@@ -50,7 +51,7 @@ windows-numerics = { workspace = true }
windows-implement = { workspace = true }
windows-interface = { workspace = true }
winput = "0.2"
winreg = "0.55"
winreg = "0.56"
serde_with = { version = "3.12", features = ["schemars_1"] }
[build-dependencies]
@@ -63,4 +64,4 @@ uuid = { version = "1", features = ["v4"] }
[features]
default = ["schemars"]
deadlock_detection = ["parking_lot/deadlock_detection"]
schemars = ["dep:schemars"]
schemars = ["dep:schemars", "komorebi-layouts/schemars"]

View File

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

View File

@@ -0,0 +1,363 @@
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,6 +13,7 @@ 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;
@@ -59,6 +60,7 @@ 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>> =
@@ -78,3 +80,4 @@ 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,4 +5,10 @@ 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,7 +11,9 @@ use crate::core::Rect;
use crate::windows_api;
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::mpsc;
use windows::Win32::Foundation::FALSE;
@@ -56,6 +58,7 @@ 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;
@@ -65,11 +68,21 @@ 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 {}
@@ -98,6 +111,98 @@ 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;
@@ -126,6 +231,7 @@ pub struct Border {
pub brush_properties: D2D1_BRUSH_PROPERTIES,
pub rounded_rect: D2D1_ROUNDED_RECT,
pub brushes: HashMap<WindowKind, ID2D1SolidColorBrush>,
pub is_destroying: Arc<AtomicBool>,
}
impl From<isize> for Border {
@@ -144,6 +250,7 @@ impl From<isize> for Border {
brush_properties: D2D1_BRUSH_PROPERTIES::default(),
rounded_rect: D2D1_ROUNDED_RECT::default(),
brushes: HashMap::new(),
is_destroying: Arc::new(AtomicBool::new(false)),
}
}
}
@@ -192,6 +299,7 @@ impl Border {
brush_properties: Default::default(),
rounded_rect: Default::default(),
brushes: HashMap::new(),
is_destroying: Arc::new(AtomicBool::new(false)),
};
let border_pointer = &raw mut border;
@@ -313,14 +421,54 @@ impl Border {
}
pub fn destroy(&self) -> color_eyre::Result<()> {
// clear user data **BEFORE** closing window
// pending messages will see a null pointer and exit early
unsafe {
SetWindowLongPtrW(self.hwnd(), GWLP_USERDATA, 0);
}
// 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));
}
}
}
pub fn set_position(&self, rect: &Rect, reference_hwnd: isize) -> color_eyre::Result<()> {
let mut rect = *rect;
rect.add_margin(self.width);
@@ -386,77 +534,29 @@ 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 _;
(*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_null() {
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);
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
apply_tracked_rect(border_pointer, *rect_box);
LRESULT(0)
}
WM_PAINT => {
@@ -468,6 +568,10 @@ impl Border {
return LRESULT(0);
}
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
let reference_hwnd = (*border_pointer).tracking_hwnd;
// Update position to update the ZOrder
@@ -481,6 +585,11 @@ impl Border {
}
if let Some(render_target) = (*border_pointer).render_target.as_ref() {
// double-check destruction flag before rendering
if (*border_pointer).is_destroying.load(Ordering::Acquire) {
return LRESULT(0);
}
(*border_pointer).width = BORDER_WIDTH.load(Ordering::Relaxed);
(*border_pointer).offset = BORDER_OFFSET.load(Ordering::Relaxed);
@@ -547,8 +656,27 @@ 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 => {
SetWindowLongPtrW(window, GWLP_USERDATA, 0);
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);
}
PostQuitMessage(0);
LRESULT(0)
}

View File

@@ -113,6 +113,20 @@ 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")
@@ -451,8 +465,11 @@ 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)
border.update_brushes()?;
// 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();
}
border.invalidate();
@@ -616,8 +633,11 @@ 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)
border.update_brushes()?;
// 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();
}
border.set_position(&rect, focused_window_hwnd)?;
border.invalidate();
@@ -699,8 +719,11 @@ 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)
border.update_brushes()?;
// 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();
}
border.set_position(&rect, window.hwnd)?;
border.invalidate();
@@ -767,12 +790,6 @@ 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 Bézier function
/// Custom Cubic Bezier function
CubicBezier(f64, f64, f64, f64),
}

View File

@@ -15,37 +15,45 @@ 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]
@@ -113,6 +121,7 @@ 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),
@@ -249,6 +258,8 @@ pub enum SocketMessage {
StaticConfigSchema,
GenerateStaticConfig,
DebugWindow(isize),
// low level commands
ApplyState(State),
}
impl SocketMessage {
@@ -545,32 +556,6 @@ pub enum OperationBehaviour {
NoOp,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Display, EnumString, ValueEnum)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
/// Sizing
pub enum Sizing {
/// Increase
Increase,
/// Decrease
Decrease,
}
impl Sizing {
#[must_use]
pub const fn adjust_by(&self, value: i32, adjustment: i32) -> i32 {
match self {
Self::Increase => value + adjustment,
Self::Decrease => {
if value > 0 && value - adjustment >= 0 {
value - adjustment
} else {
value
}
}
}
}
}
#[derive(
Clone, Copy, Debug, Default, Serialize, Deserialize, Display, EnumString, ValueEnum, PartialEq,
)]

View File

@@ -238,6 +238,9 @@ 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_PADDING: AtomicI32 = AtomicI32::new(10);
@@ -322,7 +325,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
current.map(|current| current.to_vec())
}
#[derive(Clone, Debug, Serialize, Deserialize)]

View File

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

View File

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

View File

@@ -60,9 +60,11 @@ use crate::core::Axis;
use crate::core::BorderImplementation;
use crate::core::FocusFollowsMouseImplementation;
use crate::core::Layout;
use crate::core::LayoutOptions;
use crate::core::MoveBehaviour;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::core::ScrollingLayoutOptions;
use crate::core::Sizing;
use crate::core::SocketMessage;
use crate::core::StateQuery;
@@ -72,8 +74,6 @@ use crate::core::config_generation::IdWithIdentifier;
use crate::core::config_generation::MatchingRule;
use crate::core::config_generation::MatchingStrategy;
use crate::current_virtual_desktop;
use crate::default_layout::LayoutOptions;
use crate::default_layout::ScrollingLayoutOptions;
use crate::monitor::MonitorInformation;
use crate::notify_subscribers;
use crate::stackbar_manager;
@@ -947,6 +947,8 @@ impl WindowManager {
center_focused_column: Default::default(),
}),
grid: None,
column_ratios: None,
row_ratios: None,
},
};
@@ -955,6 +957,29 @@ 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)?;
}
@@ -2271,6 +2296,9 @@ 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,18 +266,33 @@ impl WindowManager {
}
}
WindowManagerEvent::Minimize(_, window) => {
let mut hide = false;
// During transient display connection changes (e.g. monitor
// briefly disconnecting and reconnecting), Windows may fire
// SystemMinimizeStart for windows on the affected monitor.
// We must not treat these OS-initiated minimizes as user
// actions, otherwise the window gets removed from the
// workspace and the reconciliator cannot restore it.
if crate::monitor_reconciliator::display_change_in_progress(
std::time::Duration::from_secs(10),
) {
tracing::debug!(
"ignoring minimize during display connection change for hwnd: {}",
window.hwnd
);
} else {
let mut hide = false;
{
let programmatically_hidden_hwnds = HIDDEN_HWNDS.lock();
if !programmatically_hidden_hwnds.contains(&window.hwnd) {
hide = true;
{
let programmatically_hidden_hwnds = HIDDEN_HWNDS.lock();
if !programmatically_hidden_hwnds.contains(&window.hwnd) {
hide = true;
}
}
}
if hide {
self.focused_workspace_mut()?.remove_window(window.hwnd)?;
self.update_focused_workspace(false, false)?;
if hide {
self.focused_workspace_mut()?.remove_window(window.hwnd)?;
self.update_focused_workspace(false, false)?;
}
}
}
WindowManagerEvent::Hide(_, window) => {
@@ -431,6 +446,24 @@ 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,12 +28,10 @@ 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,6 +253,9 @@ 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

@@ -13,6 +13,7 @@ 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;
@@ -38,6 +39,8 @@ 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;
@@ -53,6 +56,8 @@ 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;
@@ -67,7 +72,6 @@ use crate::core::config_generation::ApplicationOptions;
use crate::core::config_generation::MatchingRule;
use crate::core::config_generation::MatchingStrategy;
use crate::current_virtual_desktop;
use crate::default_layout::LayoutOptions;
use crate::monitor;
use crate::monitor::Monitor;
use crate::monitor_reconciliator;
@@ -215,6 +219,12 @@ 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")]
@@ -223,6 +233,9 @@ 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")]
@@ -287,6 +300,13 @@ 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);
@@ -325,7 +345,18 @@ impl From<&Workspace> for WorkspaceConfig {
Layout::Custom(_) => None,
})
.flatten(),
layout_options: value.layout_options,
layout_options: {
tracing::debug!(
"Parsing workspace config - layout_options: {:?}",
value.layout_options
);
value.layout_options
},
layout_options_rules: if value.layout_options_rules.is_empty() {
None
} else {
Some(value.layout_options_rules.iter().copied().collect())
},
#[allow(deprecated)]
custom_layout: value
.workspace_config
@@ -347,6 +378,7 @@ 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,
@@ -445,7 +477,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.40`
/// The `komorebi.json` static configuration file reference for `v0.1.42`
pub struct StaticConfig {
/// DEPRECATED from v0.1.22: no longer required
#[deprecated(note = "No longer required")]
@@ -565,6 +597,11 @@ 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>>,
@@ -660,6 +697,11 @@ 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;
@@ -874,6 +916,14 @@ 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,
@@ -979,6 +1029,17 @@ 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 {
@@ -989,6 +1050,12 @@ impl StaticConfig {
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);
}
@@ -1397,7 +1464,7 @@ impl StaticConfig {
workspace_config.layout = Some(DefaultLayout::Columns);
}
ws.load_static_config(workspace_config)?;
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
}
}
@@ -1480,7 +1547,10 @@ 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)?;
ws.load_static_config(
workspace_config,
value.layout_defaults.as_ref(),
)?;
}
}
@@ -1562,7 +1632,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)?;
ws.load_static_config(workspace_config, value.layout_defaults.as_ref())?;
}
}
@@ -1645,7 +1715,10 @@ 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)?;
ws.load_static_config(
workspace_config,
value.layout_defaults.as_ref(),
)?;
}
}
@@ -1913,7 +1986,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.36", "0.1.37", "0.1.38", "0.1.39",
];
let mut versions = vec![];

View File

@@ -20,7 +20,9 @@ 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;
@@ -42,6 +44,7 @@ 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;
@@ -52,6 +55,7 @@ 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;
@@ -165,6 +169,18 @@ 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 {
@@ -183,37 +199,33 @@ 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),
}
}
}
impl RenderDispatcher for MovementRenderDispatcher {
fn get_animation_key(&self) -> String {
new_animation_key(MovementRenderDispatcher::PREFIX, self.hwnd.to_string())
fn use_ghost(&self) -> bool {
GHOST_MOVEMENT_ENABLED.load(Ordering::Relaxed)
}
fn pre_render(&self) -> eyre::Result<()> {
stackbar_manager::STACKBAR_TEMPORARILY_DISABLED.store(true, Ordering::SeqCst);
stackbar_manager::send_notification();
Ok(())
/// 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 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)?;
fn finalise_managers(&self) {
if ANIMATION_MANAGER
.lock()
.count_in_progress(MovementRenderDispatcher::PREFIX)
@@ -228,9 +240,202 @@ impl RenderDispatcher for 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,6 +28,7 @@ use crate::animation::AnimationEngine;
use crate::core::Arrangement;
use crate::core::Axis;
use crate::core::BorderImplementation;
use crate::core::CustomLayout;
use crate::core::CycleDirection;
use crate::core::DefaultLayout;
use crate::core::FocusFollowsMouseImplementation;
@@ -40,7 +41,6 @@ use crate::core::Sizing;
use crate::core::WindowContainerBehaviour;
use crate::core::WindowManagementBehaviour;
use crate::core::config_generation::MatchingRule;
use crate::core::custom_layout::CustomLayout;
use crate::CrossBoundaryBehaviour;
use crate::DATA_DIR;
@@ -239,21 +239,30 @@ 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;
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_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()
{
// 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;
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;
*workspace = state_workspace.clone();
*workspace = state_workspace.clone();
workspace.container_padding = container_padding;
workspace.workspace_padding = workspace_padding;
workspace.container_padding = container_padding;
workspace.workspace_padding = workspace_padding;
workspace.layout_options = layout_options;
if state_monitor.focused_workspace_idx() == workspace_idx {
focused_workspace = workspace_idx;
if state_monitor.focused_workspace_idx() == workspace_idx {
focused_workspace = workspace_idx;
}
}
}
}
@@ -2101,12 +2110,19 @@ impl WindowManager {
tracing::info!("focusing container");
let new_idx =
if workspace.maximized_window.is_some() || workspace.monocle_container.is_some() {
None
} else {
workspace.new_idx_for_direction(direction)
if workspace.monocle_container.is_some() {
let cycle_direction = match direction {
OperationDirection::Left | OperationDirection::Down => CycleDirection::Previous,
OperationDirection::Right | OperationDirection::Up => CycleDirection::Next,
};
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;
@@ -3091,6 +3107,27 @@ 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()?;
@@ -3339,7 +3376,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(|a, b| a.0.cmp(&b.0));
rules.sort_by_key(|a| a.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 {
@@ -3382,7 +3419,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(|a, b| a.0.cmp(&b.0));
rules.sort_by_key(|a| a.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 {

View File

@@ -24,6 +24,7 @@ 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;
@@ -32,7 +33,10 @@ 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;
@@ -144,6 +148,7 @@ 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;
@@ -1343,6 +1348,41 @@ 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,3 +1,4 @@
use std::collections::HashMap;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fmt::Display;
@@ -25,9 +26,10 @@ 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;
@@ -61,6 +63,15 @@ 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 +129,9 @@ impl Default for Workspace {
layout: Layout::Default(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)),
@@ -163,8 +177,49 @@ 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) -> eyre::Result<()> {
pub fn load_static_config(
&mut self,
config: &WorkspaceConfig,
layout_defaults: Option<&HashMap<DefaultLayout, LayoutDefaultEntry>>,
) -> eyre::Result<()> {
self.name = Option::from(config.name.clone());
self.container_padding = config.container_padding;
@@ -213,6 +268,15 @@ 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 =
@@ -240,13 +304,78 @@ 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();
@@ -473,9 +602,27 @@ 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,
@@ -489,7 +636,6 @@ 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
@@ -550,6 +696,15 @@ 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(
@@ -559,7 +714,7 @@ impl Workspace {
self.layout_flip,
&self.resize_dimensions,
self.focused_container_idx(),
self.layout_options,
effective_layout_options,
&self.latest_layout,
);
@@ -1504,6 +1659,23 @@ 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.40"
version = "0.1.42"
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.40"
version = "0.1.42"
description = "The command-line interface for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2024"

View File

@@ -1001,6 +1001,16 @@ 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
@@ -1267,6 +1277,8 @@ 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)]
@@ -1912,13 +1924,11 @@ 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()
);
}
_ => {}
}
@@ -2934,6 +2944,15 @@ 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,6 +81,8 @@ 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.40`",
"description": "The `komorebi.json` static configuration file reference for `v0.1.42`",
"type": "object",
"properties": {
"animation": {
@@ -304,6 +304,16 @@
"$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": [
@@ -703,7 +713,7 @@
},
{
"title": "CubicBezier",
"description": "Custom Cubic Bézier function",
"description": "Custom Cubic Bezier function",
"type": "object",
"properties": {
"CubicBezier": {
@@ -768,6 +778,14 @@
"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": [
@@ -3290,10 +3308,57 @@
"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": [
@@ -3305,6 +3370,23 @@
}
]
},
"row_ratios": {
"description": "Row height ratios (up to MAX_RATIOS values between 0.1 and 0.9)\n\n- Used by Rows layout: ratios for each row height\n- Used by Grid layout: ratios for row heights\n\nRows without a ratio share remaining space equally.\nExample: `[0.5, 0.5]` for 50%-50% rows",
"type": [
"array",
"null"
],
"default": null,
"items": {
"type": [
"number",
"null"
],
"format": "float"
},
"maxItems": 5,
"minItems": 5
},
"scrolling": {
"description": "Options related to the Scrolling layout",
"anyOf": [
@@ -4180,6 +4262,19 @@
}
]
},
"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": [
@@ -4252,6 +4347,19 @@
}
]
},
"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": [