Compare commits

..

65 Commits

Author SHA1 Message Date
LGUG2Z
ffa76ea28c chore(release): v0.1.38 2025-09-12 17:36:04 -07:00
Lucas de Linhares
2c00d79968 perf(cargo): add release-opt profile 2025-09-12 17:05:24 -07:00
LGUG2Z
78177af6b8 docs(mkdocs): run docgen, depgen and jsonschema targets 2025-09-09 08:56:29 -07:00
dependabot[bot]
86e0d40828 chore(deps): bump actions/github-script from 7 to 8
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-08 10:03:38 -07:00
LGUG2Z
48f6ac8964 chore(deps): cargo update 2025-09-08 08:17:40 -07:00
omark96
1db572f789 feat(wm): implement Clone for State, Notification and NotificationEvent
This implements Clone for State, Notification and NotificationEvent to allow for the use of these
types in Rhai scripts.
2025-09-02 07:53:36 -07:00
1337cookie
3dad77533b feat(bar): add config opts for ro and removable disks
This lets the user enable or disable showing removable or read only
disks in the komorebi-bar storage widget.

Removable disks are set to show by default.

Read-only disks are set not to show by default. Having windows sandbox
installed displays a very long read only disk which could be problematic
for new users.
2025-09-01 15:15:41 -07:00
omark96
f40fb9a251 feat(wm): add config option to set tiling
This commit adds the option to set whether a workspace should be tiled or not by default. It retains
the default behaviour of komorebi, but adds the option to set a workspace to not be tiled by
default, but still be able to change the default layout for that workspace.
2025-09-01 15:04:59 -07:00
omark96
b4e16e43e9 feat(cli): change quickstart to prompt user before writing files
This commit makes it so the quickstart command first checks for the existence of the config files.
If they don't exist it writes them, if they do exist it prompts the user to whether or not they want
to overwrite the existing files. Lastly it prints the full path to the files that were written. This
is to prevent users from accidentally overwriting their configs as well as making it clearer where
komorebi places the config files for new users.
2025-09-01 15:03:48 -07:00
LGUG2Z
4c2e8ff6d2 chore(deps): cargo update 2025-09-01 15:03:09 -07:00
Jerry Kingsbury
c879aae1e7 test(wm): monocle on and off on nonexistent container
Created a test that tests moving a nonexistent container to monocle and
retreiving a nonexistent container from monocle. The test ensures we
receive an error when attempting to use monocle_on or monocle_off when a
container doesn't exist.
2025-08-27 18:05:07 -07:00
Jerry Kingsbury
7e87e83189 test(wm): toggle monocle on nonexistent container
Created a test to test toggle monocle when a container doesn't exist in
the monitor. The test ensures that we receive an error when attempting
to call toggle monocle when a monitor doesn't contain a container.
2025-08-27 18:05:07 -07:00
Jerry Kingsbury
aae9338f66 test(wm): toggle maximize a nonexistent window
The test ensures that we receive an error when attempting to toggle
maximize a window that doesn't exist.
2025-08-27 18:05:07 -07:00
Jerry Kingsbury
f68a709f1d test(wm): float nonexistent window
Created a test to test trying to float a nonexistent window. The test
ensures that we receive an error when trying to float a window in an
empty container
2025-08-27 18:05:07 -07:00
Jerry Kingsbury
c76846ac63 test(wm): cycle windows in an empty container
Created a test to test cycling windows in an empty container. The test
ensures that when attempting to cycle windows in an empty container it
will push out an error.
2025-08-27 18:05:07 -07:00
Jerry Kingsbury
b29dd8b1d1 test(wm): remove nonexistent window from container
Created a test that tests removing a nonexistent window from a
container. The test creates a monitor containing an empty container and
attempts to remove a window from the container. The test ensures that
the result is an error when calling remove_window_from_container.
2025-08-27 18:05:07 -07:00
omark96
59c3c14731 feat(config): add work-area-offset per workspace
This commit adds the option to set 'work_area_offset' per workspace. If
no workspace work area offset is set for that workspace it will instead
use the value of the globals.work_area_offset for that workspace.

This commit adds a command to set the work area offset of a workspace
given a monitor index and a workspace index.
2025-08-27 18:05:07 -07:00
dependabot[bot]
db96f2cc5a chore(deps): bump reqwest from 0.12.22 to 0.12.23
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.12.22 to 0.12.23.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.22...v0.12.23)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-27 18:05:07 -07:00
dependabot[bot]
a37a6752a8 chore(deps): bump clap from 4.5.41 to 4.5.45
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.41 to 4.5.45.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.41...clap_complete-v4.5.45)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.45
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-27 18:05:07 -07:00
loeiks
4d0df9c5b5 fix(cli): fix typo in the toggle-pause docs 2025-08-27 18:05:07 -07:00
dependabot[bot]
f8ea62f857 chore(deps): bump shadow-rs from 1.2.0 to 1.2.1
Bumps [shadow-rs](https://github.com/baoyachi/shadow-rs) from 1.2.0 to 1.2.1.
- [Release notes](https://github.com/baoyachi/shadow-rs/releases)
- [Commits](https://github.com/baoyachi/shadow-rs/compare/v1.2.0...v1.2.1)

---
updated-dependencies:
- dependency-name: shadow-rs
  dependency-version: 1.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-17 10:09:45 -07:00
dependabot[bot]
93bb41737b chore(deps): bump serde_json from 1.0.141 to 1.0.142
Bumps [serde_json](https://github.com/serde-rs/json) from 1.0.141 to 1.0.142.
- [Release notes](https://github.com/serde-rs/json/releases)
- [Commits](https://github.com/serde-rs/json/compare/v1.0.141...v1.0.142)

---
updated-dependencies:
- dependency-name: serde_json
  dependency-version: 1.0.142
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-17 10:09:35 -07:00
dependabot[bot]
280352eeef chore(deps): bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-17 10:06:45 -07:00
dependabot[bot]
7619b9b4ed chore(deps): bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-17 10:06:36 -07:00
dependabot[bot]
72a4d5276e chore(deps): bump slab from 0.4.10 to 0.4.11
Bumps [slab](https://github.com/tokio-rs/slab) from 0.4.10 to 0.4.11.
- [Release notes](https://github.com/tokio-rs/slab/releases)
- [Changelog](https://github.com/tokio-rs/slab/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/slab/compare/v0.4.10...v0.4.11)

---
updated-dependencies:
- dependency-name: slab
  dependency-version: 0.4.11
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-12 07:07:54 -07:00
JustForFun88
4bb3b83d57 refactor(bar) split komorebi widget into smaller parts
This PR significantly refactors the komorebi  bar rendering logic,
simplifying state management, and addressing some found bugs. The
primary motivation was to make the codebase more readable and
maintainable.

Key Changes:

- Allocation Reduction: Removed most per-frame structure allocations.

- Runtime Matching Elimination: Replaced runtime pattern matching with
  pre-selected function pointers determined at initialization. Widget
  validations and configurations are now performed during widget
  creation rather than per-frame checks. For example, widget enablement
  is now handled by an Option that wraps each ..Bar structure. If a
  widget is enabled, its structure is present; otherwise, it is None.
  This eliminates the need for runtime enabled checks.

- Widget Modularity: Code is split into smaller parts, reducing
  complexity.

Bug Fixes:

- Corrected icon sizing for floating windows following regular
  containers, ensuring icons revert correctly from icon_size to
  text_size.

- There was also another bug with a floating window positioned above a
  monocle container, but I forgot the details 😅
2025-07-31 17:14:39 -07:00
omark96
ccd2f3a464 fix(wm): move last_focused_workspace logic to focus_workspace method
There are currently a number of commands that do not update the previously focused workspace
correctly. The previous method relied on handling each case, this change makes it so that
last_focused_workspace is always updated whenever the focus is changed.
2025-07-23 09:47:57 -07:00
dependabot[bot]
7e242ada66 chore(deps): bump netdev from 0.35.3 to 0.36.0
---
updated-dependencies:
- dependency-name: netdev
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-21 10:59:39 -07:00
dependabot[bot]
5b2acd0f12 chore(deps): bump shadow-rs from 1.1.1 to 1.2.0
Bumps [shadow-rs](https://github.com/baoyachi/shadow-rs) from 1.1.1 to 1.2.0.
- [Release notes](https://github.com/baoyachi/shadow-rs/releases)
- [Commits](https://github.com/baoyachi/shadow-rs/compare/v1.1.1...v1.2.0)

---
updated-dependencies:
- dependency-name: shadow-rs
  dependency-version: 1.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-20 19:14:59 -07:00
LGUG2Z
ec0bbaae98 chore(deps): cargo update 2025-07-20 18:49:37 -07:00
LGUG2Z
3c44f3bfdb refactor(clippy): apply lints 2025-07-19 16:42:25 -07:00
Martin Hjulstad Bednar
7839980ddf feat(shortcuts): show all hotkey bindings
- Removed command filtering logic from the shortcuts UI.
- All hotkey bindings are now shown regardless of command.
- Improved UI with hint text for the filter input.
2025-07-16 21:04:15 -07:00
JustForFun88
6416c0b6eb refactor(wm): move 'locked' flag down to containers
Key Changes
- Added `locked: bool` field directly to the `Container` struct.
- Removed `locked_containers` from `Workspace`.
- Updated `komorebi_bar` to access `locked` directly
  from `Container`.

Insert and swap operations respects `locked` container
indexes in the sequence
2025-06-15 12:30:03 -07:00
LGUG2Z
f6ccec9505 chore(deps): cargo update 2025-06-14 16:44:55 -07:00
LGUG2Z
21cb5e1e6f feat(stackbar): set title as default label
Following discussing in #1475, and considering the default of the
komorebi widget in komorebi-bar, this commit updates the default
StackbarLabel from Process to Title.

resolve #1475
2025-06-05 22:03:53 -07:00
LGUG2Z
009c0dcd28 chore(deps): cargo update 2025-06-04 15:54:15 -07:00
LGUG2Z
98c5ab3b9b fix(wm): prevent stack-all issues with n>1 stacks on ws
This commit ensures that unstack_all is called on a workspace before
attempting to proceed with stack_all to avoid stacking loops that can
occur when there are multiple pre-existing stacks already created on the
workspace.
2025-06-04 15:51:05 -07:00
dependabot[bot]
d4eeec994f chore(deps): bump netdev from 0.34.0 to 0.35.1
Bumps [netdev](https://github.com/shellrow/netdev) from 0.34.0 to 0.35.1.
- [Release notes](https://github.com/shellrow/netdev/releases)
- [Commits](https://github.com/shellrow/netdev/commits)

---
updated-dependencies:
- dependency-name: netdev
  dependency-version: 0.35.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 10:47:34 -07:00
dependabot[bot]
e9ed1cfd3b chore(deps): bump reqwest from 0.12.15 to 0.12.19
Bumps [reqwest](https://github.com/seanmonstar/reqwest) from 0.12.15 to 0.12.19.
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.15...v0.12.19)

---
updated-dependencies:
- dependency-name: reqwest
  dependency-version: 0.12.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 10:47:05 -07:00
dependabot[bot]
4a2eb391f7 chore(deps): bump clap from 4.5.38 to 4.5.39
Bumps [clap](https://github.com/clap-rs/clap) from 4.5.38 to 4.5.39.
- [Release notes](https://github.com/clap-rs/clap/releases)
- [Changelog](https://github.com/clap-rs/clap/blob/master/CHANGELOG.md)
- [Commits](https://github.com/clap-rs/clap/compare/clap_complete-v4.5.38...clap_complete-v4.5.39)

---
updated-dependencies:
- dependency-name: clap
  dependency-version: 4.5.39
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-06-02 10:46:48 -07:00
LGUG2Z
41e18bccc6 fix(stackbar): show regular cursor on hover
This commit ensures that the WM_SETCURSOR message is handled by stackbar
windows by setting the cursor to the default IDC_ARROW, which prevents
the spinning "Loading" icon from showing on hover.

re #1456
2025-05-27 08:12:58 -07:00
LGUG2Z
3d373b3630 fix(schema): flatten bar mouse config opts 2025-05-26 09:52:14 -07:00
LGUG2Z
b4e61b079c feat(wm): add scrolling layout
This commit adds a new DefaultLayout::Scrolling variant, along with a
new LayoutOptions configuration which will initially be used to allow
the user to declaratively specify the number of visible columns for the
Scrolling layout, and a new komorebic "scrolling-layout-columns" command
to allow the user to modify this value for the focused workspace at
runtime.

The Scrolling layout is inspired by the Niri scrolling window manager,
presenting a workspace as an infinite scrollable horizontal strip with a
viewport which includes the focused window + N other windows in columns.
There is no support for splitting columns into multiple rows.

This layout can currently only be applied to single-monitor setups as
the scrolling would result in layout calculations which push the windows
in the columns moving out of the viewport onto adjacent monitors.

This implementation in the current state is enough to be useable for me
personally, but if others want to iterate on this, make it handle
hiding/restoring windows correctly when scrolling the viewport so that
adjacent monitors don't get impacted etc., patches are always welcome.

resolve #1434
2025-05-17 21:12:51 -07:00
LGUG2Z
eec6312a51 chore(dev): begin v0.1.38-dev 2025-05-17 20:26:13 -07:00
LGUG2Z
00384ce333 chore(release): v0.1.37 2025-05-17 11:44:55 -07:00
LGUG2Z
e75578d9ce ci(github): skip backwards compat test 2025-05-17 08:25:23 -07:00
alex-ds13
50c850cb02 feat(wm): floating over monocle
This commit allows for floating windows on the floating workspace layer
to be toggled on and off when monocle mode is enabled on a workspace.
2025-05-16 16:35:47 -07:00
Jerry Kingsbury
5a1af5b133 test(wm): swap container with non-existent container
Created a test for swapping a container with a container that doesn't
exist, which ensures that we receive an error and that the contents of
the container is still available when attempting to swap a container
with a non-existent one.

A bug was discovered where the origin container was being removed even
though the swap_container function would return an error.

@LGUGZ discovered that the issue was due to the origin container being
removed before verifying that both containers exist. The fix that @LGUGZ
came up with is included.
2025-05-16 16:24:02 -07:00
Jerry Kingsbury
74c433185a test(wm): switch focus to non-existent monitor
Created a test for focusing on a monitor that doesn't exist, which
ensures that we receive an error and that we are still focused on the
current monitor when attempting to focus a non-existent monitor.
2025-05-16 16:24:02 -07:00
Jerry Kingsbury
71bb346c89 test(wm): swap workspace with non-existent monitor
Created a test for swapping workspaces with a monitor that doesn't
exist, which ensures that we receive an error and that none of the
workspaces were moved when attempting to swap workspaces with a monitor
that doesn't exist.
2025-05-16 16:23:55 -07:00
LGUG2Z
3feff1dca9 docs(schema): update jsonschema and docgen 2025-05-15 18:28:27 -07:00
JustForFun88
3019eaf89c refactor(bar): app widget and icon caching
PR #1439 authored and submitted by @JustForFun88

I understand this PR combines two areas of work — refactoring the
Applications widget and introducing a new icon caching system —
which would ideally be submitted separately.

Originally, I only intended to reduce allocations and simplify icon
loading in `applications.rs`, but as I worked through it, it became
clear that a more general-purpose caching system was needed. One
improvement led to another ... 😄

Apologies for bundling these changes together. If needed, I’m happy to
split this PR into smaller, focused ones.

Key Changes

- Introduced `IconsCache` with unified in-memory image & texture
  management.
- Added `ImageIcon` and `ImageIconId` (based on path or HWND) for
  caching and reuse.
- `Icon::Image` now wraps `ImageIcon`, decoupled from direct `RgbaImage`
  usage.
- Extracted app launch logic into `UserCommand` with built-in cooldown.
- Simplified config parsing and UI hover rendering in `App`.
- Replaced legacy `ICON_CACHE` in
  `KomorebiNotificationStateContainerInformation`
  → Now uses the shared `ImageIcon::try_load(hwnd, ..)` with caching and fallback.

Motivation

- Reduce redundant image copies and avoid repeated pixel-to-texture
  conversions.
- Cleanly separate concerns for launching and icon handling.
- Reuse icons across `Applications`, Komorebi windows, and potentially
  more in the future.

Tested

- Works on Windows 11.
- Verified path/exe/HWND icon loading and fallback.
2025-05-15 18:17:27 -07:00
LGUG2Z
ce59bd9ae4 chore(deps): cargo update 2025-05-15 17:59:57 -07:00
LGUG2Z
309dd159ca docs(mkdocs): updates to prepare for v0.1.37 2025-05-15 17:45:16 -07:00
LGUG2Z
6f1d6dbdc7 fix(wm): disallow toggle-float if ws has a monocle container
This commit ensures that the user can't get into a weird state by
attempting to float a monocle container. If a user attempts to call
toggle-float on a monocle container, a warning will be logged and the
command will be ignored.
2025-05-15 08:09:10 -07:00
LGUG2Z
6a10d583a6 build(cargo): propagate expensive schemars feature correctly 2025-05-11 20:00:15 -07:00
LGUG2Z
270ea5aa46 feat(cli): add focused-container-kind state query
This commit adds a new StateQuery variant, FocusedContainerKind, which
will will return None if there is not focused container on the current
workspace (ie. it is empty), Single if the focused container contains a
single window, or Stack if the focused container contains more than one
window.
2025-05-11 18:23:53 -07:00
dependabot[bot]
a9d2738733 chore(deps): bump chrono from 0.4.40 to 0.4.41
Bumps [chrono](https://github.com/chronotope/chrono) from 0.4.40 to 0.4.41.
- [Release notes](https://github.com/chronotope/chrono/releases)
- [Changelog](https://github.com/chronotope/chrono/blob/main/CHANGELOG.md)
- [Commits](https://github.com/chronotope/chrono/compare/v0.4.40...v0.4.41)

---
updated-dependencies:
- dependency-name: chrono
  dependency-version: 0.4.41
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-05-10 15:22:32 -07:00
Csaba
3d8f68e559 feat(bar): send commands by mouse/touchpad/screen
This commit makes it possible to send commands from the bar by using the
mouse/touchpad/touchscreen.

Komorebi or custom commands can be sent by clicking on the mouse's
primary, secondary, middle, back or forward buttons.

As the primary single click is already used by widgets, only primary
double clicks can send commands. This limitation is due to Egui also
triggering 2 single clicks before a double click is triggered. Egui does
not have an implementation for stopping event propagation out of the box
and would be too much work to include.

Similarly, commands can be sent on every "tick" of mouse scrolling,
touchpad or touchscreen swiping in any of the 4 directions. This "tick"
can be adjusted to fit user's preference.

This is due to the fact, that Egui does not have an event for when a
mouse "tick" occurs. It instead gives a number of points that the user
scrolled/swiped on each frame.

PR: #1403
2025-05-10 15:19:24 -07:00
Jerry Kingsbury
80bb7288c4 test(wm): transfer window to nonexistent monitor
Created a test for the transfer_window function.

The tests attempts to transfer a window to a monitor that doesn't exist,
and checks to see if we return an error. The test successfully gets an
error but there is a bug where the window isn't in the contiainer after
a failed transfer. I wrote a note comment to explain the bug just in
case we need to reference back to it.
2025-05-10 15:01:17 -07:00
Jerry Kingsbury
76c833f661 test(wm): move workspace to non existent monitor
Created a test for moving the focused workspace to a monitor that hasn't
been created. The test ensures that we receive an error when moving a
workspace to a monitor that doesn't exist.
2025-05-10 15:01:17 -07:00
Jerry Kingsbury
80bce4be7e test(workspace): move window to non existent container
Created a test for moving a window to a container that doesn't exist.
The test ensures that we receive an error when moving a window to a
container that doesn't exist.
2025-05-10 15:01:17 -07:00
Jerry Kingsbury
8c10547325 test(workspace): remove a non existent window
Created a test for removing a window that doesn't exist.

The test creates a container with a window and then attempts to remove a
window that doesn't exist. The test will check the result to ensure that
the result returned an error and that we still have the expected number
of windows.
2025-05-10 15:01:10 -07:00
Jerry Kingsbury
8f886b3fe4 test(monitor): move container to a nonexistent workspace
Created a test for moving a container to a workspace that doesn't exist.

The test ensures when a container is moved to a workspace that doesn't
exist, the workspace is created and that the workspace contains the
container.

It also checks that there are N workspaces available where N is the
largest workspace number.
2025-05-10 15:01:06 -07:00
Jerry Kingsbury
53c38e157f test(monitor): remove nonexistent workspace
Created a test for moving a workspace that doesn't exist. The test
attempts to remove a workspace that doesn't exist and checks to ensure
the removed_workspace variable is None.
2025-05-10 14:58:17 -07:00
81 changed files with 60255 additions and 2884 deletions

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Check and close feature issues
uses: actions/github-script@v7
uses: actions/github-script@v8
with:
script: |
const issue = context.payload.issue;

View File

@@ -21,7 +21,7 @@ jobs:
cargo-deny:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- uses: EmbarkStudios/cargo-deny-action@v2
@@ -43,7 +43,7 @@ jobs:
RUSTFLAGS: -Ctarget-feature=+crt-static -Dwarnings
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- run: rustup toolchain install stable --profile minimal
@@ -81,12 +81,12 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- shell: bash
run: echo "VERSION=nightly" >> $GITHUB_ENV
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
- 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
@@ -128,14 +128,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- shell: bash
run: |
TAG=${{ github.event.release.tag_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
- 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
@@ -170,14 +170,14 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- shell: bash
run: |
TAG=${{ github.ref_name }}
echo "VERSION=${TAG#v}" >> $GITHUB_ENV
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v5
- 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

2090
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ members = [
"komorebic-no-console",
"komorebi-bar",
"komorebi-themes",
"komorebi-shortcuts"
"komorebi-shortcuts",
]
[workspace.dependencies]
@@ -33,6 +33,7 @@ strum = { version = "0.27", features = ["derive"] }
tracing = "0.1"
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
parking_lot = "0.12"
paste = "1"
sysinfo = "0.34"
uds_windows = "1"
@@ -51,7 +52,7 @@ features = [
"Win32_Devices",
"Win32_Devices_Display",
"Win32_System_Com",
"Win32_UI_Shell_Common", # for IObjectArray
"Win32_UI_Shell_Common", # for IObjectArray
"Win32_Foundation",
"Win32_Globalization",
"Win32_Graphics_Dwm",
@@ -72,5 +73,12 @@ features = [
"Win32_System_SystemServices",
"Win32_System_WindowsProgramming",
"Media",
"Media_Control"
"Media_Control",
]
[profile.release-opt]
inherits = "release"
lto = true
panic = "abort"
codegen-units = 1
strip = true

View File

@@ -394,7 +394,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.36"}
// komorebi-client = { git = "https://github.com/LGUG2Z/komorebi", tag = "v0.1.38"}
use anyhow::Result;
use komorebi_client::Notification;

View File

@@ -14,7 +14,8 @@ feature-depth = 1
ignore = [
{ id = "RUSTSEC-2020-0016", reason = "local tcp connectivity is an opt-in feature, and there is no upgrade path for TcpStreamExt" },
{ id = "RUSTSEC-2024-0436", reason = "paste being unmaintained is not an issue in our use" },
{ id = "RUSTSEC-2024-0320", reason = "not using any yaml features from this library" }
{ id = "RUSTSEC-2024-0320", reason = "not using any yaml features from this library" },
{ id = "RUSTSEC-2025-0056", reason = "only used for colour palette generation" }
]
[licenses]

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@ Usage: komorebic.exe change-layout <DEFAULT_LAYOUT>
Arguments:
<DEFAULT_LAYOUT>
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack]
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack, scrolling]
Options:
-h, --help

View File

@@ -13,7 +13,7 @@ Arguments:
The number of window containers on-screen required to trigger this layout rule
<LAYOUT>
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack]
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack, scrolling]
Options:
-h, --help

View File

@@ -10,7 +10,7 @@ Arguments:
Target workspace name
<VALUE>
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack]
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack, scrolling]
Options:
-h, --help

View File

@@ -7,7 +7,7 @@ Usage: komorebic.exe query <STATE_QUERY>
Arguments:
<STATE_QUERY>
[possible values: focused-monitor-index, focused-workspace-index, focused-container-index, focused-window-index, focused-workspace-name, focused-workspace-layout, version]
[possible values: focused-monitor-index, focused-workspace-index, focused-container-index, focused-window-index, focused-workspace-name, focused-workspace-layout, focused-container-kind, version]
Options:
-h, --help

View File

@@ -0,0 +1,16 @@
# scrolling-layout-columns
```
Set the number of visible columns for the Scrolling layout on the focused workspace
Usage: komorebic.exe scrolling-layout-columns <COUNT>
Arguments:
<COUNT>
Desired number of visible columns
Options:
-h, --help
Print help
```

View File

@@ -1,7 +1,7 @@
# toggle-pause
```
Toggle window tiling on the focused workspace
Toggle the paused state for all window tiling
Usage: komorebic.exe toggle-pause

View File

@@ -0,0 +1,12 @@
# toggle-shortcuts
```
Toggle the komorebi-shortcuts helper
Usage: komorebic.exe toggle-shortcuts
Options:
-h, --help
Print help
```

View File

@@ -8,7 +8,7 @@ Usage: komorebic.exe window-hiding-behaviour <HIDING_BEHAVIOUR>
Arguments:
<HIDING_BEHAVIOUR>
Possible values:
- hide: Use the SW_HIDE flag to hide windows when switching workspaces (has issues with Electron apps)
- hide: END OF LIFE FEATURE: Use the SW_HIDE flag to hide windows when switching workspaces (has issues with Electron apps)
- minimize: Use the SW_MINIMIZE flag to hide windows when switching workspaces (has issues with frequent workspace switching)
- cloak: Use the undocumented SetCloak Win32 function to hide windows when switching workspaces

View File

@@ -16,7 +16,7 @@ Arguments:
The number of window containers on-screen required to trigger this layout rule
<LAYOUT>
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack]
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack, scrolling]
Options:
-h, --help

View File

@@ -13,7 +13,7 @@ Arguments:
Workspace index on the specified monitor (zero-indexed)
<VALUE>
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack]
[possible values: bsp, columns, rows, vertical-stack, horizontal-stack, ultrawide-vertical-stack, grid, right-main-vertical-stack, scrolling]
Options:
-h, --help

View File

@@ -0,0 +1,31 @@
# workspace-work-area-offset
```
Set offsets for a workspace to exclude parts of the work area from tiling
Usage: komorebic.exe workspace-work-area-offset <MONITOR> <WORKSPACE> <LEFT> <TOP> <RIGHT> <BOTTOM>
Arguments:
<MONITOR>
Monitor index (zero-indexed)
<WORKSPACE>
Workspace index (zero-indexed)
<LEFT>
Size of the left work area offset (set right to left * 2 to maintain right padding)
<TOP>
Size of the top work area offset (set bottom to the same value to maintain bottom padding)
<RIGHT>
Size of the right work area offset
<BOTTOM>
Size of the bottom work area offset
Options:
-h, --help
Print help
```

View File

@@ -6,7 +6,10 @@ defined in the `komorebi.json` configuration file.
```json
{
"animation": {
"enabled": true
"enabled": true,
"duration": 250,
"fps": 60,
"style": "EaseOutSine"
}
}
```

View File

@@ -301,7 +301,7 @@ how to map the indices and would use default behaviour which would result in a m
}
```
# Multiple Monitors on different machines
# Multiple monitors on different machines
You can use the same `komorebi.json` to configure two different setups and then synchronize your config across machines.
However, if you do this it is important to be aware of a few things.
@@ -393,6 +393,13 @@ This is because komorebi will apply the appropriate config to the loaded monitor
index (the index defined in the user config) to the actual monitor index, and the bar will use that map to know if it
should be enabled, and where it should be drawn.
# Windows Display Settings
In `Settings > System > Display > Multiple Displays`:
- Disable "Remember windows locations on monitor connection"
- Enable "Minimize windows when a monitor is disconnected"
### Things to keep in mind
* If you are using a laptop connected to one monitor at work and a different one at home, the work monitor and the home

View File

@@ -1,17 +0,0 @@
# Setting a Given Display to a Specific Index
If you would like `komorebi` to remember monitor index positions, you will need to set the `display_index_preferences`
configuration option in the static configuration file.
Display IDs can be found using `komorebic monitor-information`.
Then, in `komorebi.json`, you simply need to specify the preferred index position for each display ID:
```json
{
"display_index_preferences": {
"0": "DEL4310-5&1a6c0954&0&UID209155",
"1": "<another-display_id>"
}
}
```

View File

@@ -186,6 +186,9 @@ limitations on hotkey bindings that include the `win` key. However, you will sti
to [modify the registry](https://superuser.com/questions/1059511/how-to-disable-winl-in-windows-10) to prevent
`win + l` from locking the operating system.
You can toggle an overlay of the current `whkdrc` shortcuts related to `komorebi` at any time when using the example
configuration with `alt + i`.
```
{% include "./whkdrc.sample" %}
```

View File

@@ -120,6 +120,7 @@ cargo +stable install --path komorebic --locked
cargo +stable install --path komorebic-no-console --locked
cargo +stable install --path komorebi-gui --locked
cargo +stable install --path komorebi-bar --locked
cargo +stable install --path komorebi-shortcuts --locked
```
If the binaries have been built and added to your `$PATH` correctly, you should

View File

@@ -1,5 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.36/schema.bar.json",
"$schema": "https://raw.githubusercontent.com/LGUG2Z/komorebi/v0.1.38/schema.bar.json",
"monitor": 0,
"font_family": "JetBrains Mono",
"theme": {

View File

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

View File

@@ -138,13 +138,14 @@ running `komorebic stop` and `komorebic start`.
Users with Nvidia GPUs may have issues with transparency on the Komorebi Bar.
To solve this the user can do the following:
1. Open the Nvidia Control Panel
2. On the left menu tree, under "3D Settings", select "Manage 3D Settings"
3. Select the "Program Settings" tab
4. Press the "Add" button and select "komorebi-bar"
5. Under "3. Specify the settings for this program:", find the feature labelled, "OpenGL GDI compatibility"
6. Change the setting to "Prefer compatibility"
7. At the bottom of the window select "Apply"
8. Restart the Komorebi Bar with "komorebic stop --bar; komorebic start --bar"
- Open the Nvidia Control Panel
- On the left menu tree, under "3D Settings", select "Manage 3D Settings"
- Select the "Program Settings" tab
- Press the "Add" button and select "komorebi-bar"
- Under "3. Specify the settings for this program:", find the feature labelled, "OpenGL GDI compatibility"
- Change the setting to "Prefer compatibility"
- At the bottom of the window select "Apply"
- Restart the Komorebi Bar with "komorebic stop --bar; komorebic start --bar"
This should resolve the issue and your Komorebi Bar should render with the proper transparency.

View File

@@ -28,7 +28,7 @@ install-target-with-jsonschema target:
cargo +stable install --path {{ target }} --locked
install:
just install-targets komorebic komorebic-no-console komorebi komorebi-bar komorebi-gui
just install-targets komorebic komorebic-no-console komorebi komorebi-bar komorebi-gui komorebi-shortcuts
install-with-jsonschema:
just install-targets-with-jsonschema komorebic komorebic-no-console komorebi komorebi-bar komorebi-gui komorebi-shortcuts
@@ -52,7 +52,7 @@ wpm target:
just build-target {{ target }} && wpmctl stop {{ target }}; just copy-target {{ target }} && wpmctl start {{ target }}
copy:
just copy-targets komorebic komorebic-no-console komorebi komorebi-bar komorebi-gui
just copy-targets komorebic komorebic-no-console komorebi komorebi-bar komorebi-gui komorebi-shortcuts
run target:
cargo +stable run --bin {{ target }} --locked --no-default-features

View File

@@ -1,13 +1,13 @@
[package]
name = "komorebi-bar"
version = "0.1.37"
version = "0.1.38"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-client = { path = "../komorebi-client" }
komorebi-themes = { path = "../komorebi-themes" }
komorebi-client = { path = "../komorebi-client", default-features = false }
komorebi-themes = { path = "../komorebi-themes", default-features = false }
chrono-tz = { workspace = true }
chrono = { workspace = true }
@@ -22,10 +22,11 @@ font-loader = "0.11"
hotwatch = { workspace = true }
image = "0.25"
lazy_static = { workspace = true }
netdev = "0.34"
netdev = "0.36"
num = "0.4"
num-derive = "0.4"
num-traits = "0.2"
parking_lot = { workspace = true }
random_word = { version = "0.5", features = ["en"] }
reqwest = { version = "0.12", features = ["blocking"] }
schemars = { workspace = true, optional = true }
@@ -43,4 +44,4 @@ windows-icons-fallback = { package = "windows-icons", git = "https://github.com/
[features]
default = ["schemars"]
schemars = ["dep:schemars", "komorebi-client/schemars", "komorebi-themes/schemars"]
schemars = ["dep:schemars", "komorebi-client/default", "komorebi-themes/default"]

View File

@@ -10,7 +10,7 @@ use crate::render::Grouping;
use crate::render::RenderConfig;
use crate::render::RenderExt;
use crate::widgets::komorebi::Komorebi;
use crate::widgets::komorebi::KomorebiNotificationState;
use crate::widgets::komorebi::MonitorInfo;
use crate::widgets::widget::BarWidget;
use crate::widgets::widget::WidgetConfig;
use crate::KomorebiEvent;
@@ -38,6 +38,7 @@ use eframe::egui::Frame;
use eframe::egui::Id;
use eframe::egui::Layout;
use eframe::egui::Margin;
use eframe::egui::PointerButton;
use eframe::egui::Rgba;
use eframe::egui::Style;
use eframe::egui::TextStyle;
@@ -46,31 +47,110 @@ use eframe::egui::Visuals;
use font_loader::system_fonts;
use font_loader::system_fonts::FontPropertyBuilder;
use komorebi_client::Colour;
use komorebi_client::KomorebiTheme;
use komorebi_client::MonitorNotification;
use komorebi_client::NotificationEvent;
use komorebi_client::PathExt;
use komorebi_client::SocketMessage;
use komorebi_client::VirtualDesktopNotification;
use komorebi_themes::catppuccin_egui;
use komorebi_themes::Base16Value;
use komorebi_themes::Base16Wrapper;
use komorebi_themes::Catppuccin;
use komorebi_themes::CatppuccinValue;
use lazy_static::lazy_static;
use parking_lot::Mutex;
use std::cell::RefCell;
use std::collections::HashMap;
use std::io::Error;
use std::io::ErrorKind;
use std::io::Result;
use std::io::Write;
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::ChildStdin;
use std::process::Command;
use std::process::Stdio;
use std::rc::Rc;
use std::sync::atomic::Ordering;
use std::sync::Arc;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
lazy_static! {
static ref SESSION_STDIN: Mutex<Option<ChildStdin>> = Mutex::new(None);
}
fn start_powershell() -> Result<()> {
// found running session, do nothing
if SESSION_STDIN.lock().as_mut().is_some() {
tracing::debug!("PowerShell session already started");
return Ok(());
}
tracing::debug!("Starting PowerShell session");
let mut child = Command::new("powershell.exe")
.args(["-NoLogo", "-NoProfile", "-Command", "-"])
.stdin(Stdio::piped())
.creation_flags(CREATE_NO_WINDOW)
.spawn()?;
let stdin = child.stdin.take().expect("stdin piped");
// Store stdin for later commands
let mut session_stdin = SESSION_STDIN.lock();
*session_stdin = Option::from(stdin);
Ok(())
}
fn stop_powershell() -> Result<()> {
tracing::debug!("Stopping PowerShell session");
if let Some(mut session_stdin) = SESSION_STDIN.lock().take() {
if let Err(e) = session_stdin.write_all(b"exit\n") {
tracing::error!(error = %e, "failed to write exit command to PowerShell stdin");
return Err(e);
}
if let Err(e) = session_stdin.flush() {
tracing::error!(error = %e, "failed to flush PowerShell stdin");
return Err(e);
}
tracing::debug!("PowerShell session stopped");
} else {
tracing::debug!("PowerShell session already stopped");
}
Ok(())
}
pub fn exec_powershell(cmd: &str) -> Result<()> {
if let Some(session_stdin) = SESSION_STDIN.lock().as_mut() {
if let Err(e) = writeln!(session_stdin, "{cmd}") {
tracing::error!(error = %e, cmd = cmd, "failed to write command to PowerShell stdin");
return Err(e);
}
if let Err(e) = session_stdin.flush() {
tracing::error!(error = %e, "failed to flush PowerShell stdin");
return Err(e);
}
return Ok(());
}
Err(Error::new(
ErrorKind::NotFound,
"PowerShell session not started",
))
}
pub struct Komobar {
pub hwnd: Option<isize>,
pub monitor_index: Option<usize>,
pub disabled: bool,
pub config: KomobarConfig,
pub render_config: Rc<RefCell<RenderConfig>>,
pub komorebi_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>,
pub monitor_info: Option<Rc<RefCell<MonitorInfo>>>,
pub left_widgets: Vec<Box<dyn BarWidget>>,
pub center_widgets: Vec<Box<dyn BarWidget>>,
pub right_widgets: Vec<Box<dyn BarWidget>>,
@@ -82,6 +162,18 @@ pub struct Komobar {
pub size_rect: komorebi_client::Rect,
pub work_area_offset: komorebi_client::Rect,
applied_theme_on_first_frame: bool,
mouse_follows_focus: bool,
input_config: InputConfig,
}
struct InputConfig {
accumulated_scroll_delta: Vec2,
act_on_vertical_scroll: bool,
act_on_horizontal_scroll: bool,
vertical_scroll_threshold: f32,
horizontal_scroll_threshold: f32,
vertical_scroll_max_threshold: f32,
horizontal_scroll_max_threshold: f32,
}
pub fn apply_theme(
@@ -250,7 +342,7 @@ impl Komobar {
pub fn apply_config(
&mut self,
ctx: &Context,
previous_notification_state: Option<Rc<RefCell<KomorebiNotificationState>>>,
previous_monitor_info: Option<Rc<RefCell<MonitorInfo>>>,
) {
MAX_LABEL_WIDTH.store(
self.config.max_label_width.unwrap_or(400.0) as i32,
@@ -279,7 +371,7 @@ impl Komobar {
self.config.icon_scale,
));
let mut komorebi_notification_state = previous_notification_state;
let mut monitor_info = previous_monitor_info;
let mut komorebi_widgets = Vec::new();
for (idx, widget_config) in self.config.left_widgets.iter().enumerate() {
@@ -331,19 +423,18 @@ impl Komobar {
komorebi_widgets
.into_iter()
.for_each(|(mut widget, idx, side)| {
match komorebi_notification_state {
match monitor_info {
None => {
komorebi_notification_state =
Some(widget.komorebi_notification_state.clone());
monitor_info = Some(widget.monitor_info.clone());
}
Some(ref previous) => {
if widget.workspaces.is_some_and(|w| w.enable) {
previous.borrow_mut().update_from_config(
&widget.komorebi_notification_state.borrow(),
);
if widget.workspaces.is_some() {
previous
.borrow_mut()
.update_from_self(&widget.monitor_info.borrow());
}
widget.komorebi_notification_state = previous.clone();
widget.monitor_info = previous.clone();
}
}
@@ -368,15 +459,19 @@ impl Komobar {
}
MonitorConfigOrIndex::Index(idx) => (*idx, None),
};
let monitor_index = self.komorebi_notification_state.as_ref().and_then(|state| {
state
.borrow()
.monitor_usr_idx_map
.get(&usr_monitor_index)
.copied()
let mapped_info = self.monitor_info.as_ref().map(|info| {
let monitor = info.borrow();
(
monitor.monitor_usr_idx_map.get(&usr_monitor_index).copied(),
monitor.mouse_follows_focus,
)
});
self.monitor_index = monitor_index;
if let Some(info) = mapped_info {
self.monitor_index = info.0;
self.mouse_follows_focus = info.1;
}
if let Some(monitor_index) = self.monitor_index {
if let (prev_rect, Some(new_rect)) = (&self.work_area_offset, &config_work_area_offset)
@@ -427,7 +522,7 @@ impl Komobar {
}
}
}
} else if self.komorebi_notification_state.is_some() && !self.disabled {
} else if self.monitor_info.is_some() && !self.disabled {
tracing::warn!("couldn't find the monitor index of this bar! Disabling the bar until the monitor connects...");
self.disabled = true;
} else {
@@ -435,9 +530,39 @@ impl Komobar {
self.disabled = true;
}
if let Some(mouse) = &self.config.mouse {
self.input_config.act_on_vertical_scroll =
mouse.on_scroll_up.is_some() || mouse.on_scroll_down.is_some();
self.input_config.act_on_horizontal_scroll =
mouse.on_scroll_left.is_some() || mouse.on_scroll_right.is_some();
self.input_config.vertical_scroll_threshold = mouse
.vertical_scroll_threshold
.unwrap_or(30.0)
.clamp(10.0, 300.0);
self.input_config.horizontal_scroll_threshold = mouse
.horizontal_scroll_threshold
.unwrap_or(30.0)
.clamp(10.0, 300.0);
// limit how many "ticks" can be accumulated
self.input_config.vertical_scroll_max_threshold =
self.input_config.vertical_scroll_threshold * 3.0;
self.input_config.horizontal_scroll_max_threshold =
self.input_config.horizontal_scroll_threshold * 3.0;
if mouse.has_command() {
start_powershell().unwrap_or_else(|_| {
tracing::error!("failed to start powershell session");
});
} else {
stop_powershell().unwrap_or_else(|_| {
tracing::error!("failed to stop powershell session");
});
}
}
tracing::info!("widget configuration options applied");
self.komorebi_notification_state = komorebi_notification_state;
self.monitor_info = monitor_info;
}
/// Updates the `size_rect` field. Returns a bool indicating if the field was changed or not
@@ -506,8 +631,7 @@ impl Komobar {
assert!(
home.is_dir(),
"$Env:KOMOREBI_CONFIG_HOME is set to '{}', which is not a valid directory",
home_path
"$Env:KOMOREBI_CONFIG_HOME is set to '{home_path}', which is not a valid directory"
);
home
@@ -521,26 +645,6 @@ impl Komobar {
match komorebi_client::StaticConfig::read(&config) {
Ok(config) => {
if let Some(theme) = config.theme {
let stack_accent = match theme {
KomorebiTheme::Catppuccin {
name, stack_border, ..
} => stack_border
.unwrap_or(CatppuccinValue::Green)
.color32(name.as_theme()),
KomorebiTheme::Base16 {
name, stack_border, ..
} => stack_border
.unwrap_or(Base16Value::Base0B)
.color32(Base16Wrapper::Base16(name)),
KomorebiTheme::Custom {
ref colours,
stack_border,
..
} => stack_border
.unwrap_or(Base16Value::Base0B)
.color32(Base16Wrapper::Custom(colours.clone())),
};
apply_theme(
ctx,
KomobarTheme::from(theme),
@@ -550,10 +654,6 @@ impl Komobar {
bar_grouping,
self.render_config.clone(),
);
if let Some(state) = &self.komorebi_notification_state {
state.borrow_mut().stack_accent = Some(stack_accent);
}
}
}
Err(_) => {
@@ -596,7 +696,7 @@ impl Komobar {
disabled: false,
config,
render_config: Rc::new(RefCell::new(RenderConfig::new())),
komorebi_notification_state: None,
monitor_info: None,
left_widgets: vec![],
center_widgets: vec![],
right_widgets: vec![],
@@ -608,6 +708,16 @@ impl Komobar {
size_rect: komorebi_client::Rect::default(),
work_area_offset: komorebi_client::Rect::default(),
applied_theme_on_first_frame: false,
mouse_follows_focus: false,
input_config: InputConfig {
accumulated_scroll_delta: Vec2::new(0.0, 0.0),
act_on_vertical_scroll: false,
act_on_horizontal_scroll: false,
vertical_scroll_threshold: 0.0,
horizontal_scroll_threshold: 0.0,
vertical_scroll_max_threshold: 0.0,
horizontal_scroll_max_threshold: 0.0,
},
};
komobar.apply_config(&cc.egui_ctx, None);
@@ -732,12 +842,12 @@ impl eframe::App for Komobar {
if self.scale_factor != ctx.native_pixels_per_point().unwrap_or(1.0) {
self.scale_factor = ctx.native_pixels_per_point().unwrap_or(1.0);
self.apply_config(ctx, self.komorebi_notification_state.clone());
self.apply_config(ctx, self.monitor_info.clone());
}
if let Ok(updated_config) = self.rx_config.try_recv() {
self.config = updated_config;
self.apply_config(ctx, self.komorebi_notification_state.clone());
self.apply_config(ctx, self.monitor_info.clone());
}
match self.rx_gui.try_recv() {
@@ -862,24 +972,26 @@ impl eframe::App for Komobar {
}
}
if let Some(komorebi_notification_state) = &self.komorebi_notification_state {
komorebi_notification_state
.borrow_mut()
.handle_notification(
ctx,
self.monitor_index,
notification,
self.bg_color.clone(),
self.bg_color_with_alpha.clone(),
self.config.transparency_alpha,
self.config.grouping,
self.config.theme.clone(),
self.render_config.clone(),
);
if let Some(monitor_info) = &self.monitor_info {
monitor_info.borrow_mut().update(
self.monitor_index,
notification.state,
self.render_config.borrow().show_all_icons,
);
handle_notification(
ctx,
notification.event,
self.bg_color.clone(),
self.bg_color_with_alpha.clone(),
self.config.transparency_alpha,
self.config.grouping,
self.config.theme.clone(),
self.render_config.clone(),
);
}
if should_apply_config {
self.apply_config(ctx, self.komorebi_notification_state.clone());
self.apply_config(ctx, self.monitor_info.clone());
// Reposition the Bar
self.position_bar();
@@ -961,6 +1073,111 @@ impl eframe::App for Komobar {
let frame = render_config.change_frame_on_bar(frame, &ctx.style());
CentralPanel::default().frame(frame).show(ctx, |ui| {
if let Some(mouse_config) = &self.config.mouse {
let command = if ui
.input(|i| i.pointer.button_double_clicked(PointerButton::Primary))
{
tracing::debug!("Input: primary button double clicked");
&mouse_config.on_primary_double_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Secondary)) {
tracing::debug!("Input: secondary button clicked");
&mouse_config.on_secondary_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Middle)) {
tracing::debug!("Input: middle button clicked");
&mouse_config.on_middle_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Extra1)) {
tracing::debug!("Input: extra1 button clicked");
&mouse_config.on_extra1_click
} else if ui.input(|i| i.pointer.button_clicked(PointerButton::Extra2)) {
tracing::debug!("Input: extra2 button clicked");
&mouse_config.on_extra2_click
} else if self.input_config.act_on_vertical_scroll
|| self.input_config.act_on_horizontal_scroll
{
let scroll_delta = ui.input(|input| input.smooth_scroll_delta);
self.input_config.accumulated_scroll_delta += scroll_delta;
if scroll_delta.y != 0.0 && self.input_config.act_on_vertical_scroll {
// Do not store more than the max threshold
self.input_config.accumulated_scroll_delta.y =
self.input_config.accumulated_scroll_delta.y.clamp(
-self.input_config.vertical_scroll_max_threshold,
self.input_config.vertical_scroll_max_threshold,
);
// When the accumulated scroll passes the threshold, trigger a tick.
if self.input_config.accumulated_scroll_delta.y.abs()
>= self.input_config.vertical_scroll_threshold
{
let direction_command =
if self.input_config.accumulated_scroll_delta.y > 0.0 {
&mouse_config.on_scroll_up
} else {
&mouse_config.on_scroll_down
};
// Remove one tick's worth of scroll from the accumulator, preserving any excess.
self.input_config.accumulated_scroll_delta.y -=
self.input_config.vertical_scroll_threshold
* self.input_config.accumulated_scroll_delta.y.signum();
tracing::debug!(
"Input: vertical scroll ticked. excess: {} | threshold: {}",
self.input_config.accumulated_scroll_delta.y,
self.input_config.vertical_scroll_threshold
);
direction_command
} else {
&None
}
} else if scroll_delta.x != 0.0 && self.input_config.act_on_horizontal_scroll {
// Do not store more than the max threshold
self.input_config.accumulated_scroll_delta.x =
self.input_config.accumulated_scroll_delta.x.clamp(
-self.input_config.horizontal_scroll_max_threshold,
self.input_config.horizontal_scroll_max_threshold,
);
// When the accumulated scroll passes the threshold, trigger a tick.
if self.input_config.accumulated_scroll_delta.x.abs()
>= self.input_config.horizontal_scroll_threshold
{
let direction_command =
if self.input_config.accumulated_scroll_delta.x > 0.0 {
&mouse_config.on_scroll_left
} else {
&mouse_config.on_scroll_right
};
// Remove one tick's worth of scroll from the accumulator, preserving any excess.
self.input_config.accumulated_scroll_delta.x -=
self.input_config.horizontal_scroll_threshold
* self.input_config.accumulated_scroll_delta.x.signum();
tracing::debug!(
"Input: horizontal scroll ticked. excess: {} | threshold: {}",
self.input_config.accumulated_scroll_delta.x,
self.input_config.horizontal_scroll_threshold
);
direction_command
} else {
&None
}
} else {
&None
}
} else {
&None
};
if let Some(command) = command {
command.execute(self.mouse_follows_focus);
}
}
// Apply grouping logic for the bar as a whole
let area_frame = if let Some(frame) = &self.config.frame {
Frame::NONE
@@ -1100,3 +1317,64 @@ pub enum Alignment {
Center,
Right,
}
#[allow(clippy::too_many_arguments)]
fn handle_notification(
ctx: &Context,
event: komorebi_client::NotificationEvent,
bg_color: Rc<RefCell<Color32>>,
bg_color_with_alpha: Rc<RefCell<Color32>>,
transparency_alpha: Option<u8>,
grouping: Option<Grouping>,
default_theme: Option<KomobarTheme>,
render_config: Rc<RefCell<RenderConfig>>,
) {
if let NotificationEvent::Socket(message) = event {
match message {
SocketMessage::ReloadStaticConfiguration(path) => {
if let Ok(config) = komorebi_client::StaticConfig::read(&path) {
if let Some(theme) = config.theme {
apply_theme(
ctx,
KomobarTheme::from(theme),
bg_color.clone(),
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!("applied theme from updated komorebi.json");
} else if let Some(default_theme) = default_theme {
apply_theme(
ctx,
default_theme,
bg_color.clone(),
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!(
"removed theme from updated komorebi.json and applied default theme"
);
} else {
tracing::warn!("theme was removed from updated komorebi.json but there was no default theme to apply");
}
}
}
SocketMessage::Theme(theme) => {
apply_theme(
ctx,
KomobarTheme::from(*theme),
bg_color,
bg_color_with_alpha.clone(),
transparency_alpha,
grouping,
render_config,
);
tracing::info!("applied theme from komorebi socket message");
}
_ => {}
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::bar::exec_powershell;
use crate::render::Grouping;
use crate::widgets::widget::WidgetConfig;
use crate::DEFAULT_PADDING;
@@ -5,7 +6,9 @@ use eframe::egui::Pos2;
use eframe::egui::TextBuffer;
use eframe::egui::Vec2;
use komorebi_client::KomorebiTheme;
use komorebi_client::PathExt;
use komorebi_client::Rect;
use komorebi_client::SocketMessage;
use serde::Deserialize;
use serde::Serialize;
use std::collections::HashMap;
@@ -13,7 +16,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.37`
/// The `komorebi.bar.json` configuration file reference for `v0.1.38`
pub struct KomobarConfig {
/// Bar height (default: 50)
pub height: Option<f32>,
@@ -90,6 +93,8 @@ pub struct KomobarConfig {
pub widget_spacing: Option<f32>,
/// Visual grouping for widgets
pub grouping: Option<Grouping>,
/// Options for mouse interaction on the bar
pub mouse: Option<MouseConfig>,
/// Left side widgets (ordered left-to-right)
pub left_widgets: Vec<WidgetConfig>,
/// Center widgets (ordered left-to-right)
@@ -325,6 +330,146 @@ pub fn get_individual_spacing(
})
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum MouseMessage {
/// Send a message to the komorebi client.
/// By default, a batch of messages are sent in the following order:
/// FocusMonitorAtCursor =>
/// MouseFollowsFocus(false) =>
/// {message} =>
/// MouseFollowsFocus({original.value})
///
/// Example:
/// ```json
/// "on_extra2_click": {
/// "message": {
/// "type": "NewWorkspace"
/// }
/// },
/// ```
/// or:
/// ```json
/// "on_middle_click": {
/// "focus_monitor_at_cursor": false,
/// "ignore_mouse_follows_focus": false,
/// "message": {
/// "type": "TogglePause"
/// }
/// }
/// ```
/// or:
/// ```json
/// "on_scroll_up": {
/// "message": {
/// "type": "CycleFocusWorkspace",
/// "content": "Previous"
/// }
/// }
/// ```
Komorebi(KomorebiMouseMessage),
/// Execute a custom command.
/// CMD (%variable%), Bash ($variable) and PowerShell ($Env:variable) variables will be resolved.
/// Example: `komorebic toggle-pause`
Command(String),
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct KomorebiMouseMessage {
/// Send the FocusMonitorAtCursor message (default:true)
pub focus_monitor_at_cursor: Option<bool>,
/// Wrap the {message} with a MouseFollowsFocus(false) and MouseFollowsFocus({original.value}) message (default:true)
pub ignore_mouse_follows_focus: Option<bool>,
/// The message to send to the komorebi client
pub message: komorebi_client::SocketMessage,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct MouseConfig {
/// Command to send on primary/left double button click
pub on_primary_double_click: Option<MouseMessage>,
/// Command to send on secondary/right button click
pub on_secondary_click: Option<MouseMessage>,
/// Command to send on middle button click
pub on_middle_click: Option<MouseMessage>,
/// Command to send on extra1/back button click
pub on_extra1_click: Option<MouseMessage>,
/// Command to send on extra2/forward button click
pub on_extra2_click: Option<MouseMessage>,
/// Defines how many points a user needs to scroll vertically to make a "tick" on a mouse/touchpad/touchscreen (default: 30)
pub vertical_scroll_threshold: Option<f32>,
/// Command to send on scrolling up (every tick)
pub on_scroll_up: Option<MouseMessage>,
/// Command to send on scrolling down (every tick)
pub on_scroll_down: Option<MouseMessage>,
/// Defines how many points a user needs to scroll horizontally to make a "tick" on a mouse/touchpad/touchscreen (default: 30)
pub horizontal_scroll_threshold: Option<f32>,
/// Command to send on scrolling left (every tick)
pub on_scroll_left: Option<MouseMessage>,
/// Command to send on scrolling right (every tick)
pub on_scroll_right: Option<MouseMessage>,
}
impl MouseConfig {
pub fn has_command(&self) -> bool {
[
&self.on_primary_double_click,
&self.on_secondary_click,
&self.on_middle_click,
&self.on_extra1_click,
&self.on_extra2_click,
&self.on_scroll_up,
&self.on_scroll_down,
&self.on_scroll_left,
&self.on_scroll_right,
]
.iter()
.any(|opt| matches!(opt, Some(MouseMessage::Command(_))))
}
}
impl MouseMessage {
pub fn execute(&self, mouse_follows_focus: bool) {
match self {
MouseMessage::Komorebi(config) => {
let mut messages = Vec::new();
if config.focus_monitor_at_cursor.unwrap_or(true) {
messages.push(SocketMessage::FocusMonitorAtCursor);
}
if config.ignore_mouse_follows_focus.unwrap_or(true) {
messages.push(SocketMessage::MouseFollowsFocus(false));
messages.push(config.message.clone());
messages.push(SocketMessage::MouseFollowsFocus(mouse_follows_focus));
} else {
messages.push(config.message.clone());
}
tracing::debug!("Sending messages: {messages:?}");
if komorebi_client::send_batch(messages.into_iter()).is_err() {
tracing::error!("could not send commands");
}
}
MouseMessage::Command(cmd) => {
tracing::debug!("Executing command: {}", cmd);
let cmd_no_env = cmd.replace_env();
if exec_powershell(cmd_no_env.to_str().expect("Invalid command")).is_err() {
tracing::error!("Failed to execute '{}'", cmd);
}
}
};
}
}
impl KomobarConfig {
pub fn read(path: &PathBuf) -> color_eyre::Result<Self> {
let content = std::fs::read_to_string(path)?;

View File

@@ -15,12 +15,10 @@ use eframe::egui::ViewportBuilder;
use font_loader::system_fonts;
use hotwatch::EventKind;
use hotwatch::Hotwatch;
use image::RgbaImage;
use komorebi_client::replace_env_in_path;
use komorebi_client::PathExt;
use komorebi_client::SocketMessage;
use komorebi_client::SubscribeOptions;
use std::collections::HashMap;
use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
@@ -28,8 +26,6 @@ use std::sync::atomic::AtomicI32;
use std::sync::atomic::AtomicU32;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::time::Duration;
use tracing_subscriber::EnvFilter;
use windows::Win32::Foundation::HWND;
@@ -53,9 +49,6 @@ pub static DEFAULT_PADDING: f32 = 10.0;
pub static AUTO_SELECT_FILL_COLOUR: AtomicU32 = AtomicU32::new(0);
pub static AUTO_SELECT_TEXT_COLOUR: AtomicU32 = AtomicU32::new(0);
pub static ICON_CACHE: LazyLock<Mutex<HashMap<isize, RgbaImage>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Parser)]
#[clap(author, about, version)]
struct Opts {
@@ -166,8 +159,7 @@ fn main() -> color_eyre::Result<()> {
assert!(
home.is_dir(),
"$Env:KOMOREBI_CONFIG_HOME is set to '{}', which is not a valid directory",
home_path
"$Env:KOMOREBI_CONFIG_HOME is set to '{home_path}', which is not a valid directory"
);
home

View File

@@ -1,4 +1,4 @@
use super::komorebi::img_to_texture;
use super::ImageIcon;
use crate::render::RenderConfig;
use crate::selected_frame::SelectableFrame;
use crate::widgets::widget::BarWidget;
@@ -17,14 +17,13 @@ use eframe::egui::Stroke;
use eframe::egui::StrokeKind;
use eframe::egui::Ui;
use eframe::egui::Vec2;
use image::DynamicImage;
use image::RgbaImage;
use komorebi_client::PathExt;
use serde::Deserialize;
use serde::Serialize;
use std::borrow::Cow;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tracing;
@@ -119,42 +118,32 @@ impl BarWidget for Applications {
impl From<&ApplicationsConfig> for Applications {
fn from(applications_config: &ApplicationsConfig) -> Self {
// Allow immediate launch by initializing last_launch in the past.
let last_launch = Instant::now() - 2 * MIN_LAUNCH_INTERVAL;
let mut applications_config = applications_config.clone();
let items = applications_config
.items
.iter_mut()
.iter()
.enumerate()
.map(|(index, app_config)| {
app_config.command = app_config
.command
.replace_env()
.to_string_lossy()
.to_string();
if let Some(icon) = &mut app_config.icon {
*icon = icon.replace_env().to_string_lossy().to_string();
}
.map(|(index, config)| {
let command = UserCommand::new(&config.command);
App {
enable: app_config.enable.unwrap_or(applications_config.enable),
name: app_config
enable: config.enable.unwrap_or(applications_config.enable),
#[allow(clippy::obfuscated_if_else)]
name: config
.name
.is_empty()
.then(|| format!("App {}", index + 1))
.unwrap_or_else(|| app_config.name.clone()),
icon: Icon::try_from(app_config),
command: app_config.command.clone(),
display: app_config
.unwrap_or_else(|| config.name.clone()),
icon: Icon::try_from_path(config.icon.as_deref())
.or_else(|| Icon::try_from_command(&command)),
command,
display: config
.display
.or(applications_config.display)
.unwrap_or_default(),
show_command_on_hover: app_config
show_command_on_hover: config
.show_command_on_hover
.or(applications_config.show_command_on_hover)
.unwrap_or(false),
last_launch,
}
})
.collect();
@@ -177,13 +166,11 @@ pub struct App {
/// Icon to display for this application, if available.
pub icon: Option<Icon>,
/// Command to execute when the application is launched.
pub command: String,
pub command: UserCommand,
/// Display format (icon, text, or both).
pub display: DisplayFormat,
/// Whether to show the launch command on hover.
pub show_command_on_hover: bool,
/// Last time this application was launched (used for cooldown control).
pub last_launch: Instant,
}
impl App {
@@ -205,17 +192,15 @@ impl App {
}
// Add hover text with command information
let response = ui.response();
if self.show_command_on_hover {
ui.response()
.on_hover_text(format!("Launch: {}", self.command));
} else {
ui.response();
response.on_hover_text(format!("Launch: {}", self.command.as_ref()));
}
})
.clicked()
{
// Launch the application when clicked
self.launch_if_ready();
self.command.launch_if_ready();
}
}
@@ -235,84 +220,75 @@ impl App {
fn draw_name(&self, ui: &mut Ui) {
ui.add(Label::new(&self.name).selectable(false));
}
/// Attempts to launch the specified command in a separate thread if enough time has passed
/// since the last launch. This prevents repeated launches from rapid consecutive clicks.
///
/// Errors during launch are logged using the `tracing` crate.
pub fn launch_if_ready(&mut self) {
let now = Instant::now();
if now.duration_since(self.last_launch) < MIN_LAUNCH_INTERVAL {
return;
}
self.last_launch = now;
let command_string = self.command.clone();
// Launch the application in a separate thread to avoid blocking the UI
std::thread::spawn(move || {
if let Err(e) = Command::new("cmd").args(["/C", &command_string]).spawn() {
tracing::error!("Failed to launch command '{}': {}", command_string, e);
}
});
}
}
/// Holds decoded image data to be used as an icon in the UI.
/// Holds image/text data to be used as an icon in the UI.
/// This represents source icon data before rendering.
#[derive(Clone, Debug)]
pub enum Icon {
/// RGBA image used for rendering the icon.
Image(RgbaImage),
Image(ImageIcon),
/// Text-based icon, e.g. from a font like Nerd Fonts.
Text(String),
}
impl Icon {
/// Attempts to create an `Icon` from the given `AppConfig`.
/// Loads the image from a specified icon path or extracts it from the application's
/// executable if the command points to a valid executable file.
/// Attempts to create an [`Icon`] from a string path or text glyph/glyphs.
///
/// - Environment variables in the path are resolved using [`PathExt::replace_env`].
/// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved path.
/// - If the path is invalid but the string is non-empty, it is interpreted as a text-based icon and
/// returned as [`Icon::Text`].
/// - Returns `None` if the input is empty, `None`, or image loading fails.
#[inline]
pub fn try_from(config: &AppConfig) -> Option<Self> {
if let Some(icon) = config.icon.as_deref().map(str::trim) {
if !icon.is_empty() {
let path = Path::new(&icon);
if path.is_file() {
match image::open(path).as_ref().map(DynamicImage::to_rgba8) {
Ok(image) => return Some(Icon::Image(image)),
Err(err) => {
tracing::error!("Failed to load icon from {}, error: {}", icon, err)
}
}
} else {
return Some(Icon::Text(icon.to_owned()));
}
pub fn try_from_path(icon: Option<&str>) -> Option<Self> {
let icon = icon.map(str::trim)?;
if icon.is_empty() {
return None;
}
let path = icon.replace_env();
if !path.is_file() {
return Some(Icon::Text(icon.to_owned()));
}
let image_icon = ImageIcon::try_load(path.as_ref(), || match image::open(&path) {
Ok(img) => Some(img),
Err(err) => {
tracing::error!("Failed to load icon from {:?}, error: {}", path, err);
None
}
}
})?;
let binary = PathBuf::from(config.command.split(".exe").next()?);
let path = if binary.is_file() {
Some(binary)
} else {
which(binary).ok()
};
match path {
Some(path) => windows_icons::get_icon_by_path(&path.to_string_lossy())
.or_else(|| windows_icons_fallback::get_icon_by_path(&path.to_string_lossy()))
.map(Icon::Image),
None => None,
}
Some(Icon::Image(image_icon))
}
/// Renders the icon in the given `Ui` context with the specified size.
/// Attempts to create an [`Icon`] by extracting an image from the executable path of a [`UserCommand`].
///
/// - Uses [`ImageIcon::try_load`] to load and cache the icon image based on the resolved executable path.
/// - Returns [`Icon::Image`] if an icon is successfully extracted.
/// - Returns `None` if the executable path is unavailable or icon extraction fails.
#[inline]
pub fn try_from_command(command: &UserCommand) -> Option<Self> {
let path = command.get_executable()?;
let image_icon = ImageIcon::try_load(path.as_ref(), || {
let path_str = path.to_str()?;
windows_icons::get_icon_by_path(path_str)
.or_else(|| windows_icons_fallback::get_icon_by_path(path_str))
})?;
Some(Icon::Image(image_icon))
}
/// Renders the icon in the given [`Ui`] using the provided [`IconConfig`].
#[inline]
pub fn draw(&self, ctx: &Context, ui: &mut Ui, icon_config: &IconConfig) {
match self {
Icon::Image(image) => {
Icon::Image(image_icon) => {
Frame::NONE
.inner_margin(Margin::same(ui.style().spacing.button_padding.y as i8))
.show(ui, |ui| {
ui.add(
Image::from(&img_to_texture(ctx, image))
Image::from_texture(&image_icon.texture(ctx))
.maintain_aspect_ratio(true)
.fit_to_exact_size(Vec2::splat(icon_config.size)),
);
@@ -354,3 +330,77 @@ pub struct IconConfig {
/// Color of the icon used for text-based icons
pub color: Color32,
}
/// A structure to manage command execution with cooldown prevention.
#[derive(Clone, Debug)]
pub struct UserCommand {
/// The command string to execute
pub command: Arc<str>,
/// Last time this command was executed (used for cooldown control)
pub last_launch: Instant,
}
impl AsRef<str> for UserCommand {
#[inline]
fn as_ref(&self) -> &str {
&self.command
}
}
impl UserCommand {
/// Creates a new [`UserCommand`] with environment variables in the command path
/// resolved using [`PathExt::replace_env`].
#[inline]
pub fn new(command: &str) -> Self {
// Allow immediate launch by initializing last_launch in the past
let last_launch = Instant::now() - 2 * MIN_LAUNCH_INTERVAL;
Self {
command: Arc::from(command.replace_env().to_str().unwrap_or_default()),
last_launch,
}
}
/// Attempts to resolve the executable path from the command string.
///
/// Resolution logic:
/// - Splits the command by ".exe" and checks if the first part is an existing file.
/// - If not, attempts to locate the binary using [`which`] on this name.
/// - If still unresolved, takes the first word (separated by whitespace) and attempts
/// to find it in the system `PATH` using [`which`].
///
/// Returns `None` if no executable path can be determined.
#[inline]
pub fn get_executable(&self) -> Option<Cow<'_, Path>> {
if let Some(binary) = self.command.split(".exe").next().map(Path::new) {
if binary.is_file() {
return Some(Cow::Borrowed(binary));
} else if let Ok(binary) = which(binary) {
return Some(Cow::Owned(binary));
}
}
which(self.command.split(' ').next()?).ok().map(Cow::Owned)
}
/// Attempts to launch the specified command in a separate thread if enough time has passed
/// since the last launch. This prevents repeated launches from rapid consecutive clicks.
///
/// Errors during launch are logged using the `tracing` crate.
pub fn launch_if_ready(&mut self) {
let now = Instant::now();
// Check if enough time has passed since the last launch
if now.duration_since(self.last_launch) < MIN_LAUNCH_INTERVAL {
return;
}
self.last_launch = now;
let command_string = self.command.clone();
// Launch the application in a separate thread to avoid blocking the UI
std::thread::spawn(move || {
if let Err(e) = Command::new("cmd").args(["/C", &command_string]).spawn() {
tracing::error!("Failed to launch command '{}': {}", command_string, e);
}
});
}
}

View File

@@ -181,7 +181,7 @@ impl BarWidget for Battery {
.args(["/C", "start", "ms-settings:batterysaver"])
.spawn()
{
eprintln!("{}", error)
eprintln!("{error}")
}
}
});

View File

@@ -76,8 +76,8 @@ impl Cpu {
CpuOutput {
label: match self.label_prefix {
LabelPrefix::Text | LabelPrefix::IconAndText => format!("CPU: {}%", used),
LabelPrefix::None | LabelPrefix::Icon => format!("{}%", used),
LabelPrefix::Text | LabelPrefix::IconAndText => format!("CPU: {used}%"),
LabelPrefix::None | LabelPrefix::Icon => format!("{used}%"),
},
selected,
}
@@ -124,7 +124,7 @@ impl BarWidget for Cpu {
if let Err(error) =
Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{
eprintln!("{}", error)
eprintln!("{error}")
}
}
});

View File

@@ -166,7 +166,7 @@ impl Date {
.to_string()
.trim()
.to_string(),
Err(_) => format!("Invalid timezone: {}", timezone),
Err(_) => format!("Invalid timezone: {timezone}"),
},
None => Local::now()
.format(&self.format.fmt_string())

File diff suppressed because it is too large Load Diff

View File

@@ -41,8 +41,7 @@ impl<'de> Deserialize<'de> for KomorebiLayout {
let s: String = String::deserialize(deserializer)?;
// Attempt to deserialize the string as a DefaultLayout
if let Ok(default_layout) =
from_str::<komorebi_client::DefaultLayout>(&format!("\"{}\"", s))
if let Ok(default_layout) = from_str::<komorebi_client::DefaultLayout>(&format!("\"{s}\""))
{
return Ok(KomorebiLayout::Default(default_layout));
}
@@ -53,7 +52,7 @@ impl<'de> Deserialize<'de> for KomorebiLayout {
"Floating" => Ok(KomorebiLayout::Floating),
"Paused" => Ok(KomorebiLayout::Paused),
"Custom" => Ok(KomorebiLayout::Custom),
_ => Err(Error::custom(format!("Invalid layout: {}", s))),
_ => Err(Error::custom(format!("Invalid layout: {s}"))),
}
}
}
@@ -188,6 +187,12 @@ impl KomorebiLayout {
painter.line_segment([c - vec2(r, 0.0), c + vec2(r, 0.0)], stroke);
painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke);
}
// TODO: @CtByte can you think of a nice icon to draw here?
komorebi_client::DefaultLayout::Scrolling => {
painter.line_segment([c - vec2(r / 2.0, r), c + vec2(-r / 2.0, r)], stroke);
painter.line_segment([c - vec2(0.0, r), c + vec2(0.0, r)], stroke);
painter.line_segment([c - vec2(-r / 2.0, r), c + vec2(r / 2.0, r)], stroke);
}
},
KomorebiLayout::Monocle => {}
KomorebiLayout::Floating => {

View File

@@ -79,9 +79,9 @@ impl Memory {
MemoryOutput {
label: match self.label_prefix {
LabelPrefix::Text | LabelPrefix::IconAndText => {
format!("RAM: {}%", usage)
format!("RAM: {usage}%")
}
LabelPrefix::None | LabelPrefix::Icon => format!("{}%", usage),
LabelPrefix::None | LabelPrefix::Icon => format!("{usage}%"),
},
selected,
}
@@ -128,7 +128,7 @@ impl BarWidget for Memory {
if let Err(error) =
Command::new("cmd.exe").args(["/C", "taskmgr.exe"]).spawn()
{
eprintln!("{}", error)
eprintln!("{error}")
}
}
});

View File

@@ -1,3 +1,14 @@
use eframe::egui::ColorImage;
use eframe::egui::Context;
use eframe::egui::TextureHandle;
use eframe::egui::TextureOptions;
use image::RgbaImage;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::LazyLock;
use std::sync::RwLock;
pub mod applications;
pub mod battery;
pub mod cpu;
@@ -12,3 +23,151 @@ pub mod storage;
pub mod time;
pub mod update;
pub mod widget;
/// Global cache for icon images and their associated GPU textures.
pub static ICONS_CACHE: IconsCache = IconsCache::new();
/// In-memory cache for icon images and their associated GPU textures.
///
/// Stores raw [`ColorImage`]s and [`TextureHandle`]s keyed by [`ImageIconId`].
/// Texture entries are context-dependent and automatically invalidated when the [`Context`] changes.
#[allow(clippy::type_complexity)]
pub struct IconsCache {
textures: LazyLock<RwLock<(Option<Context>, HashMap<ImageIconId, TextureHandle>)>>,
images: LazyLock<RwLock<HashMap<ImageIconId, Arc<ColorImage>>>>,
}
impl IconsCache {
/// Creates a new empty IconsCache instance.
#[inline]
pub const fn new() -> Self {
Self {
textures: LazyLock::new(|| RwLock::new((None, HashMap::new()))),
images: LazyLock::new(|| RwLock::new(HashMap::new())),
}
}
/// Retrieves or creates a texture handle for the given icon ID and image.
///
/// If a texture for the given ID already exists for the current [`Context`], it is reused.
/// Otherwise, a new texture is created, inserted into the cache, and returned.
/// The cache is reset if the [`Context`] has changed.
#[inline]
pub fn texture(&self, ctx: &Context, id: &ImageIconId, img: &Arc<ColorImage>) -> TextureHandle {
if let Some(texture) = self.get_texture(ctx, id) {
return texture;
}
let texture_handle = ctx.load_texture("icon", img.clone(), TextureOptions::default());
self.insert_texture(ctx, id.clone(), texture_handle.clone());
texture_handle
}
/// Returns the cached texture for the given icon ID if it exists and matches the current [`Context`].
pub fn get_texture(&self, ctx: &Context, id: &ImageIconId) -> Option<TextureHandle> {
let textures_lock = self.textures.read().unwrap();
if textures_lock.0.as_ref() == Some(ctx) {
return textures_lock.1.get(id).cloned();
}
None
}
/// Inserts a texture handle, resetting the cache if the [`Context`] has changed.
pub fn insert_texture(&self, ctx: &Context, id: ImageIconId, texture: TextureHandle) {
let mut textures_lock = self.textures.write().unwrap();
if textures_lock.0.as_ref() != Some(ctx) {
textures_lock.0 = Some(ctx.clone());
textures_lock.1.clear();
}
textures_lock.1.insert(id, texture);
}
/// Returns the cached image for the given icon ID, if available.
pub fn get_image(&self, id: &ImageIconId) -> Option<Arc<ColorImage>> {
self.images.read().unwrap().get(id).cloned()
}
/// Caches a raw [`ColorImage`] associated with the given icon ID.
pub fn insert_image(&self, id: ImageIconId, image: Arc<ColorImage>) {
self.images.write().unwrap().insert(id, image);
}
}
#[inline]
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())
}
/// Represents an image-based icon with a unique ID and pixel data.
#[derive(Clone, Debug)]
pub struct ImageIcon {
/// Unique identifier for the image icon, used for texture caching.
pub id: ImageIconId,
/// Shared pixel data of the icon in `ColorImage` format.
pub image: Arc<ColorImage>,
}
impl ImageIcon {
/// Creates a new [`ImageIcon`] from the given ID and image data.
#[inline]
pub fn new(id: ImageIconId, image: Arc<ColorImage>) -> Self {
Self { id, image }
}
/// Loads an [`ImageIcon`] from [`ICONS_CACHE`] or calls `loader` if not cached.
/// The loaded image is converted to a [`ColorImage`], cached, and returned.
#[inline]
pub fn try_load<F, I>(id: impl Into<ImageIconId>, loader: F) -> Option<Self>
where
F: FnOnce() -> Option<I>,
I: Into<RgbaImage>,
{
let id = id.into();
let image = ICONS_CACHE.get_image(&id).or_else(|| {
let img = loader()?;
let img = Arc::new(rgba_to_color_image(&img.into()));
ICONS_CACHE.insert_image(id.clone(), img.clone());
Some(img)
})?;
Some(ImageIcon::new(id, image))
}
/// Returns a texture handle for the icon, using the given [`Context`].
///
/// If the texture is already cached in [`ICONS_CACHE`], it is reused.
/// Otherwise, a new texture is created from the [`ColorImage`] and cached.
#[inline]
pub fn texture(&self, ctx: &Context) -> TextureHandle {
ICONS_CACHE.texture(ctx, &self.id, &self.image)
}
}
/// Unique identifier for an image-based icon.
///
/// Used to distinguish cached images and textures by either a file path
/// or a Windows window handle.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub enum ImageIconId {
/// Identifier based on a file system path.
Path(Arc<Path>),
/// Windows HWND handle.
Hwnd(isize),
}
impl From<&Path> for ImageIconId {
#[inline]
fn from(value: &Path) -> Self {
Self::Path(value.into())
}
}
impl From<isize> for ImageIconId {
#[inline]
fn from(value: isize) -> Self {
Self::Hwnd(value)
}
}

View File

@@ -314,7 +314,7 @@ impl Network {
.clicked()
{
if let Err(error) = Command::new("cmd.exe").args(["/C", "ncpa"]).spawn() {
eprintln!("{}", error);
eprintln!("{error}");
}
}
}
@@ -535,6 +535,6 @@ enum DataUnit {
impl fmt::Display for DataUnit {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self)
write!(f, "{self:?}")
}
}

View File

@@ -25,6 +25,10 @@ pub struct StorageConfig {
pub data_refresh_interval: Option<u64>,
/// Display label prefix
pub label_prefix: Option<LabelPrefix>,
/// Show disks that are read only. (default: false)
pub show_read_only_disks: Option<bool>,
/// Show removable disks. (default: true)
pub show_removable_disks: Option<bool>,
/// 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]]
@@ -38,6 +42,8 @@ impl From<StorageConfig> for Storage {
disks: Disks::new_with_refreshed_list(),
data_refresh_interval: value.data_refresh_interval.unwrap_or(10),
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),
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 +61,8 @@ pub struct Storage {
disks: Disks,
data_refresh_interval: u64,
label_prefix: LabelPrefix,
show_read_only_disks: bool,
show_removable_disks: bool,
auto_select_over: Option<u8>,
auto_hide_under: Option<u8>,
last_updated: Instant,
@@ -71,6 +79,12 @@ impl Storage {
let mut disks = vec![];
for disk in &self.disks {
if disk.is_read_only() && !self.show_read_only_disks {
continue;
}
if disk.is_removable() && !self.show_removable_disks {
continue;
}
let mount = disk.mount_point();
let total = disk.total_space();
let available = disk.available_space();
@@ -87,7 +101,7 @@ impl Storage {
LabelPrefix::Text | LabelPrefix::IconAndText => {
format!("{} {}%", mount.to_string_lossy(), percentage)
}
LabelPrefix::None | LabelPrefix::Icon => format!("{}%", percentage),
LabelPrefix::None | LabelPrefix::Icon => format!("{percentage}%"),
},
selected,
})
@@ -151,7 +165,7 @@ impl BarWidget for Storage {
])
.spawn()
{
eprintln!("{}", error)
eprintln!("{error}")
}
}
});

View File

@@ -209,7 +209,7 @@ impl Time {
Some(dt.time()),
)
}
Err(_) => (format!("Invalid timezone: {:?}", timezone), None),
Err(_) => (format!("Invalid timezone: {timezone:?}"), None),
},
None => {
let dt = Local::now();

View File

@@ -148,7 +148,7 @@ impl BarWidget for Update {
)])
.spawn()
{
eprintln!("{}", error)
eprintln!("{error}")
}
}
});

View File

@@ -1,16 +1,16 @@
[package]
name = "komorebi-client"
version = "0.1.37"
version = "0.1.38"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi = { path = "../komorebi" }
komorebi = { path = "../komorebi", default-features = false }
uds_windows = { workspace = true }
serde_json = { workspace = true }
[features]
default = ["schemars"]
schemars = ["komorebi/schemars"]
schemars = ["komorebi/default"]

View File

@@ -77,6 +77,7 @@ pub use komorebi::WorkspaceConfig;
use komorebi::DATA_DIR;
use std::borrow::Borrow;
use std::io::BufReader;
use std::io::Read;
use std::io::Write;
@@ -94,12 +95,15 @@ pub fn send_message(message: &SocketMessage) -> std::io::Result<()> {
stream.write_all(serde_json::to_string(message)?.as_bytes())
}
pub fn send_batch(messages: impl IntoIterator<Item = SocketMessage>) -> std::io::Result<()> {
pub fn send_batch<Q>(messages: impl IntoIterator<Item = Q>) -> std::io::Result<()>
where
Q: Borrow<SocketMessage>,
{
let socket = DATA_DIR.join(KOMOREBI);
let mut stream = UnixStream::connect(socket)?;
stream.set_write_timeout(Some(Duration::from_secs(1)))?;
let msgs = messages.into_iter().fold(String::new(), |mut s, m| {
if let Ok(m_str) = serde_json::to_string(&m) {
if let Ok(m_str) = serde_json::to_string(m.borrow()) {
s.push_str(&m_str);
s.push('\n');
}

View File

@@ -1,12 +1,12 @@
[package]
name = "komorebi-gui"
version = "0.1.37"
version = "0.1.38"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-client = { path = "../komorebi-client" }
komorebi-client = { path = "../komorebi-client", default-features = false }
eframe = { workspace = true }
egui_extras = { workspace = true }

View File

@@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024"
[dependencies]
whkd-parser = { git = "https://github.com/LGUG2Z/whkd", rev = "29df24ff2dd715655b0366bd2a598837c699a8e9" }
whkd-core = { git = "https://github.com/LGUG2Z/whkd", rev = "29df24ff2dd715655b0366bd2a598837c699a8e9" }
whkd-parser = { git = "https://github.com/LGUG2Z/whkd", rev = "v0.2.9" }
whkd-core = { git = "https://github.com/LGUG2Z/whkd", rev = "v0.2.9" }
eframe = { workspace = true }
dirs = { workspace = true }

View File

@@ -1,6 +1,5 @@
use eframe::egui::ViewportBuilder;
use std::path::PathBuf;
use whkd_core::HotkeyBinding;
use whkd_core::Whkdrc;
#[derive(Default)]
@@ -58,21 +57,18 @@ impl eframe::App for Quicklook {
ui.label("Filter");
ui.add(
eframe::egui::text_edit::TextEdit::singleline(&mut self.filter)
.hint_text("Filter by command...")
.background_color(ctx.style().visuals.faint_bg_color),
);
ui.end_row();
ui.end_row();
for binding in &whkdrc.bindings {
if is_komorebic_binding(binding) {
let keys = binding.keys.join(" + ");
if self.filter.is_empty()
|| binding.command.contains(&self.filter)
{
ui.label(keys);
ui.label(&binding.command);
ui.end_row();
}
let keys = binding.keys.join(" + ");
if self.filter.is_empty() || binding.command.contains(&self.filter)
{
ui.label(keys);
ui.label(&binding.command);
ui.end_row();
}
}
}
@@ -100,7 +96,3 @@ fn main() {
)
.unwrap();
}
fn is_komorebic_binding(binding: &HotkeyBinding) -> bool {
binding.command.starts_with("komorebic")
}

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi-themes"
version = "0.1.37"
version = "0.1.38"
edition = "2021"
[dependencies]

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebi"
version = "0.1.37"
version = "0.1.38"
description = "A tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2021"
@@ -17,7 +17,6 @@ crossbeam-channel = { workspace = true }
crossbeam-utils = { workspace = true }
ctrlc = { version = "3", features = ["termination"] }
dirs = { workspace = true }
dunce = { workspace = true }
getset = "0.1"
hotwatch = { workspace = true }
lazy_static = { workspace = true }
@@ -25,7 +24,7 @@ miow = "0.6"
nanoid = "0.4"
net2 = "0.2"
os_info = "3.10"
parking_lot = "0.12"
parking_lot = { workspace = true }
paste = { workspace = true }
powershell_script = "1.0"
regex = "1"

View File

@@ -17,5 +17,5 @@ pub enum AnimationPrefix {
}
pub fn new_animation_key(prefix: AnimationPrefix, key: String) -> String {
format!("{}:{}", prefix, key)
format!("{prefix}:{key}")
}

View File

@@ -392,7 +392,7 @@ impl Border {
tracing::error!("failed to update border position {error}");
}
if !rect.is_same_size_as(&old_rect) {
if !rect.is_same_size_as(&old_rect) || !rect.has_same_position_as(&old_rect) {
if let Some(render_target) = (*border_pointer).render_target.as_ref() {
let border_width = (*border_pointer).width;
let border_offset = (*border_pointer).offset;

View File

@@ -6,6 +6,7 @@ use crate::core::BorderStyle;
use crate::core::WindowKind;
use crate::ring::Ring;
use crate::windows_api;
use crate::workspace::Workspace;
use crate::workspace::WorkspaceLayer;
use crate::WindowManager;
use crate::WindowsApi;
@@ -212,6 +213,8 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
[focused_workspace_idx]
.layer();
let foreground_window = WindowsApi::foreground_window().unwrap_or_default();
let layer_changed = previous_layer != workspace_layer;
let forced_update = matches!(notification, Notification::ForceUpdate);
drop(state);
@@ -234,6 +237,17 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
.unwrap_or_default()
.set_accent(window_kind_colour(window_kind))?;
if ws.layer() == &WorkspaceLayer::Floating {
for window in ws.floating_windows() {
let mut window_kind = WindowKind::Unfocused;
if foreground_window == window.hwnd {
window_kind = WindowKind::Floating;
}
window.set_accent(window_kind_colour(window_kind))?;
}
}
continue 'monitors;
}
@@ -241,7 +255,7 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
let window_kind = if idx != ws.focused_container_idx()
|| monitor_idx != focused_monitor_idx
{
if ws.locked_containers().contains(&idx) {
if c.locked() {
WindowKind::UnfocusedLocked
} else {
WindowKind::Unfocused
@@ -342,7 +356,7 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
};
if !should_process_notification {
tracing::trace!("monitor state matches latest snapshot, skipping notification");
tracing::debug!("monitor state matches latest snapshot, skipping notification");
continue 'receiver;
}
@@ -448,14 +462,40 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
windows_borders.insert(focused_window_hwnd, id);
let border_hwnd = border.hwnd;
// Remove all borders on this monitor except monocle
remove_borders(
&mut borders,
&mut windows_borders,
monitor_idx,
|_, b| border_hwnd != b.hwnd,
)?;
if ws.layer() == &WorkspaceLayer::Floating {
handle_floating_borders(
&mut borders,
&mut windows_borders,
ws,
monitor_idx,
foreground_window,
layer_changed,
forced_update,
)?;
// Remove all borders on this monitor except monocle and floating borders
remove_borders(
&mut borders,
&mut windows_borders,
monitor_idx,
|_, b| {
border_hwnd != b.hwnd
&& !ws
.floating_windows()
.iter()
.any(|w| w.hwnd == b.tracking_hwnd)
},
)?;
} else {
// Remove all borders on this monitor except monocle
remove_borders(
&mut borders,
&mut windows_borders,
monitor_idx,
|_, b| border_hwnd != b.hwnd,
)?;
}
continue 'monitors;
}
@@ -523,7 +563,7 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
|| monitor_idx != focused_monitor_idx
|| focused_window_hwnd != foreground_window
{
if ws.locked_containers().contains(&idx) {
if c.locked() {
WindowKind::UnfocusedLocked
} else {
WindowKind::Unfocused
@@ -569,9 +609,6 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
};
border.window_rect = rect;
let layer_changed = previous_layer != workspace_layer;
let forced_update = matches!(notification, Notification::ForceUpdate);
let should_invalidate = new_border
|| (last_focus_state != new_focus_state)
|| layer_changed
@@ -591,65 +628,15 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
windows_borders.insert(focused_window_hwnd, id);
}
{
for window in ws.floating_windows() {
let mut new_border = false;
let id = window.hwnd.to_string();
let border = match borders.entry(id.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
if let Ok(border) = Border::create(
&window.hwnd.to_string(),
window.hwnd,
monitor_idx,
) {
new_border = true;
entry.insert(border)
} else {
continue 'monitors;
}
}
};
let last_focus_state = border.window_kind;
let new_focus_state = if foreground_window == window.hwnd {
WindowKind::Floating
} else {
WindowKind::Unfocused
};
border.window_kind = new_focus_state;
// Update the border's monitor idx in case it changed
border.monitor_idx = Some(monitor_idx);
let rect = WindowsApi::window_rect(window.hwnd)?;
border.window_rect = rect;
let layer_changed = previous_layer != workspace_layer;
let forced_update =
matches!(notification, Notification::ForceUpdate);
let should_invalidate = new_border
|| (last_focus_state != new_focus_state)
|| layer_changed
|| forced_update;
if should_invalidate {
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()?;
}
border.set_position(&rect, window.hwnd)?;
border.invalidate();
}
windows_borders.insert(window.hwnd, id);
}
}
handle_floating_borders(
&mut borders,
&mut windows_borders,
ws,
monitor_idx,
foreground_window,
layer_changed,
forced_update,
)?;
}
}
}
@@ -665,6 +652,68 @@ pub fn handle_notifications(wm: Arc<Mutex<WindowManager>>) -> color_eyre::Result
Ok(())
}
fn handle_floating_borders(
borders: &mut HashMap<String, Box<Border>>,
windows_borders: &mut HashMap<isize, String>,
ws: &Workspace,
monitor_idx: usize,
foreground_window: isize,
layer_changed: bool,
forced_update: bool,
) -> color_eyre::Result<()> {
for window in ws.floating_windows() {
let mut new_border = false;
let id = window.hwnd.to_string();
let border = match borders.entry(id.clone()) {
Entry::Occupied(entry) => entry.into_mut(),
Entry::Vacant(entry) => {
if let Ok(border) =
Border::create(&window.hwnd.to_string(), window.hwnd, monitor_idx)
{
new_border = true;
entry.insert(border)
} else {
return Ok(());
}
}
};
let last_focus_state = border.window_kind;
let new_focus_state = if foreground_window == window.hwnd {
WindowKind::Floating
} else {
WindowKind::Unfocused
};
border.window_kind = new_focus_state;
// Update the border's monitor idx in case it changed
border.monitor_idx = Some(monitor_idx);
let rect = WindowsApi::window_rect(window.hwnd)?;
border.window_rect = rect;
let should_invalidate =
new_border || (last_focus_state != new_focus_state) || layer_changed || forced_update;
if should_invalidate {
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()?;
}
border.set_position(&rect, window.hwnd)?;
border.invalidate();
}
windows_borders.insert(window.hwnd, id);
}
Ok(())
}
/// Removes all borders from monitor with index `monitor_idx` filtered by
/// `condition`. This condition is a function that will take a reference to
/// the container id and the border and returns a bool, if true that border

View File

@@ -1,18 +1,24 @@
use std::collections::VecDeque;
use getset::CopyGetters;
use getset::Getters;
use getset::Setters;
use nanoid::nanoid;
use serde::Deserialize;
use serde::Serialize;
use crate::ring::Ring;
use crate::window::Window;
use crate::Lockable;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Getters)]
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Getters, CopyGetters, Setters)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Container {
#[getset(get = "pub")]
id: String,
#[serde(default)]
#[getset(get_copy = "pub", set = "pub")]
locked: bool,
windows: Ring<Window>,
}
@@ -22,11 +28,23 @@ impl Default for Container {
fn default() -> Self {
Self {
id: nanoid!(),
locked: false,
windows: Ring::default(),
}
}
}
impl Lockable for Container {
fn locked(&self) -> bool {
self.locked
}
fn set_locked(&mut self, locked: bool) -> &mut Self {
self.locked = locked;
self
}
}
impl Container {
pub fn hide(&self, omit: Option<isize>) {
for window in self.windows().iter().rev() {
@@ -144,6 +162,7 @@ impl Container {
#[cfg(test)]
mod tests {
use super::*;
use serde_json;
#[test]
fn test_contains_window() {
@@ -250,4 +269,40 @@ mod tests {
// Should return None since window 4 doesn't exist
assert_eq!(container.idx_for_window(4), None);
}
#[test]
fn deserializes_with_missing_locked_field_defaults_to_false() {
let json = r#"{
"id": "test-1",
"windows": { "elements": [], "focused": 0 }
}"#;
let container: Container = serde_json::from_str(json).expect("Should deserialize");
assert!(!container.locked());
assert_eq!(container.id(), "test-1");
assert!(container.windows().is_empty());
let json = r#"{
"id": "test-2",
"windows": { "elements": [ { "hwnd": 5 }, { "hwnd": 9 } ], "focused": 1 }
}"#;
let container: Container = serde_json::from_str(json).unwrap();
assert_eq!(container.id(), "test-2");
assert!(!container.locked());
assert_eq!(container.windows(), &[Window::from(5), Window::from(9)]);
assert_eq!(container.focused_window_idx(), 1);
}
#[test]
fn serializes_and_deserializes() {
let mut container = Container::default();
container.set_locked(true);
let serialized = serde_json::to_string(&container).expect("Should serialize");
let deserialized: Container =
serde_json::from_str(&serialized).expect("Should deserialize");
assert!(deserialized.locked());
assert_eq!(deserialized.id(), container.id());
}
}

View File

@@ -12,8 +12,10 @@ use super::custom_layout::ColumnSplitWithCapacity;
use super::CustomLayout;
use super::DefaultLayout;
use super::Rect;
use crate::default_layout::LayoutOptions;
pub trait Arrangement {
#[allow(clippy::too_many_arguments)]
fn calculate(
&self,
area: &Rect,
@@ -21,6 +23,9 @@ pub trait Arrangement {
container_padding: Option<i32>,
layout_flip: Option<Axis>,
resize_dimensions: &[Option<Rect>],
focused_idx: usize,
layout_options: Option<LayoutOptions>,
latest_layout: &[Rect],
) -> Vec<Rect>;
}
@@ -33,9 +38,110 @@ impl Arrangement for DefaultLayout {
container_padding: Option<i32>,
layout_flip: Option<Axis>,
resize_dimensions: &[Option<Rect>],
focused_idx: usize,
layout_options: Option<LayoutOptions>,
latest_layout: &[Rect],
) -> Vec<Rect> {
let len = usize::from(len);
let mut dimensions = match self {
Self::Scrolling => {
let column_count = layout_options
.and_then(|o| o.scrolling.map(|s| s.columns))
.unwrap_or(3);
let column_width = area.right / column_count as i32;
let mut layouts = Vec::with_capacity(len);
match len {
// treat < 3 windows the same as the columns layout
len if len < 3 => {
layouts = columns(area, len);
let adjustment = calculate_columns_adjustment(resize_dimensions);
layouts.iter_mut().zip(adjustment.iter()).for_each(
|(layout, adjustment)| {
layout.top += adjustment.top;
layout.bottom += adjustment.bottom;
layout.left += adjustment.left;
layout.right += adjustment.right;
},
);
if matches!(
layout_flip,
Some(Axis::Horizontal | Axis::HorizontalAndVertical)
) {
if let 2.. = len {
columns_reverse(&mut layouts);
}
}
}
// treat >= column_count as scrolling
len => {
let visible_columns = area.right / column_width;
let first_visible: isize = if focused_idx == 0 {
// if focused idx is 0, we are at the beginning of the scrolling strip
0
} else {
let previous_first_visible = if latest_layout.is_empty() {
0
} else {
// previous first_visible based on the left position of the first visible window
let left_edge = area.left;
latest_layout
.iter()
.position(|rect| rect.left >= left_edge)
.unwrap_or(0) as isize
};
let focused_idx = focused_idx as isize;
if focused_idx < previous_first_visible {
// focused window is off the left edge, we need to scroll left
focused_idx
} else if focused_idx
>= previous_first_visible + visible_columns as isize
{
// focused window is off the right edge, we need to scroll right
// and make sure it's the last visible window
(focused_idx + 1 - visible_columns as isize).max(0)
} else {
// focused window is already visible, we don't need to scroll
previous_first_visible
}
.min(
(len as isize)
.saturating_sub(visible_columns as isize)
.max(0),
)
};
for i in 0..len {
let position = (i as isize) - first_visible;
let left = area.left + (position as i32 * column_width);
layouts.push(Rect {
left,
top: area.top,
right: column_width,
bottom: area.bottom,
});
}
let adjustment = calculate_scrolling_adjustment(resize_dimensions);
layouts.iter_mut().zip(adjustment.iter()).for_each(
|(layout, adjustment)| {
layout.top += adjustment.top;
layout.bottom += adjustment.bottom;
layout.left += adjustment.left;
layout.right += adjustment.right;
},
);
}
}
layouts
}
Self::BSP => recursive_fibonacci(
0,
len,
@@ -487,6 +593,9 @@ impl Arrangement for CustomLayout {
container_padding: Option<i32>,
_layout_flip: Option<Axis>,
_resize_dimensions: &[Option<Rect>],
_focused_idx: usize,
_layout_options: Option<LayoutOptions>,
_latest_layout: &[Rect],
) -> Vec<Rect> {
let mut dimensions = vec![];
let container_count = len.get();
@@ -541,7 +650,7 @@ impl Arrangement for CustomLayout {
};
match column {
Column::Primary(Option::Some(_)) => {
Column::Primary(Some(_)) => {
let main_column_area = if idx == 0 {
Self::main_column_area(area, primary_right, None)
} else {
@@ -1115,6 +1224,37 @@ fn calculate_ultrawide_adjustment(resize_dimensions: &[Option<Rect>]) -> Vec<Rec
result
}
fn calculate_scrolling_adjustment(resize_dimensions: &[Option<Rect>]) -> Vec<Rect> {
let len = resize_dimensions.len();
let mut result = vec![Rect::default(); len];
if len <= 1 {
return result;
}
for (i, rect) in resize_dimensions.iter().enumerate() {
if let Some(rect) = rect {
let is_leftmost = i == 0;
let is_rightmost = i == len - 1;
resize_left(&mut result[i], rect.left);
resize_right(&mut result[i], rect.right);
resize_top(&mut result[i], rect.top);
resize_bottom(&mut result[i], rect.bottom);
if !is_leftmost && rect.left != 0 {
resize_right(&mut result[i - 1], rect.left);
}
if !is_rightmost && rect.right != 0 {
resize_left(&mut result[i + 1], rect.right);
}
}
}
result
}
fn resize_left(rect: &mut Rect, resize: i32) {
rect.left += resize / 2;
rect.right += -resize / 2;

View File

@@ -21,9 +21,24 @@ pub enum DefaultLayout {
UltrawideVerticalStack,
Grid,
RightMainVerticalStack,
Scrolling,
// NOTE: If any new layout is added, please make sure to register the same in `DefaultLayout::cycle`
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct LayoutOptions {
/// Options related to the Scrolling layout
pub scrolling: Option<ScrollingLayoutOptions>,
}
#[derive(Clone, Copy, Debug, Serialize, Deserialize, Eq, PartialEq)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct ScrollingLayoutOptions {
/// Desired number of visible columns (default: 3)
pub columns: usize,
}
impl DefaultLayout {
pub fn leftmost_index(&self, len: usize) -> usize {
match self {
@@ -31,6 +46,7 @@ impl DefaultLayout {
n if n > 1 => 1,
_ => 0,
},
Self::Scrolling => 0,
DefaultLayout::BSP
| DefaultLayout::Columns
| DefaultLayout::Rows
@@ -53,6 +69,7 @@ impl DefaultLayout {
_ => len.saturating_sub(1),
},
DefaultLayout::RightMainVerticalStack => 0,
DefaultLayout::Scrolling => len.saturating_sub(1),
}
}
@@ -75,6 +92,7 @@ impl DefaultLayout {
| Self::RightMainVerticalStack
| Self::HorizontalStack
| Self::UltrawideVerticalStack
| Self::Scrolling
) {
return None;
};
@@ -169,13 +187,15 @@ impl DefaultLayout {
Self::HorizontalStack => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::Grid,
Self::Grid => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::BSP,
Self::RightMainVerticalStack => Self::Scrolling,
Self::Scrolling => Self::BSP,
}
}
#[must_use]
pub const fn cycle_previous(self) -> Self {
match self {
Self::Scrolling => Self::RightMainVerticalStack,
Self::RightMainVerticalStack => Self::Grid,
Self::Grid => Self::UltrawideVerticalStack,
Self::UltrawideVerticalStack => Self::HorizontalStack,

View File

@@ -102,6 +102,7 @@ impl Direction for DefaultLayout {
Self::VerticalStack | Self::RightMainVerticalStack => idx != 0 && idx != 1,
Self::UltrawideVerticalStack => idx > 2,
Self::Grid => !is_grid_edge(op_direction, idx, count),
Self::Scrolling => false,
},
OperationDirection::Down => match self {
Self::BSP => idx != count - 1 && idx % 2 != 0,
@@ -111,6 +112,7 @@ impl Direction for DefaultLayout {
Self::HorizontalStack => idx == 0,
Self::UltrawideVerticalStack => idx > 1 && idx != count - 1,
Self::Grid => !is_grid_edge(op_direction, idx, count),
Self::Scrolling => false,
},
OperationDirection::Left => match self {
Self::BSP => idx != 0,
@@ -120,6 +122,7 @@ impl Direction for DefaultLayout {
Self::HorizontalStack => idx != 0 && idx != 1,
Self::UltrawideVerticalStack => idx != 1,
Self::Grid => !is_grid_edge(op_direction, idx, count),
Self::Scrolling => idx != 0,
},
OperationDirection::Right => match self {
Self::BSP => idx % 2 == 0 && idx != count - 1,
@@ -133,6 +136,7 @@ impl Direction for DefaultLayout {
_ => idx < 2,
},
Self::Grid => !is_grid_edge(op_direction, idx, count),
Self::Scrolling => idx != count - 1,
},
}
}
@@ -158,6 +162,7 @@ impl Direction for DefaultLayout {
| Self::RightMainVerticalStack => idx - 1,
Self::HorizontalStack => 0,
Self::Grid => grid_neighbor(op_direction, idx, count),
Self::Scrolling => unreachable!(),
}
}
@@ -176,6 +181,7 @@ impl Direction for DefaultLayout {
Self::Columns => unreachable!(),
Self::HorizontalStack => 1,
Self::Grid => grid_neighbor(op_direction, idx, count),
Self::Scrolling => unreachable!(),
}
}
@@ -203,6 +209,7 @@ impl Direction for DefaultLayout {
_ => 0,
},
Self::Grid => grid_neighbor(op_direction, idx, count),
Self::Scrolling => idx - 1,
}
}
@@ -223,6 +230,7 @@ impl Direction for DefaultLayout {
_ => unreachable!(),
},
Self::Grid => grid_neighbor(op_direction, idx, count),
Self::Scrolling => idx + 1,
}
}
}

View File

@@ -1,6 +1,7 @@
#![warn(clippy::all)]
#![allow(clippy::missing_errors_doc, clippy::use_self, clippy::doc_markdown)]
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::str::FromStr;
@@ -108,6 +109,7 @@ pub enum SocketMessage {
AdjustWorkspacePadding(Sizing, i32),
ChangeLayout(DefaultLayout),
CycleLayout(CycleDirection),
ScrollingLayoutColumns(NonZeroUsize),
ChangeLayoutCustom(#[serde_as(as = "ResolvedPathBuf")] PathBuf),
FlipLayout(Axis),
ToggleWorkspaceWindowContainerBehaviour,
@@ -200,6 +202,7 @@ pub enum SocketMessage {
StackbarFontFamily(Option<String>),
WorkAreaOffset(Rect),
MonitorWorkAreaOffset(usize, Rect),
WorkspaceWorkAreaOffset(usize, usize, Rect),
ToggleWindowBasedWorkAreaOffset,
ResizeDelta(i32),
InitialWorkspaceRule(ApplicationIdentifier, String, usize, usize),
@@ -341,6 +344,7 @@ pub enum StateQuery {
FocusedWindowIndex,
FocusedWorkspaceName,
FocusedWorkspaceLayout,
FocusedContainerKind,
Version,
}

View File

@@ -10,9 +10,9 @@ use serde::Serialize;
/// Path extension trait
pub trait PathExt {
/// Resolve environment variables components in a path.
/// Resolve environment variable components in a path.
///
/// Resolves the follwing formats:
/// Resolves the following formats:
/// - CMD: `%variable%`
/// - PowerShell: `$Env:variable`
/// - Bash: `$variable`.

View File

@@ -41,6 +41,10 @@ impl Rect {
pub fn is_same_size_as(&self, rhs: &Self) -> bool {
self.right == rhs.right && self.bottom == rhs.bottom
}
pub fn has_same_position_as(&self, rhs: &Self) -> bool {
self.left == rhs.left && self.top == rhs.top
}
}
impl Rect {

View File

@@ -8,7 +8,7 @@ pub mod ring;
pub mod container;
pub mod core;
pub mod focus_manager;
pub mod locked_deque;
pub mod lockable_sequence;
pub mod monitor;
pub mod monitor_reconciliator;
pub mod process_command;
@@ -200,8 +200,7 @@ lazy_static! {
assert!(
home.is_dir(),
"$Env:KOMOREBI_CONFIG_HOME is set to '{}', which is not a valid directory",
home_path
"$Env:KOMOREBI_CONFIG_HOME is set to '{home_path}', which is not a valid directory"
);
@@ -255,6 +254,14 @@ pub static WINDOW_HANDLING_BEHAVIOUR: AtomicCell<WindowHandlingBehaviour> =
shadow_rs::shadow!(build);
/// A trait for types that can be marked as locked or unlocked.
pub trait Lockable {
/// Returns `true` if the item is locked.
fn locked(&self) -> bool;
/// Sets the locked state of the item.
fn set_locked(&mut self, locked: bool) -> &mut Self;
}
#[must_use]
pub fn current_virtual_desktop() -> Option<Vec<u8>> {
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
@@ -299,7 +306,7 @@ pub fn current_virtual_desktop() -> Option<Vec<u8>> {
current
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum NotificationEvent {
@@ -316,7 +323,7 @@ pub enum VirtualDesktopNotification {
LeftAssociatedVirtualDesktop,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct Notification {
pub event: NotificationEvent,

View File

@@ -0,0 +1,357 @@
use std::collections::VecDeque;
use crate::Lockable;
/// A sequence supporting insertion, removal, and swapping of elements while preserving the absolute
/// positions of locked items.
pub trait LockableSequence<T: Lockable> {
/// Inserts a value at `idx`, keeping locked elements at their absolute positions.
fn insert_respecting_locks(&mut self, idx: usize, value: T) -> usize;
/// Removes the element at `idx`, keeping locked elements at their absolute positions.
fn remove_respecting_locks(&mut self, idx: usize) -> Option<T>;
/// Swaps the elements at indices `i` and `j`, keeping locked elements at their absolute positions.
fn swap_respecting_locks(&mut self, i: usize, j: usize);
}
impl<T: Lockable> LockableSequence<T> for VecDeque<T> {
/// Insert `value` at logical index `idx`, with trying to keep locked elements
/// (`is_locked()`) anchored at their original positions.
///
/// Returns the final index of the inserted element.
fn insert_respecting_locks(&mut self, mut idx: usize, value: T) -> usize {
// 1. Bounds check: if index is out of range, simply append.
if idx >= self.len() {
self.push_back(value);
return self.len() - 1; // last index
}
// 2. Normal VecDeque insertion
self.insert(idx, value);
// 3. Walk left-to-right once, swapping any misplaced locked element. After
// the VecDeque::insert all items after `idx` have moved right by one. For every locked
// element that is now to the right of an unlocked one, swap it back left exactly once.
for index in (idx + 1)..self.len() {
if self[index].locked() && !self[index - 1].locked() {
self.swap(index - 1, index);
// If the element we just inserted participated in the swap,
// update `idx` so we can return its final location.
if idx == index - 1 {
idx = index;
}
}
}
idx
}
/// Remove element at `idx`, with trying to keep locked elements
/// (`is_locked()`) anchored at their original positions.
///
/// Returns the removed element, or `None` if `idx` is out of bounds.
fn remove_respecting_locks(&mut self, idx: usize) -> Option<T> {
// 1. Bounds check: if index is out of range, do nothing.
if idx >= self.len() {
return None;
}
// 2. Remove the element at the requested index.
// All elements after idx are now shifted left by 1.
let removed = self.remove(idx)?;
// 3. If less than 2 elements remain, nothing to shift.
if self.len() < 2 {
return Some(removed);
}
// 4. Iterate from the element just after the removed spot up to the second-to-last
// element, right-to-left. This loop "fixes" locked elements that were shifted left
// off their anchored positions: If a locked element now has an unlocked element
// to its right, swap them back to restore locked order.
for index in (idx..self.len() - 1).rev() {
// If current is locked and the next one is not locked, swap them.
if self[index].locked() && !self[index + 1].locked() {
self.swap(index, index + 1);
}
}
// 5. Return the removed value.
Some(removed)
}
/// Swaps the elements at indices `i` and `j`, along with their `locked` status, ensuring
/// the lock state remains associated with the position rather than the element itself.
fn swap_respecting_locks(&mut self, i: usize, j: usize) {
self.swap(i, j);
let locked_i = self[i].locked();
let locked_j = self[j].locked();
self[i].set_locked(locked_j);
self[j].set_locked(locked_i);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Debug, PartialEq)]
struct TestItem {
val: i32,
locked: bool,
}
impl Lockable for TestItem {
fn locked(&self) -> bool {
self.locked
}
fn set_locked(&mut self, locked: bool) -> &mut Self {
self.locked = locked;
self
}
}
fn vals(v: &VecDeque<TestItem>) -> Vec<i32> {
v.iter().map(|x| x.val).collect()
}
fn test_deque(items: &[(i32, bool)]) -> VecDeque<TestItem> {
items
.iter()
.cloned()
.map(|(val, locked)| TestItem { val, locked })
.collect()
}
#[test]
fn test_insert_respecting_locks() {
// Test case 1: Basic insertion with locked index
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
// Insert at index 0, should shift elements while keeping index 2 locked
ring.insert_respecting_locks(
0,
TestItem {
val: 99,
locked: false,
},
);
// Element '2' remains at index 2, element '1' that was at index 1 is now at index 3
assert_eq!(vals(&ring), vec![99, 0, 2, 1, 3, 4]);
}
// Test case 2: Insert at a locked index (should insert after locked)
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
// Try to insert at locked index 2, should insert at index 3 instead
let actual_index = ring.insert_respecting_locks(
2,
TestItem {
val: 99,
locked: false,
},
);
assert_eq!(actual_index, 3);
assert_eq!(vals(&ring), vec![0, 1, 2, 99, 3, 4]);
}
// Test case 3: Multiple locked indices
{
// Lock index 1 and 3
let mut ring = test_deque(&[(0, false), (1, true), (2, false), (3, true), (4, false)]);
// Insert at index 0, should maintain locked indices
ring.insert_respecting_locks(
0,
TestItem {
val: 99,
locked: false,
},
);
// Elements '1' and '3' remain at indices 1 and 3
assert_eq!(vals(&ring), vec![99, 1, 0, 3, 2, 4]);
}
// Test case 4: Insert at end
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
let actual_index = ring.insert_respecting_locks(
5,
TestItem {
val: 99,
locked: false,
},
);
assert_eq!(actual_index, 5);
assert_eq!(vals(&ring), vec![0, 1, 2, 3, 4, 99]);
}
// Test case 5: Empty ring
{
let mut ring = test_deque(&[]);
// Insert into empty deque
let actual_index = ring.insert_respecting_locks(
0,
TestItem {
val: 99,
locked: false,
},
);
assert_eq!(actual_index, 0);
assert_eq!(vals(&ring), vec![99]);
}
// Test case 6: All indices locked
{
// Lock all indices
let mut ring = test_deque(&[(0, true), (1, true), (2, true), (3, true), (4, true)]);
// Try to insert at index 2, should insert at the end
let actual_index = ring.insert_respecting_locks(
2,
TestItem {
val: 99,
locked: false,
},
);
assert_eq!(actual_index, 5);
assert_eq!(vals(&ring), vec![0, 1, 2, 3, 4, 99]);
}
// Test case 7: Consecutive locked indices
{
// Lock index 2 and 3
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, true), (4, false)]);
// Insert at index 1, should maintain consecutive locked indices
ring.insert_respecting_locks(
1,
TestItem {
val: 99,
locked: false,
},
);
// Elements '2' and '3' remain at indices 2 and 3
assert_eq!(vals(&ring), vec![0, 99, 2, 3, 1, 4]);
}
}
#[test]
fn test_remove_respecting_locks() {
// Test case 1: Remove a non-locked index before a locked index
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
let removed = ring.remove_respecting_locks(0);
assert_eq!(removed.map(|x| x.val), Some(0));
// Elements '2' remain at index 2
assert_eq!(vals(&ring), vec![1, 3, 2, 4]);
}
// Test case 2: Remove a locked index
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
let removed = ring.remove_respecting_locks(2);
assert_eq!(removed.map(|x| x.val), Some(2));
// Elements should stay at the same places
assert_eq!(vals(&ring), vec![0, 1, 3, 4]);
}
// Test case 3: Remove an index after a locked index
{
// Lock index 1
let mut ring = test_deque(&[(0, false), (1, true), (2, false), (3, false), (4, false)]);
let removed = ring.remove_respecting_locks(3);
assert_eq!(removed.map(|x| x.val), Some(3));
// Elements should stay at the same places
assert_eq!(vals(&ring), vec![0, 1, 2, 4]);
}
// Test case 4: Multiple locked indices
{
// Lock index 1 and 3
let mut ring = test_deque(&[(0, false), (1, true), (2, false), (3, true), (4, false)]);
let removed = ring.remove_respecting_locks(0);
assert_eq!(removed.map(|x| x.val), Some(0));
// Elements '1' and '3' remain at indices '1' and '3'
assert_eq!(vals(&ring), vec![2, 1, 4, 3]);
}
// Test case 5: Remove the last element
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
let removed = ring.remove_respecting_locks(4);
assert_eq!(removed.map(|x| x.val), Some(4));
// Index 2 should still be at the same place
assert_eq!(vals(&ring), vec![0, 1, 2, 3]);
}
// Test case 6: Invalid index
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true), (3, false), (4, false)]);
let removed = ring.remove_respecting_locks(10);
assert_eq!(removed, None);
// Deque unchanged
assert_eq!(vals(&ring), vec![0, 1, 2, 3, 4]);
}
// Test case 7: Remove enough elements to make a locked index invalid
{
// Lock index 2
let mut ring = test_deque(&[(0, false), (1, false), (2, true)]);
ring.remove_respecting_locks(0);
// Index 2 should now be '1'
assert_eq!(vals(&ring), vec![1, 2]);
}
// Test case 8: Removing an element before multiple locked indices
{
// Lock index 2 and 4
let mut ring = test_deque(&[
(0, false),
(1, false),
(2, true),
(3, false),
(4, true),
(5, false),
]);
let removed = ring.remove_respecting_locks(1);
assert_eq!(removed.map(|x| x.val), Some(1));
// Both indices should still be at the same place
assert_eq!(vals(&ring), vec![0, 3, 2, 5, 4]);
}
}
#[test]
fn test_swap_respecting_locks_various_cases() {
// Swap unlocked and locked
let mut ring = test_deque(&[(0, false), (1, true), (2, false), (3, false)]);
ring.swap_respecting_locks(0, 1);
assert_eq!(vals(&ring), vec![1, 0, 2, 3]);
assert!(!ring[0].locked);
assert!(ring[1].locked);
ring.swap_respecting_locks(0, 1);
assert_eq!(vals(&ring), vec![0, 1, 2, 3]);
assert!(!ring[0].locked);
assert!(ring[1].locked);
// Both locked
let mut ring = test_deque(&[(0, true), (1, false), (2, true)]);
ring.swap_respecting_locks(0, 2);
assert_eq!(vals(&ring), vec![2, 1, 0]);
assert!(ring[0].locked);
assert!(!ring[1].locked);
assert!(ring[2].locked);
// Both unlocked
let mut ring = test_deque(&[(0, false), (1, true), (2, false)]);
ring.swap_respecting_locks(0, 2);
assert_eq!(vals(&ring), vec![2, 1, 0]);
assert!(!ring[0].locked);
assert!(ring[1].locked);
assert!(!ring[2].locked);
}
}

View File

@@ -1,316 +0,0 @@
use std::collections::BTreeSet;
use std::collections::VecDeque;
pub struct LockedDeque<'a, T> {
deque: &'a mut VecDeque<T>,
locked_indices: &'a mut BTreeSet<usize>,
}
impl<'a, T: PartialEq> LockedDeque<'a, T> {
pub fn new(deque: &'a mut VecDeque<T>, locked_indices: &'a mut BTreeSet<usize>) -> Self {
Self {
deque,
locked_indices,
}
}
pub fn insert(&mut self, index: usize, value: T) -> usize {
insert_respecting_locks(self.deque, self.locked_indices, index, value)
}
pub fn remove(&mut self, index: usize) -> Option<T> {
remove_respecting_locks(self.deque, self.locked_indices, index)
}
}
pub fn insert_respecting_locks<T>(
deque: &mut VecDeque<T>,
locked_idx: &mut BTreeSet<usize>,
idx: usize,
value: T,
) -> usize {
if idx == deque.len() {
deque.push_back(value);
return idx;
}
let mut new_deque = VecDeque::with_capacity(deque.len() + 1);
let mut temp_locked_deque = VecDeque::new();
let mut j = 0;
let mut corrected_idx = idx;
for (i, el) in deque.drain(..).enumerate() {
if i == idx {
corrected_idx = j;
}
if locked_idx.contains(&i) {
temp_locked_deque.push_back(el);
} else {
new_deque.push_back(el);
j += 1;
}
}
new_deque.insert(corrected_idx, value);
for (locked_el, locked_idx) in temp_locked_deque.into_iter().zip(locked_idx.iter()) {
new_deque.insert(*locked_idx, locked_el);
if *locked_idx <= corrected_idx {
corrected_idx += 1;
}
}
*deque = new_deque;
corrected_idx
}
pub fn remove_respecting_locks<T>(
deque: &mut VecDeque<T>,
locked_idx: &mut BTreeSet<usize>,
idx: usize,
) -> Option<T> {
if idx >= deque.len() {
return None;
}
let final_size = deque.len() - 1;
let mut new_deque = VecDeque::with_capacity(final_size);
let mut temp_locked_deque = VecDeque::new();
let mut removed = None;
let mut removed_locked_idx = None;
for (i, el) in deque.drain(..).enumerate() {
if i == idx {
removed = Some(el);
removed_locked_idx = locked_idx.contains(&i).then_some(i);
} else if locked_idx.contains(&i) {
temp_locked_deque.push_back(el);
} else {
new_deque.push_back(el);
}
}
if let Some(i) = removed_locked_idx {
let mut above = locked_idx.split_off(&i);
above.pop_first();
locked_idx.extend(above.into_iter().map(|i| i - 1));
}
while locked_idx.last().is_some_and(|i| *i >= final_size) {
locked_idx.pop_last();
}
let extra_invalid_idx = (new_deque.len()
..(new_deque.len() + temp_locked_deque.len() - locked_idx.len()))
.collect::<Vec<_>>();
for (locked_el, locked_idx) in temp_locked_deque
.into_iter()
.zip(locked_idx.iter().chain(extra_invalid_idx.iter()))
{
new_deque.insert(*locked_idx, locked_el);
}
*deque = new_deque;
removed
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use std::collections::VecDeque;
#[test]
fn test_insert_respecting_locks() {
// Test case 1: Basic insertion with locked index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
// Insert at index 0, should shift elements while keeping index 2 locked
insert_respecting_locks(&mut deque, &mut locked, 0, 99);
assert_eq!(deque, VecDeque::from(vec![99, 0, 2, 1, 3, 4]));
// Element '2' remains at index 2, element '1' that was at index 1 is now at index 3
}
// Test case 2: Insert at a locked index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
// Try to insert at locked index 2, should insert at index 3 instead
let actual_index = insert_respecting_locks(&mut deque, &mut locked, 2, 99);
assert_eq!(actual_index, 3);
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 99, 3, 4]));
}
// Test case 3: Multiple locked indices
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(1); // Lock index 1
locked.insert(3); // Lock index 3
// Insert at index 0, should maintain locked indices
insert_respecting_locks(&mut deque, &mut locked, 0, 99);
assert_eq!(deque, VecDeque::from(vec![99, 1, 0, 3, 2, 4]));
// Elements '1' and '3' remain at indices 1 and 3
}
// Test case 4: Insert at end
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
// Insert at end of deque
let actual_index = insert_respecting_locks(&mut deque, &mut locked, 5, 99);
assert_eq!(actual_index, 5);
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4, 99]));
}
// Test case 5: Empty deque
{
let mut deque = VecDeque::new();
let mut locked = BTreeSet::new();
// Insert into empty deque
let actual_index = insert_respecting_locks(&mut deque, &mut locked, 0, 99);
assert_eq!(actual_index, 0);
assert_eq!(deque, VecDeque::from(vec![99]));
}
// Test case 6: All indices locked
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
for i in 0..5 {
locked.insert(i); // Lock all indices
}
// Try to insert at index 2, should insert at the end
let actual_index = insert_respecting_locks(&mut deque, &mut locked, 2, 99);
assert_eq!(actual_index, 5);
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4, 99]));
}
// Test case 7: Consecutive locked indices
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
locked.insert(3); // Lock index 3
// Insert at index 1, should maintain consecutive locked indices
insert_respecting_locks(&mut deque, &mut locked, 1, 99);
assert_eq!(deque, VecDeque::from(vec![0, 99, 2, 3, 1, 4]));
// Elements '2' and '3' remain at indices 2 and 3
}
}
#[test]
fn test_remove_respecting_locks() {
// Test case 1: Remove a non-locked index before a locked index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
let removed = remove_respecting_locks(&mut deque, &mut locked, 0);
assert_eq!(removed, Some(0));
assert_eq!(deque, VecDeque::from(vec![1, 3, 2, 4]));
assert!(locked.contains(&2)); // Index 2 should still be locked
}
// Test case 2: Remove a locked index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
let removed = remove_respecting_locks(&mut deque, &mut locked, 2);
assert_eq!(removed, Some(2));
assert_eq!(deque, VecDeque::from(vec![0, 1, 3, 4]));
assert!(!locked.contains(&2)); // Index 2 should be unlocked
}
// Test case 3: Remove an index after a locked index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(1); // Lock index 1
let removed = remove_respecting_locks(&mut deque, &mut locked, 3);
assert_eq!(removed, Some(3));
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 4]));
assert!(locked.contains(&1)); // Index 1 should still be locked
}
// Test case 4: Multiple locked indices
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(1); // Lock index 1
locked.insert(3); // Lock index 3
let removed = remove_respecting_locks(&mut deque, &mut locked, 0);
assert_eq!(removed, Some(0));
assert_eq!(deque, VecDeque::from(vec![2, 1, 4, 3]));
assert!(locked.contains(&1) && locked.contains(&3)); // Both indices should still be locked
}
// Test case 5: Remove the last element
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
let removed = remove_respecting_locks(&mut deque, &mut locked, 4);
assert_eq!(removed, Some(4));
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3]));
assert!(locked.contains(&2)); // Index 2 should still be locked
}
// Test case 6: Invalid index
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
let removed = remove_respecting_locks(&mut deque, &mut locked, 10);
assert_eq!(removed, None);
assert_eq!(deque, VecDeque::from(vec![0, 1, 2, 3, 4])); // Deque unchanged
assert!(locked.contains(&2)); // Lock unchanged
}
// Test case 7: Remove enough elements to make a locked index invalid
{
let mut deque = VecDeque::from(vec![0, 1, 2]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
remove_respecting_locks(&mut deque, &mut locked, 0);
assert_eq!(deque, VecDeque::from(vec![1, 2]));
assert!(!locked.contains(&2)); // Index 2 should now be invalid
}
// Test case 8: Removing an element before multiple locked indices
{
let mut deque = VecDeque::from(vec![0, 1, 2, 3, 4, 5]);
let mut locked = BTreeSet::new();
locked.insert(2); // Lock index 2
locked.insert(4); // Lock index 4
let removed = remove_respecting_locks(&mut deque, &mut locked, 1);
assert_eq!(removed, Some(1));
assert_eq!(deque, VecDeque::from(vec![0, 3, 2, 5, 4]));
assert!(locked.contains(&2) && locked.contains(&4)); // Both indices should still be locked
}
}
}

View File

@@ -500,7 +500,7 @@ impl Monitor {
if workspaces.get(idx).is_none() {
workspaces.resize(idx + 1, Workspace::default());
}
self.set_last_focused_workspace(Some(self.workspaces.focused_idx()));
self.workspaces.focus(idx);
}
@@ -631,6 +631,25 @@ mod tests {
assert_eq!(m.workspaces().len(), 0);
}
#[test]
fn test_remove_nonexistent_workspace() {
let mut m = Monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Try to remove a workspace that doesn't exist
let removed_workspace = m.remove_workspace_by_idx(1);
// Should return None since there is no workspace at index 1
assert!(removed_workspace.is_none());
}
#[test]
fn test_focus_workspace() {
let mut m = Monitor::new(
@@ -738,6 +757,46 @@ mod tests {
assert_eq!(m.focused_workspace().unwrap().containers().len(), 2);
}
#[test]
fn test_move_container_to_nonexistent_workspace() {
let mut m = Monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
{
// Create workspace 1 and add 3 containers
let workspace = m.focused_workspace_mut().unwrap();
for _ in 0..3 {
let container = Container::default();
workspace.add_container_to_back(container);
}
// Should have 3 containers in workspace 1
assert_eq!(m.focused_workspace().unwrap().containers().len(), 3);
}
// Should only have 1 workspace
assert_eq!(m.workspaces().len(), 1);
// Try to move a container to a workspace that doesn't exist
m.move_container_to_workspace(8, true, None).unwrap();
// Should have 9 workspaces now
assert_eq!(m.workspaces().len(), 9);
// Should be focused on workspace 8
assert_eq!(m.focused_workspace_idx(), 8);
// Should have 1 container in workspace 8
assert_eq!(m.focused_workspace().unwrap().containers().len(), 1);
}
#[test]
fn test_ensure_workspace_count_workspace_contains_two_workspaces() {
let mut m = Monitor::new(

View File

@@ -801,7 +801,7 @@ mod tests {
let wm = match WindowManager::new(receiver, Some(socket_path.clone())) {
Ok(manager) => manager,
Err(e) => {
panic!("Failed to create WindowManager: {}", e);
panic!("Failed to create WindowManager: {e}");
}
};
@@ -829,7 +829,7 @@ mod tests {
Ok(notification) => {
assert_eq!(notification, MonitorNotification::ResolutionScalingChanged);
}
Err(e) => panic!("Failed to receive MonitorNotification: {}", e),
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
}
}
@@ -849,7 +849,7 @@ mod tests {
for _ in 0..20 {
let notification = match receiver.try_recv() {
Ok(notification) => notification,
Err(e) => panic!("Failed to receive MonitorNotification: {}", e),
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
};
assert_eq!(
notification,
@@ -960,7 +960,7 @@ mod tests {
Ok(notification) => {
assert_eq!(notification, MonitorNotification::DisplayConnectionChange);
}
Err(e) => panic!("Failed to receive MonitorNotification: {}", e),
Err(e) => panic!("Failed to receive MonitorNotification: {e}"),
}
}

View File

@@ -49,6 +49,8 @@ use crate::core::StateQuery;
use crate::core::WindowContainerBehaviour;
use crate::core::WindowKind;
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;
@@ -207,25 +209,6 @@ impl WindowManager {
// We don't have From implemented for &mut WindowManager
let initial_state = State::from(self.as_ref());
match message {
SocketMessage::CycleFocusEmptyWorkspace(_)
| SocketMessage::CycleFocusWorkspace(_)
| SocketMessage::FocusWorkspaceNumber(_) => {
if let Some(monitor) = self.focused_monitor_mut() {
let idx = monitor.focused_workspace_idx();
monitor.set_last_focused_workspace(Option::from(idx));
}
}
SocketMessage::FocusMonitorWorkspaceNumber(target_monitor_idx, _) => {
let idx = self.focused_workspace_idx_for_monitor_idx(target_monitor_idx)?;
if let Some(monitor) = self.monitors_mut().get_mut(target_monitor_idx) {
monitor.set_last_focused_workspace(Option::from(idx));
}
}
_ => {}
};
let mut force_update_borders = false;
match message {
SocketMessage::Promote => self.promote_container_to_front()?,
@@ -347,7 +330,7 @@ impl WindowManager {
SocketMessage::StackWindow(direction) => self.add_window_to_container(direction)?,
SocketMessage::UnstackWindow => self.remove_window_from_container()?,
SocketMessage::StackAll => self.stack_all()?,
SocketMessage::UnstackAll => self.unstack_all()?,
SocketMessage::UnstackAll => self.unstack_all(true)?,
SocketMessage::CycleStack(direction) => {
self.cycle_container_window_in_direction(direction)?;
}
@@ -391,7 +374,9 @@ impl WindowManager {
.get_mut(workspace_idx)
.ok_or_eyre("no workspace at the given index")?;
workspace.locked_containers.insert(container_idx);
if let Some(container) = workspace.containers_mut().get_mut(container_idx) {
container.set_locked(true);
}
}
SocketMessage::UnlockMonitorWorkspaceContainer(
monitor_idx,
@@ -408,7 +393,9 @@ impl WindowManager {
.get_mut(workspace_idx)
.ok_or_eyre("no workspace at the given index")?;
workspace.locked_containers.remove(&container_idx);
if let Some(container) = workspace.containers_mut().get_mut(container_idx) {
container.set_locked(false);
}
}
SocketMessage::ToggleLock => self.toggle_lock()?,
SocketMessage::ToggleFloat => self.toggle_float(false)?,
@@ -933,6 +920,27 @@ impl WindowManager {
self.retile_all(true)?
}
SocketMessage::FlipLayout(layout_flip) => self.flip_layout(layout_flip)?,
SocketMessage::ScrollingLayoutColumns(count) => {
let focused_workspace = self.focused_workspace_mut()?;
let options = match focused_workspace.layout_options() {
Some(mut opts) => {
if let Some(scrolling) = &mut opts.scrolling {
scrolling.columns = count.into();
}
opts
}
None => LayoutOptions {
scrolling: Some(ScrollingLayoutOptions {
columns: count.into(),
}),
},
};
focused_workspace.set_layout_options(Some(options));
self.update_focused_workspace(false, false)?;
}
SocketMessage::ChangeLayout(layout) => self.change_workspace_layout_default(layout)?,
SocketMessage::CycleLayout(direction) => self.cycle_layout(direction)?,
SocketMessage::ChangeLayoutCustom(ref path) => {
@@ -1281,6 +1289,7 @@ impl WindowManager {
if i == focused_idx {
to_focus = Some(*window);
} else {
window.restore();
window.raise()?;
}
}
@@ -1288,6 +1297,7 @@ impl WindowManager {
if let Some(focused_window) = &to_focus {
// The focused window should be the last one raised to make sure it is
// on top
focused_window.restore();
focused_window.raise()?;
}
@@ -1297,8 +1307,8 @@ impl WindowManager {
}
}
if let Some(container) = workspace.monocle_container() {
if let Some(window) = container.focused_window() {
if let Some(monocle) = workspace.monocle_container() {
if let Some(window) = monocle.focused_window() {
window.lower()?;
}
}
@@ -1306,30 +1316,41 @@ impl WindowManager {
WorkspaceLayer::Floating => {
workspace.set_layer(WorkspaceLayer::Tiling);
let focused_container_idx = workspace.focused_container_idx();
for (i, container) in workspace.containers_mut().iter_mut().enumerate() {
if let Some(window) = container.focused_window() {
if i == focused_container_idx {
to_focus = Some(*window);
}
if let Some(monocle) = workspace.monocle_container() {
if let Some(window) = monocle.focused_window() {
to_focus = Some(*window);
window.raise()?;
}
}
for window in workspace.floating_windows() {
window.hide();
}
} else {
let focused_container_idx = workspace.focused_container_idx();
for (i, container) in workspace.containers_mut().iter_mut().enumerate()
{
if let Some(window) = container.focused_window() {
if i == focused_container_idx {
to_focus = Some(*window);
}
window.raise()?;
}
}
let mut window_idx_pairs = workspace
.floating_windows_mut()
.make_contiguous()
.iter()
.collect::<Vec<_>>();
let mut window_idx_pairs = workspace
.floating_windows_mut()
.make_contiguous()
.iter()
.collect::<Vec<_>>();
// Sort by window area
window_idx_pairs.sort_by_key(|w| {
let rect = WindowsApi::window_rect(w.hwnd).unwrap_or_default();
rect.right * rect.bottom
});
// Sort by window area
window_idx_pairs.sort_by_key(|w| {
let rect = WindowsApi::window_rect(w.hwnd).unwrap_or_default();
rect.right * rect.bottom
});
for window in window_idx_pairs {
window.lower()?;
for window in window_idx_pairs {
window.lower()?;
}
}
}
};
@@ -1462,6 +1483,18 @@ impl WindowManager {
},
)
}
StateQuery::FocusedContainerKind => {
match self.focused_workspace()?.focused_container() {
None => "None".to_string(),
Some(container) => {
if container.windows().len() > 1 {
"Stack".to_string()
} else {
"Single".to_string()
}
}
}
}
};
reply.write_all(response.as_bytes())?;
@@ -1726,7 +1759,7 @@ Stop-Process -Name:komorebi-bar -ErrorAction SilentlyContinue
{
for config_file_path in &mut *display_bar_configurations {
let script = r#"Start-Process "komorebi-bar" '"--config" "CONFIGFILE"' -WindowStyle hidden"#
.replace("CONFIGFILE", &config_file_path.to_string_lossy());
.replace("CONFIGFILE", &config_file_path.to_string_lossy());
match powershell_script::run(&script) {
Ok(_) => {
@@ -1853,6 +1886,14 @@ if (!(Get-Process komorebi-bar -ErrorAction SilentlyContinue))
self.retile_all(false)?;
}
}
SocketMessage::WorkspaceWorkAreaOffset(monitor_idx, workspace_idx, rect) => {
if let Some(monitor) = self.monitors_mut().get_mut(monitor_idx) {
if let Some(workspace) = monitor.workspaces_mut().get_mut(workspace_idx) {
workspace.set_work_area_offset(Option::from(rect));
self.retile_all(false)?
}
}
}
SocketMessage::ToggleWindowBasedWorkAreaOffset => {
let workspace = self.focused_workspace_mut()?;
workspace.set_apply_window_based_work_area_offset(

View File

@@ -25,6 +25,8 @@ use crate::window_manager_event::WindowManagerEvent;
use crate::windows_api::WindowsApi;
use crate::winevent::WinEvent;
use crate::workspace::WorkspaceLayer;
use crate::DefaultLayout;
use crate::Layout;
use crate::Notification;
use crate::NotificationEvent;
use crate::State;
@@ -301,7 +303,11 @@ impl WindowManager {
// don't want to trigger the full workspace updates when there are no managed
// containers - this makes floating windows on empty workspaces go into very
// annoying focus change loops which prevents users from interacting with them
if !self.focused_workspace()?.containers().is_empty() {
if !matches!(
self.focused_workspace()?.layout(),
Layout::Default(DefaultLayout::Scrolling)
) && !self.focused_workspace()?.containers().is_empty()
{
self.update_focused_workspace(self.mouse_follows_focus, false)?;
}
@@ -328,6 +334,14 @@ impl WindowManager {
}
workspace.set_layer(WorkspaceLayer::Tiling);
if matches!(
self.focused_workspace()?.layout(),
Layout::Default(DefaultLayout::Scrolling)
) && !self.focused_workspace()?.containers().is_empty()
{
self.update_focused_workspace(self.mouse_follows_focus, false)?;
}
}
Some(idx) => {
if let Some(_window) = workspace.floating_windows().get(idx) {
@@ -339,10 +353,11 @@ impl WindowManager {
WindowManagerEvent::Show(_, window)
| WindowManagerEvent::Manage(window)
| WindowManagerEvent::Uncloak(_, window) => {
if matches!(event, WindowManagerEvent::Uncloak(_, _)) && self.uncloak_to_ignore >= 1
if matches!(event, WindowManagerEvent::Uncloak(_, _))
&& self.uncloack_to_ignore >= 1
{
tracing::info!("ignoring uncloak after monocle move by mouse across monitors");
self.uncloak_to_ignore = self.uncloak_to_ignore.saturating_sub(1);
self.uncloack_to_ignore = self.uncloack_to_ignore.saturating_sub(1);
} else {
let focused_monitor_idx = self.focused_monitor_idx();
let focused_workspace_idx =
@@ -401,7 +416,6 @@ impl WindowManager {
let workspace = self.focused_workspace_mut()?;
let workspace_contains_window = workspace.contains_window(window.hwnd);
let monocle_container = workspace.monocle_container().clone();
let mut workspace_layer = *workspace.layer();
if !workspace_contains_window && needs_reconciliation.is_none() {
let floating_applications = FLOATING_APPLICATIONS.lock();
@@ -445,7 +459,6 @@ impl WindowManager {
placement.should_center() && workspace.tile;
workspace.floating_windows_mut().push_back(window);
workspace.set_layer(WorkspaceLayer::Floating);
workspace_layer = *workspace.layer();
if center_spawned_floats {
let mut floating_window = window;
floating_window.center(
@@ -459,7 +472,6 @@ impl WindowManager {
WindowContainerBehaviour::Create => {
workspace.new_container_for_window(window);
workspace.set_layer(WorkspaceLayer::Tiling);
workspace_layer = *workspace.layer();
self.update_focused_workspace(false, false)?;
}
WindowContainerBehaviour::Append => {
@@ -470,7 +482,6 @@ impl WindowManager {
})?
.add_window(window);
workspace.set_layer(WorkspaceLayer::Tiling);
workspace_layer = *workspace.layer();
self.update_focused_workspace(true, false)?;
stackbar_manager::send_notification();
}
@@ -503,9 +514,10 @@ impl WindowManager {
}
}
if !monocle_window_event
let workspace = self.focused_workspace()?;
if !(monocle_window_event
|| workspace.layer() != &WorkspaceLayer::Tiling)
&& monocle_container.is_some()
&& matches!(workspace_layer, WorkspaceLayer::Tiling)
{
window.hide();
}

View File

@@ -43,10 +43,6 @@ impl<T> Ring<T> {
pub fn focused_mut(&mut self) -> Option<&mut T> {
self.elements.get_mut(self.focused)
}
pub fn swap(&mut self, i: usize, j: usize) {
self.elements.swap(i, j);
}
}
macro_rules! impl_ring_elements {

View File

@@ -28,8 +28,8 @@ pub static STACKBAR_UNFOCUSED_TEXT_COLOUR: AtomicU32 = AtomicU32::new(11776947);
pub static STACKBAR_TAB_BACKGROUND_COLOUR: AtomicU32 = AtomicU32::new(3355443); // gray
pub static STACKBAR_TAB_HEIGHT: AtomicI32 = AtomicI32::new(40);
pub static STACKBAR_TAB_WIDTH: AtomicI32 = AtomicI32::new(200);
pub static STACKBAR_LABEL: AtomicCell<StackbarLabel> = AtomicCell::new(StackbarLabel::Process);
pub static STACKBAR_MODE: AtomicCell<StackbarMode> = AtomicCell::new(StackbarMode::OnStack);
pub static STACKBAR_LABEL: AtomicCell<StackbarLabel> = AtomicCell::new(StackbarLabel::Title);
pub static STACKBAR_MODE: AtomicCell<StackbarMode> = AtomicCell::new(StackbarMode::Never);
pub static STACKBAR_TEMPORARILY_DISABLED: AtomicBool = AtomicBool::new(false);

View File

@@ -58,15 +58,19 @@ use windows::Win32::UI::WindowsAndMessaging::CreateWindowExW;
use windows::Win32::UI::WindowsAndMessaging::DefWindowProcW;
use windows::Win32::UI::WindowsAndMessaging::DispatchMessageW;
use windows::Win32::UI::WindowsAndMessaging::GetMessageW;
use windows::Win32::UI::WindowsAndMessaging::LoadCursorW;
use windows::Win32::UI::WindowsAndMessaging::PostQuitMessage;
use windows::Win32::UI::WindowsAndMessaging::SetCursor;
use windows::Win32::UI::WindowsAndMessaging::SetLayeredWindowAttributes;
use windows::Win32::UI::WindowsAndMessaging::TranslateMessage;
use windows::Win32::UI::WindowsAndMessaging::CS_HREDRAW;
use windows::Win32::UI::WindowsAndMessaging::CS_VREDRAW;
use windows::Win32::UI::WindowsAndMessaging::IDC_ARROW;
use windows::Win32::UI::WindowsAndMessaging::LWA_COLORKEY;
use windows::Win32::UI::WindowsAndMessaging::MSG;
use windows::Win32::UI::WindowsAndMessaging::WM_DESTROY;
use windows::Win32::UI::WindowsAndMessaging::WM_LBUTTONDOWN;
use windows::Win32::UI::WindowsAndMessaging::WM_SETCURSOR;
use windows::Win32::UI::WindowsAndMessaging::WNDCLASSW;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_LAYERED;
use windows::Win32::UI::WindowsAndMessaging::WS_EX_TOOLWINDOW;
@@ -312,6 +316,16 @@ impl Stackbar {
) -> LRESULT {
unsafe {
match msg {
WM_SETCURSOR => match LoadCursorW(None, IDC_ARROW) {
Ok(cursor) => {
SetCursor(Some(cursor));
LRESULT(0)
}
Err(error) => {
tracing::error!("{error}");
LRESULT(1)
}
},
WM_LBUTTONDOWN => {
let stackbars_containers = STACKBARS_CONTAINERS.lock();
if let Some(container) = stackbars_containers.get(&(hwnd.0 as isize)) {

View File

@@ -35,6 +35,7 @@ use crate::core::StackbarMode;
use crate::core::WindowContainerBehaviour;
use crate::core::WindowManagementBehaviour;
use crate::current_virtual_desktop;
use crate::default_layout::LayoutOptions;
use crate::monitor;
use crate::monitor::Monitor;
use crate::monitor_reconciliator;
@@ -191,6 +192,9 @@ pub struct WorkspaceConfig {
/// Layout (default: BSP)
#[serde(skip_serializing_if = "Option::is_none")]
pub layout: Option<DefaultLayout>,
/// Layout-specific options (default: None)
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_options: Option<LayoutOptions>,
/// END OF LIFE FEATURE: Custom Layout (default: None)
#[serde(skip_serializing_if = "Option::is_none")]
#[serde_as(as = "Option<ResolvedPathBuf>")]
@@ -214,6 +218,9 @@ pub struct WorkspaceConfig {
/// Permanent workspace application rules
#[serde(skip_serializing_if = "Option::is_none")]
pub workspace_rules: Option<Vec<MatchingRule>>,
/// Workspace specific work area offset (default: None)
#[serde(skip_serializing_if = "Option::is_none")]
pub work_area_offset: Option<Rect>,
/// Apply this monitor's window-based work area offset (default: true)
#[serde(skip_serializing_if = "Option::is_none")]
pub apply_window_based_work_area_offset: Option<bool>,
@@ -226,6 +233,9 @@ pub struct WorkspaceConfig {
/// Enable or disable float override, which makes it so every new window opens in floating mode (default: false)
#[serde(skip_serializing_if = "Option::is_none")]
pub float_override: Option<bool>,
/// Enable or disable tiling for the workspace (default: true)
#[serde(skip_serializing_if = "Option::is_none")]
pub tile: Option<bool>,
/// Specify an axis on which to flip the selected layout (default: None)
#[serde(skip_serializing_if = "Option::is_none")]
pub layout_flip: Option<Axis>,
@@ -274,6 +284,8 @@ impl From<&Workspace> for WorkspaceConfig {
}
});
let tile = if *value.tile() { None } else { Some(false) };
Self {
name: value
.name()
@@ -286,6 +298,7 @@ impl From<&Workspace> for WorkspaceConfig {
Layout::Custom(_) => None,
})
.flatten(),
layout_options: value.layout_options(),
custom_layout: value
.workspace_config()
.as_ref()
@@ -305,10 +318,12 @@ impl From<&Workspace> for WorkspaceConfig {
.workspace_config()
.as_ref()
.and_then(|c| c.workspace_rules.clone()),
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(),
window_container_behaviour_rules: Option::from(window_container_behaviour_rules),
float_override: *value.float_override(),
tile,
layout_flip: value.layout_flip(),
floating_layer_behaviour: value.floating_layer_behaviour(),
wallpaper: None,
@@ -397,7 +412,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.37`
/// The `komorebi.json` static configuration file reference for `v0.1.38`
pub struct StaticConfig {
/// DEPRECATED from v0.1.22: no longer required
#[serde(skip_serializing_if = "Option::is_none")]
@@ -807,7 +822,7 @@ pub struct StackbarConfig {
/// Stackbar label
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<StackbarLabel>,
/// Stackbar mode
/// Stackbar mode (default: Never)
#[serde(skip_serializing_if = "Option::is_none")]
pub mode: Option<StackbarMode>,
/// Stackbar tab configuration options
@@ -1299,7 +1314,7 @@ impl StaticConfig {
has_pending_raise_op: false,
pending_move_op: Arc::new(None),
already_moved_window_handles: Arc::new(Mutex::new(HashSet::new())),
uncloak_to_ignore: 0,
uncloack_to_ignore: 0,
known_hwnds: HashMap::new(),
};
@@ -1331,7 +1346,7 @@ impl StaticConfig {
}
pub fn postload(path: &PathBuf, wm: &Arc<Mutex<WindowManager>>) -> Result<()> {
let value = Self::read(path)?;
let mut value = Self::read(path)?;
let mut wm = wm.lock();
let configs_with_preference: Vec<_> =
@@ -1342,6 +1357,8 @@ impl StaticConfig {
workspace_matching_rules.clear();
drop(workspace_matching_rules);
let monitor_count = wm.monitors().len();
let offset = wm.work_area_offset;
for (i, monitor) in wm.monitors_mut().iter_mut().enumerate() {
let preferred_config_idx = {
@@ -1371,8 +1388,8 @@ impl StaticConfig {
});
if let Some(monitor_config) = value
.monitors
.as_ref()
.and_then(|ms| idx.and_then(|i| ms.get(i)))
.as_mut()
.and_then(|ms| idx.and_then(|i| ms.get_mut(i)))
{
if let Some(used_config_idx) = idx {
configs_used.push(used_config_idx);
@@ -1395,7 +1412,14 @@ impl StaticConfig {
monitor.update_workspaces_globals(offset);
for (j, ws) in monitor.workspaces_mut().iter_mut().enumerate() {
if let Some(workspace_config) = monitor_config.workspaces.get(j) {
if let Some(workspace_config) = monitor_config.workspaces.get_mut(j) {
if monitor_count > 1
&& matches!(workspace_config.layout, Some(DefaultLayout::Scrolling))
{
tracing::warn!("scrolling layout is only supported for a single monitor; falling back to columns layout");
workspace_config.layout = Some(DefaultLayout::Columns);
}
ws.load_static_config(workspace_config)?;
}
}
@@ -1912,11 +1936,13 @@ mod tests {
use crate::WorkspaceConfig;
#[test]
#[ignore = "this fails on github actions due to rate limiting changes introduced in may 2025"]
fn backwards_compat() {
let root = vec!["0.1.17", "0.1.18", "0.1.19"];
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.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",
];
let mut versions = vec![];

View File

@@ -120,13 +120,13 @@ pub struct WindowManager {
pub has_pending_raise_op: bool,
pub pending_move_op: Arc<Option<(usize, usize, isize)>>,
pub already_moved_window_handles: Arc<Mutex<HashSet<isize>>>,
pub uncloak_to_ignore: usize,
pub uncloack_to_ignore: usize,
/// Maps each known window hwnd to the (monitor, workspace) index pair managing it
pub known_hwnds: HashMap<isize, (usize, usize)>,
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Serialize, Deserialize)]
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
pub struct State {
pub monitors: Ring<Monitor>,
@@ -329,6 +329,7 @@ impl From<&WindowManager> for State {
maximized_window_restore_idx: workspace.maximized_window_restore_idx,
floating_windows: workspace.floating_windows.clone(),
layout: workspace.layout.clone(),
layout_options: workspace.layout_options,
layout_rules: workspace.layout_rules.clone(),
layout_flip: workspace.layout_flip,
workspace_padding: workspace.workspace_padding,
@@ -336,6 +337,7 @@ impl From<&WindowManager> for State {
latest_layout: workspace.latest_layout.clone(),
resize_dimensions: workspace.resize_dimensions.clone(),
tile: workspace.tile,
work_area_offset: workspace.work_area_offset,
apply_window_based_work_area_offset: workspace
.apply_window_based_work_area_offset,
window_container_behaviour: workspace.window_container_behaviour,
@@ -346,7 +348,6 @@ impl From<&WindowManager> for State {
layer: workspace.layer,
floating_layer_behaviour: workspace.floating_layer_behaviour,
globals: workspace.globals,
locked_containers: workspace.locked_containers.clone(),
wallpaper: workspace.wallpaper.clone(),
workspace_config: None,
})
@@ -447,7 +448,7 @@ impl WindowManager {
has_pending_raise_op: false,
pending_move_op: Arc::new(None),
already_moved_window_handles: Arc::new(Mutex::new(HashSet::new())),
uncloak_to_ignore: 0,
uncloack_to_ignore: 0,
known_hwnds: HashMap::new(),
})
}
@@ -821,7 +822,7 @@ impl WindowManager {
target_workspace_idx: usize,
floating: bool,
to_move: &mut Vec<EnforceWorkspaceRuleOp>,
) -> () {
) {
tracing::trace!(
"{} should be on monitor {}, workspace {}",
window_title,
@@ -1234,7 +1235,7 @@ impl WindowManager {
// That workspace reconciliation would focus the window on the origin monitor.
// So we need to ignore the uncloak events produced by the origin workspace
// restore to avoid that issue.
self.uncloak_to_ignore = uncloack_amount;
self.uncloack_to_ignore = uncloack_amount;
}
} else if origin_workspace
.maximized_window()
@@ -1314,43 +1315,67 @@ impl WindowManager {
let (origin_monitor_idx, origin_workspace_idx, origin_container_idx) = origin;
let (target_monitor_idx, target_workspace_idx, target_container_idx) = target;
let origin_container = self
let origin_container_is_valid = self
.monitors_mut()
.get_mut(origin_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(origin_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.remove_container(origin_container_idx)
.ok_or_else(|| anyhow!("there is no container at this index"))?;
.containers()
.get(origin_container_idx)
.is_some();
let target_container = self
let target_container_is_valid = self
.monitors_mut()
.get_mut(target_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(target_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.remove_container(target_container_idx);
.containers()
.get(origin_container_idx)
.is_some();
self.monitors_mut()
.get_mut(target_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(target_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.containers_mut()
.insert(target_container_idx, origin_container);
if let Some(target_container) = target_container {
self.monitors_mut()
if origin_container_is_valid && target_container_is_valid {
let origin_container = self
.monitors_mut()
.get_mut(origin_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(origin_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.remove_container(origin_container_idx)
.ok_or_else(|| anyhow!("there is no container at this index"))?;
let target_container = self
.monitors_mut()
.get_mut(target_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(target_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.remove_container(target_container_idx);
self.monitors_mut()
.get_mut(target_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(target_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.containers_mut()
.insert(origin_container_idx, target_container);
.insert(target_container_idx, origin_container);
if let Some(target_container) = target_container {
self.monitors_mut()
.get_mut(origin_monitor_idx)
.ok_or_else(|| anyhow!("there is no monitor at this index"))?
.workspaces_mut()
.get_mut(origin_workspace_idx)
.ok_or_else(|| anyhow!("there is no workspace at this index"))?
.containers_mut()
.insert(origin_container_idx, target_container);
}
}
Ok(())
@@ -1555,6 +1580,9 @@ impl WindowManager {
workspace.container_padding(),
workspace.layout_flip(),
&[],
workspace.focused_container_idx(),
workspace.layout_options(),
workspace.latest_layout(),
);
let mut direction = direction;
@@ -2932,6 +2960,8 @@ impl WindowManager {
#[tracing::instrument(skip(self))]
pub fn stack_all(&mut self) -> Result<()> {
self.unstack_all(false)?;
self.handle_unmanaged_window_behaviour()?;
tracing::info!("stacking all windows on workspace");
@@ -2958,7 +2988,7 @@ impl WindowManager {
}
#[tracing::instrument(skip(self))]
pub fn unstack_all(&mut self) -> Result<()> {
pub fn unstack_all(&mut self, update_workspace: bool) -> Result<()> {
self.handle_unmanaged_window_behaviour()?;
tracing::info!("unstacking all windows in container");
@@ -2988,7 +3018,11 @@ impl WindowManager {
workspace.focus_container_by_window(hwnd)?;
}
self.update_focused_workspace(self.mouse_follows_focus, true)
if update_workspace {
self.update_focused_workspace(self.mouse_follows_focus, true)?;
}
Ok(())
}
#[tracing::instrument(skip(self))]
@@ -3131,6 +3165,10 @@ impl WindowManager {
pub fn toggle_float(&mut self, force_float: bool) -> Result<()> {
let hwnd = WindowsApi::foreground_window()?;
let workspace = self.focused_workspace_mut()?;
if workspace.monocle_container().is_some() {
tracing::warn!("ignoring toggle-float command while workspace has a monocle container");
return Ok(());
}
let mut is_floating_window = false;
@@ -3154,14 +3192,10 @@ impl WindowManager {
#[tracing::instrument(skip(self))]
pub fn toggle_lock(&mut self) -> Result<()> {
let workspace = self.focused_workspace_mut()?;
let index = workspace.focused_container_idx();
if workspace.locked_containers().contains(&index) {
workspace.locked_containers_mut().remove(&index);
} else {
workspace.locked_containers_mut().insert(index);
if let Some(container) = workspace.focused_container_mut() {
// Toggle the locked flag
container.set_locked(!container.locked());
}
Ok(())
}
@@ -3324,8 +3358,16 @@ impl WindowManager {
pub fn change_workspace_layout_default(&mut self, layout: DefaultLayout) -> Result<()> {
tracing::info!("changing layout");
let monitor_count = self.monitors().len();
let workspace = self.focused_workspace_mut()?;
if monitor_count > 1 && matches!(layout, DefaultLayout::Scrolling) {
tracing::warn!(
"scrolling layout is only supported for a single monitor; not changing layout"
);
return Ok(());
}
match workspace.layout() {
Layout::Default(_) => {}
Layout::Custom(layout) => {
@@ -4288,6 +4330,44 @@ mod tests {
assert_eq!(current_monitor_idx, 0);
}
#[test]
fn test_switch_focus_to_nonexistent_monitor() {
let (mut wm, _test_context) = setup_window_manager();
{
// Create a first monitor
let m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// monitor should have a single workspace
assert_eq!(m.workspaces().len(), 1);
// add the monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should have 1 monitor and the monitor index should be 0
assert_eq!(wm.monitors().len(), 1);
assert_eq!(wm.focused_monitor_idx(), 0);
// Should receive an error when trying to focus a non-existent monitor
let result = wm.focus_monitor(1);
assert!(
result.is_err(),
"Expected an error when focusing a non-existent monitor"
);
// Should still be focused on the first monitor
assert_eq!(wm.focused_monitor_idx(), 0);
}
#[test]
fn test_focused_monitor_size() {
let (mut wm, _test_context) = setup_window_manager();
@@ -4471,6 +4551,64 @@ mod tests {
}
}
#[test]
fn test_transfer_window_to_nonexistent_monitor() {
// NOTE: transfer_window is primarily used when a window is being dragged by a mouse. The
// transfer_window function does return an error when the target monitor doesn't exist but
// there is a bug where the window isn't in the container after the window fails to
// transfer. The test will test for the result of the transfer_window function but not if
// the window is in the container after the transfer fails.
let (mut wm, _context) = setup_window_manager();
{
// Create a first monitor
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Create a container
let workspace = m.focused_workspace_mut().unwrap();
let mut container = Container::default();
// Add a window to the container
container.windows_mut().push_back(Window::from(0));
workspace.add_container_to_back(container);
// Should contain 1 container
assert_eq!(workspace.containers().len(), 1);
wm.monitors_mut().push_back(m);
}
{
// Monitor 0, Workspace 0, Window 0
let origin = (0, 0, 0);
// Monitor 1, Workspace 0, Window 0
//
let target = (1, 0, 0);
// Attempt to transfer the window from monitor 0 to a non-existent monitor
let result = wm.transfer_window(origin, target);
// Result should be an error since the monitor doesn't exist
assert!(
result.is_err(),
"Expected an error when transferring to a non-existent monitor"
);
assert_eq!(wm.focused_container_idx().unwrap(), 0);
assert_eq!(wm.focused_workspace_idx().unwrap(), 0);
}
}
#[test]
fn test_transfer_container() {
let (mut wm, _context) = setup_window_manager();
@@ -4635,6 +4773,44 @@ mod tests {
}
}
#[test]
fn test_remove_nonexistent_window_from_container() {
let (mut wm, _context) = setup_window_manager();
{
// Create a first monitor
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor1".to_string(),
"TestDevice1".to_string(),
"TestDeviceID1".to_string(),
Some("TestMonitorID1".to_string()),
);
// Create a container
let container = Container::default();
// Should have 3 windows in the container
assert_eq!(container.windows().len(), 0);
// Add the container to a workspace
let workspace = m.focused_workspace_mut().unwrap();
workspace.add_container_to_back(container);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should receive an error when trying to remove a window from an empty container
let result = wm.remove_window_from_container();
assert!(
result.is_err(),
"Expected an error when trying to remove a window from an empty container"
);
}
#[test]
fn cycle_container_window_in_direction() {
let (mut wm, _context) = setup_window_manager();
@@ -4704,6 +4880,44 @@ mod tests {
}
}
#[test]
fn test_cycle_nonexistent_windows() {
let (mut wm, _context) = setup_window_manager();
{
// Create a first monitor
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor1".to_string(),
"TestDevice1".to_string(),
"TestDeviceID1".to_string(),
Some("TestMonitorID1".to_string()),
);
// Create a container
let container = Container::default();
// Should have 3 windows in the container
assert_eq!(container.windows().len(), 0);
// Add the container to a workspace
let workspace = m.focused_workspace_mut().unwrap();
workspace.add_container_to_back(container);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should return an error when trying to cycle through windows in an empty container
let result = wm.cycle_container_window_in_direction(CycleDirection::Next);
assert!(
result.is_err(),
"Expected an error when cycling through windows in an empty container"
);
}
#[test]
fn test_cycle_container_window_index_in_direction() {
let (mut wm, _context) = setup_window_manager();
@@ -4862,6 +5076,71 @@ mod tests {
}
}
#[test]
fn test_swap_container_with_nonexistent_container() {
let (mut wm, _context) = setup_window_manager();
{
// Create a first monitor
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Create a container
let mut container = Container::default();
// Add three windows to the container
for i in 0..3 {
container.windows_mut().push_back(Window::from(i));
}
// Should have 3 windows in the container
assert_eq!(container.windows().len(), 3);
// Add the container to the workspace
let workspace = m.focused_workspace_mut().unwrap();
workspace.add_container_to_back(container);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Monitor 0, Workspace 0, Window 0
let origin = (0, 0, 0);
// Monitor 1, Workspace 0, Window 0
let target = (0, 3, 0);
// Should be focused on the first container
assert_eq!(wm.focused_container_idx().unwrap(), 0);
// Should return an error since there is only one container in the workspace
let result = wm.swap_containers(origin, target);
assert!(
result.is_err(),
"Expected an error when swapping with a non-existent container"
);
// Should still be focused on the first container
assert_eq!(wm.focused_container_idx().unwrap(), 0);
{
// Should still have 1 container in the workspace
let workspace = wm.focused_workspace_mut().unwrap();
assert_eq!(workspace.containers().len(), 1);
// Container should still have 3 windows
let container = workspace.focused_container_mut().unwrap();
assert_eq!(container.windows().len(), 3);
}
}
#[test]
fn test_swap_monitor_workspaces() {
let (mut wm, _context) = setup_window_manager();
@@ -4946,6 +5225,52 @@ mod tests {
}
}
#[test]
fn test_swap_workspace_with_nonexistent_monitor() {
let (mut wm, _context) = setup_window_manager();
{
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Add another workspace
let new_workspace_index = m.new_workspace_idx();
m.focus_workspace(new_workspace_index).unwrap();
// Should have 2 workspaces
assert_eq!(m.workspaces().len(), 2);
// Add monitor to window manager
wm.monitors_mut().push_back(m);
}
// Should be an error since Monitor 1 does not exist
let result = wm.swap_monitor_workspaces(1, 0);
assert!(
result.is_err(),
"Expected an error when swapping with a non-existent monitor"
);
{
// Should still have 2 workspaces in Monitor 0
let monitor = wm.monitors().front().unwrap();
let workspaces = monitor.workspaces();
assert_eq!(
workspaces.len(),
2,
"Expected 2 workspaces after swap attempt"
);
assert_eq!(wm.focused_monitor_idx(), 0);
}
}
#[test]
fn test_move_workspace_to_monitor() {
let (mut wm, _context) = setup_window_manager();
@@ -4968,7 +5293,7 @@ mod tests {
// Should have 2 workspaces
assert_eq!(m.workspaces().len(), 2);
// Add monitor to workspace
// Add monitor to window manager
wm.monitors_mut().push_back(m);
}
@@ -5015,6 +5340,42 @@ mod tests {
}
}
#[test]
fn test_move_workspace_to_nonexistent_monitor() {
let (mut wm, _context) = setup_window_manager();
{
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Add another workspace
let new_workspace_index = m.new_workspace_idx();
m.focus_workspace(new_workspace_index).unwrap();
// Should have 2 workspaces
assert_eq!(m.workspaces().len(), 2);
// Add monitor to window manager
wm.monitors_mut().push_back(m);
}
// Attempt to move a workspace to a non-existent monitor
let result = wm.move_workspace_to_monitor(1);
// Should be an error since Monitor 1 does not exist
assert!(
result.is_err(),
"Expected an error when moving to a non-existent monitor"
);
}
#[test]
fn test_toggle_tiling() {
let (mut wm, _context) = setup_window_manager();
@@ -5086,7 +5447,8 @@ mod tests {
{
// Ensure container 2 is not locked
let workspace = wm.focused_workspace_mut().unwrap();
assert!(!workspace.locked_containers().contains(&2));
assert_eq!(workspace.focused_container_idx(), 2);
assert!(!workspace.focused_container().unwrap().locked());
}
// Toggle lock on focused container
@@ -5095,7 +5457,7 @@ mod tests {
{
// Ensure container 2 is locked
let workspace = wm.focused_workspace_mut().unwrap();
assert!(workspace.locked_containers().contains(&2));
assert!(workspace.focused_container().unwrap().locked());
}
// Toggle lock on focused container
@@ -5104,7 +5466,7 @@ mod tests {
{
// Ensure container 2 is not locked
let workspace = wm.focused_workspace_mut().unwrap();
assert!(!workspace.locked_containers().contains(&2));
assert!(!workspace.focused_container().unwrap().locked());
}
}
@@ -5186,6 +5548,40 @@ mod tests {
}
}
#[test]
fn test_float_nonexistent_window() {
let (mut wm, _context) = setup_window_manager();
{
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Add another workspace
let new_workspace_index = m.new_workspace_idx();
m.focus_workspace(new_workspace_index).unwrap();
// Should have 2 workspaces
assert_eq!(m.workspaces().len(), 2);
// Add monitor to window manager
wm.monitors_mut().push_back(m);
}
// Should return an error when trying to float a non-existent window
let result = wm.float_window();
assert!(
result.is_err(),
"Expected an error when trying to float a non-existent window"
);
}
#[test]
fn test_maximize_and_unmaximize_window() {
let (mut wm, _context) = setup_window_manager();
@@ -5332,6 +5728,41 @@ mod tests {
}
}
#[test]
fn test_toggle_maximize_nonexistent_window() {
let (mut wm, _context) = setup_window_manager();
{
// Create a monitor
let mut m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Create a container
let container = Container::default();
// Add the container to the workspace
let workspace = m.focused_workspace_mut().unwrap();
workspace.add_container_to_back(container);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should return an error when trying to toggle maximize on a non-existent window
let result = wm.toggle_maximize();
assert!(
result.is_err(),
"Expected an error when trying to toggle maximize on a non-existent window"
);
}
#[test]
fn test_monocle_on_and_monocle_off() {
let (mut wm, _context) = setup_window_manager();
@@ -5403,6 +5834,41 @@ mod tests {
}
}
#[test]
fn test_monocle_on_and_off_nonexistent_container() {
let (mut wm, _context) = setup_window_manager();
{
// Create a monitor
let m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should return an error when trying to move a non-existent container to monocle
let result = wm.monocle_on();
assert!(
result.is_err(),
"Expected an error when trying to move a non-existent container to monocle"
);
// Should return an error when trying to restore a non-existent container from monocle
let result = wm.monocle_off();
assert!(
result.is_err(),
"Expected an error when trying to restore a non-existent container from monocle"
);
}
#[test]
fn test_toggle_monocle() {
let (mut wm, _context) = setup_window_manager();
@@ -5474,6 +5940,34 @@ mod tests {
}
}
#[test]
fn test_toggle_monocle_nonexistent_container() {
let (mut wm, _context) = setup_window_manager();
{
// Create a monitor
let m = monitor::new(
0,
Rect::default(),
Rect::default(),
"TestMonitor".to_string(),
"TestDevice".to_string(),
"TestDeviceID".to_string(),
Some("TestMonitorID".to_string()),
);
// Add monitor to the window manager
wm.monitors_mut().push_back(m);
}
// Should return an error when trying to toggle monocle on a non-existent container
let result = wm.toggle_monocle();
assert!(
result.is_err(),
"Expected an error when trying to toggle monocle on a non-existent container"
);
}
#[test]
fn test_ensure_named_workspace_for_monitor() {
let (mut wm, _context) = setup_window_manager();

View File

@@ -1180,6 +1180,7 @@ impl WindowsApi {
#[allow(dead_code)]
pub fn enable_focus_follows_mouse() -> Result<()> {
#[allow(clippy::manual_dangling_ptr)]
Self::system_parameters_info_w(
SPI_SETACTIVEWINDOWTRACKING,
0,

View File

@@ -1,4 +1,3 @@
use std::collections::BTreeSet;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::fmt::Display;
@@ -16,7 +15,8 @@ use crate::core::DefaultLayout;
use crate::core::Layout;
use crate::core::OperationDirection;
use crate::core::Rect;
use crate::locked_deque::LockedDeque;
use crate::default_layout::LayoutOptions;
use crate::lockable_sequence::LockableSequence;
use crate::ring::Ring;
use crate::should_act;
use crate::stackbar_manager;
@@ -70,6 +70,8 @@ pub struct Workspace {
pub floating_windows: Ring<Window>,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub layout: Layout,
#[getset(get_copy = "pub", set = "pub")]
pub layout_options: Option<LayoutOptions>,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub layout_rules: Vec<(usize, Layout)>,
#[getset(get_copy = "pub", set = "pub")]
@@ -85,6 +87,8 @@ pub struct Workspace {
#[getset(get = "pub", set = "pub")]
pub tile: bool,
#[getset(get_copy = "pub", set = "pub")]
pub work_area_offset: Option<Rect>,
#[getset(get_copy = "pub", set = "pub")]
pub apply_window_based_work_area_offset: bool,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub window_container_behaviour: Option<WindowContainerBehaviour>,
@@ -100,8 +104,6 @@ pub struct Workspace {
#[getset(get_copy = "pub", get_mut = "pub", set = "pub")]
pub floating_layer_behaviour: Option<FloatingLayerBehaviour>,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub locked_containers: BTreeSet<usize>,
#[getset(get = "pub", get_mut = "pub", set = "pub")]
pub wallpaper: Option<Wallpaper>,
#[serde(skip_serializing_if = "Option::is_none")]
#[getset(get = "pub", set = "pub")]
@@ -139,6 +141,7 @@ impl Default for Workspace {
monocle_container_restore_idx: None,
floating_windows: Ring::default(),
layout: Layout::Default(DefaultLayout::BSP),
layout_options: None,
layout_rules: vec![],
layout_flip: None,
workspace_padding: Option::from(DEFAULT_WORKSPACE_PADDING.load(Ordering::SeqCst)),
@@ -146,6 +149,7 @@ impl Default for Workspace {
latest_layout: vec![],
resize_dimensions: vec![],
tile: true,
work_area_offset: None,
apply_window_based_work_area_offset: true,
window_container_behaviour: None,
window_container_behaviour_rules: None,
@@ -154,7 +158,6 @@ impl Default for Workspace {
floating_layer_behaviour: Default::default(),
globals: Default::default(),
workspace_config: None,
locked_containers: Default::default(),
wallpaper: None,
}
}
@@ -205,18 +208,16 @@ impl Workspace {
if let Some(layout) = &config.layout {
self.layout = Layout::Default(*layout);
self.tile = true;
}
if let Some(pathbuf) = &config.custom_layout {
let layout = CustomLayout::from_path(pathbuf)?;
self.layout = Layout::Custom(layout);
self.tile = true;
}
if config.custom_layout.is_none() && config.layout.is_none() {
self.tile = false;
}
self.tile =
!(config.custom_layout.is_none() && config.layout.is_none() && config.tile.is_none()
|| config.tile.is_some_and(|tile| !tile));
let mut all_layout_rules = vec![];
if let Some(layout_rules) = &config.layout_rules {
@@ -241,6 +242,8 @@ impl Workspace {
self.set_layout_rules(all_layout_rules);
}
self.set_work_area_offset(config.work_area_offset);
self.set_apply_window_based_work_area_offset(
config.apply_window_based_work_area_offset.unwrap_or(true),
);
@@ -267,6 +270,7 @@ impl Workspace {
self.set_layout_flip(config.layout_flip);
self.set_floating_layer_behaviour(config.floating_layer_behaviour);
self.set_wallpaper(config.wallpaper.clone());
self.set_layout_options(config.layout_options);
self.set_workspace_config(Some(config.clone()));
@@ -495,7 +499,7 @@ 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.globals().work_area_offset;
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;
@@ -583,6 +587,9 @@ impl Workspace {
Some(container_padding),
self.layout_flip(),
self.resize_dimensions(),
self.focused_container_idx(),
self.layout_options(),
self.latest_layout(),
);
let should_remove_titlebars = REMOVE_TITLEBARS.load(Ordering::SeqCst);
@@ -890,10 +897,9 @@ impl Workspace {
// this fn respects locked container indexes - we should use it for pretty much everything
// except monocle and maximize toggles
pub fn insert_container_at_idx(&mut self, idx: usize, container: Container) -> usize {
let mut locked_containers = self.locked_containers().clone();
let mut ld = LockedDeque::new(self.containers_mut(), &mut locked_containers);
let insertion_idx = ld.insert(idx, container);
self.locked_containers = locked_containers;
let insertion_idx = self
.containers_mut()
.insert_respecting_locks(idx, container);
if insertion_idx > self.resize_dimensions().len() {
self.resize_dimensions_mut().push(None);
@@ -909,10 +915,7 @@ impl Workspace {
// this fn respects locked container indexes - we should use it for pretty much everything
// except monocle and maximize toggles
pub fn remove_container_by_idx(&mut self, idx: usize) -> Option<Container> {
let mut locked_containers = self.locked_containers().clone();
let mut ld = LockedDeque::new(self.containers_mut(), &mut locked_containers);
let container = ld.remove(idx);
self.locked_containers = locked_containers;
let container = self.containers_mut().remove_respecting_locks(idx);
if idx < self.resize_dimensions().len() {
self.resize_dimensions_mut().remove(idx);
@@ -1194,6 +1197,9 @@ impl Workspace {
Layout::Default(DefaultLayout::UltrawideVerticalStack) => {
self.enforce_resize_for_ultrawide();
}
Layout::Default(DefaultLayout::Scrolling) => {
self.enforce_resize_for_scrolling();
}
_ => self.enforce_no_resize(),
}
}
@@ -1421,6 +1427,28 @@ impl Workspace {
}
}
fn enforce_resize_for_scrolling(&mut self) {
let resize_dimensions = self.resize_dimensions_mut();
match resize_dimensions.len() {
0 | 1 => self.enforce_no_resize(),
_ => {
let len = resize_dimensions.len();
for (i, rect) in resize_dimensions.iter_mut().enumerate() {
if let Some(rect) = rect {
rect.top = 0;
rect.bottom = 0;
if i == 0 {
rect.left = 0;
} else if i == len - 1 {
rect.right = 0;
}
}
}
}
}
}
fn enforce_no_resize(&mut self) {
for rect in self.resize_dimensions_mut().iter_mut().flatten() {
rect.left = 0;
@@ -1601,7 +1629,7 @@ impl Workspace {
}
pub fn swap_containers(&mut self, i: usize, j: usize) {
self.containers.swap(i, j);
self.containers.elements_mut().swap_respecting_locks(i, j);
self.focus_container(j);
}
@@ -1700,7 +1728,6 @@ mod tests {
use super::*;
use crate::container::Container;
use crate::Window;
use std::collections::BTreeSet;
use std::collections::HashMap;
#[test]
@@ -1708,20 +1735,18 @@ mod tests {
let mut ws = Workspace::default();
let mut state = HashMap::new();
let mut locked = BTreeSet::new();
// add 3 containers
// add 4 containers
for i in 0..4 {
let container = Container::default();
let mut container = Container::default();
if i == 3 {
container.set_locked(true); // set index 3 locked
}
state.insert(i, container.id().to_string());
ws.add_container_to_back(container);
}
assert_eq!(ws.containers().len(), 4);
// set index 3 locked
locked.insert(3);
ws.locked_containers = locked;
// focus container at index 2
ws.focus_container(2);
@@ -1753,20 +1778,17 @@ mod tests {
fn test_locked_containers_remove_window() {
let mut ws = Workspace::default();
let mut locked = BTreeSet::new();
// add 4 containers
for i in 0..4 {
let mut container = Container::default();
container.windows_mut().push_back(Window::from(i));
if i == 1 {
container.set_locked(true);
}
ws.add_container_to_back(container);
}
assert_eq!(ws.containers().len(), 4);
// set index 1 locked
locked.insert(1);
ws.locked_containers = locked;
ws.remove_window(0).unwrap();
assert_eq!(ws.containers()[0].focused_window().unwrap().hwnd, 2);
// index 1 should still be the same
@@ -1778,20 +1800,17 @@ mod tests {
fn test_locked_containers_toggle_float() {
let mut ws = Workspace::default();
let mut locked = BTreeSet::new();
// add 4 containers
for i in 0..4 {
let mut container = Container::default();
container.windows_mut().push_back(Window::from(i));
if i == 1 {
container.set_locked(true);
}
ws.add_container_to_back(container);
}
assert_eq!(ws.containers().len(), 4);
// set index 1 locked
locked.insert(1);
ws.locked_containers = locked;
// set index 0 focused
ws.focus_container(0);
@@ -1823,20 +1842,17 @@ mod tests {
fn test_locked_containers_stack() {
let mut ws = Workspace::default();
let mut locked = BTreeSet::new();
// add 6 containers
for i in 0..6 {
let mut container = Container::default();
container.windows_mut().push_back(Window::from(i));
if i == 4 {
container.set_locked(true);
}
ws.add_container_to_back(container);
}
assert_eq!(ws.containers().len(), 6);
// set index 4 locked
locked.insert(4);
ws.locked_containers = locked;
// set index 3 focused
ws.focus_container(3);
@@ -1939,6 +1955,33 @@ mod tests {
assert_eq!(container.windows().len(), 3);
}
#[test]
fn test_remove_non_existent_window() {
let mut workspace = Workspace::default();
{
// Add a container with one window
let mut container = Container::default();
container.windows_mut().push_back(Window::from(1));
workspace.add_container_to_back(container);
}
// Attempt to remove a non-existent window
let result = workspace.remove_window(2);
// Should return an error
assert!(
result.is_err(),
"Expected an error when removing a non-existent window"
);
// Get focused container. Should be the index of the last container added
let container = workspace.focused_container_mut().unwrap();
// Should still have 1 window
assert_eq!(container.windows().len(), 1);
}
#[test]
fn test_remove_focused_container() {
let mut workspace = Workspace::default();
@@ -2264,6 +2307,25 @@ mod tests {
assert_eq!(container.windows().len(), 1);
}
#[test]
fn test_move_window_to_non_existent_container() {
let mut workspace = Workspace::default();
// Add a container with one window
let mut container = Container::default();
container.windows_mut().push_back(Window::from(1));
workspace.add_container_to_back(container);
// Try to move window to a non-existent container
let result = workspace.move_window_to_container(8);
// Should return an error
assert!(
result.is_err(),
"Expected an error when moving a window to a non-existent container"
);
}
#[test]
fn test_remove_window() {
let mut workspace = Workspace::default();

View File

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

View File

@@ -184,6 +184,10 @@ MonitorWorkAreaOffset(monitor, left, top, right, bottom) {
RunWait("komorebic.exe monitor-work-area-offset " monitor " " left " " top " " right " " bottom, , "Hide")
}
WorkspaceWorkAreaOffset(monitor, workspace, left, top, right, bottom) {
RunWait("komorebic.exe workspace-work-area-offset " monitor " "workspace" " left " " top " " right " " bottom, , "Hide")
}
AdjustContainerPadding(sizing, adjustment) {
RunWait("komorebic.exe adjust-container-padding " sizing " " adjustment, , "Hide")
}

View File

@@ -1,6 +1,6 @@
[package]
name = "komorebic"
version = "0.1.37"
version = "0.1.38"
description = "The command-line interface for Komorebi, a tiling window manager for Windows"
repository = "https://github.com/LGUG2Z/komorebi"
edition = "2021"
@@ -8,7 +8,7 @@ edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
komorebi-client = { path = "../komorebi-client" }
komorebi-client = { path = "../komorebi-client", default-features = false }
chrono = { workspace = true }
clap = { workspace = true }
@@ -36,7 +36,7 @@ shadow-rs = { workspace = true }
[features]
default = ["schemars"]
schemars = ["dep:schemars", "komorebi-client/schemars"]
schemars = ["dep:schemars", "komorebi-client/default"]
[lints.rust]
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(FALSE)'] }

View File

@@ -6,9 +6,11 @@ use komorebi_client::replace_env_in_path;
use komorebi_client::PathExt;
use std::fs::File;
use std::fs::OpenOptions;
use std::io;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Write;
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::process::Command;
use std::sync::atomic::AtomicBool;
@@ -91,8 +93,7 @@ lazy_static! {
assert!(
whkd_config_home.is_dir(),
"$Env:WHKD_CONFIG_HOME is set to '{}', which is not a valid directory",
home_path
"$Env:WHKD_CONFIG_HOME is set to '{home_path}', which is not a valid directory"
);
whkd_config_home
@@ -428,6 +429,22 @@ struct MonitorWorkAreaOffset {
bottom: i32,
}
#[derive(Parser)]
struct WorkspaceWorkAreaOffset {
/// Monitor index (zero-indexed)
monitor: usize,
/// Workspace index (zero-indexed)
workspace: usize,
/// Size of the left work area offset (set right to left * 2 to maintain right padding)
left: i32,
/// Size of the top work area offset (set bottom to the same value to maintain bottom padding)
top: i32,
/// Size of the right work area offset
right: i32,
/// Size of the bottom work area offset
bottom: i32,
}
#[derive(Parser)]
struct MonitorIndexPreference {
/// Preferred monitor index (zero-indexed)
@@ -963,6 +980,12 @@ struct EagerFocus {
exe: String,
}
#[derive(Parser)]
struct ScrollingLayoutColumns {
/// Desired number of visible columns
count: NonZeroUsize,
}
#[derive(Parser)]
#[clap(author, about, version = build::CLAP_LONG_VERSION)]
struct Opts {
@@ -1182,6 +1205,9 @@ enum SubCommand {
/// Set offsets for a monitor to exclude parts of the work area from tiling
#[clap(arg_required_else_help = true)]
MonitorWorkAreaOffset(MonitorWorkAreaOffset),
/// Set offsets for a workspace to exclude parts of the work area from tiling
#[clap(arg_required_else_help = true)]
WorkspaceWorkAreaOffset(WorkspaceWorkAreaOffset),
/// Toggle application of the window-based work area offset for the focused workspace
ToggleWindowBasedWorkAreaOffset,
/// Set container padding on the focused workspace
@@ -1202,6 +1228,9 @@ enum SubCommand {
/// Cycle between available layouts
#[clap(arg_required_else_help = true)]
CycleLayout(CycleLayout),
/// Set the number of visible columns for the Scrolling layout on the focused workspace
#[clap(arg_required_else_help = true)]
ScrollingLayoutColumns(ScrollingLayoutColumns),
/// Load a custom layout from file for the focused workspace
#[clap(hide = true)]
#[clap(arg_required_else_help = true)]
@@ -1298,7 +1327,7 @@ enum SubCommand {
ToggleWorkspaceFloatOverride,
/// Toggle between the Tiling and Floating layers on the focused workspace
ToggleWorkspaceLayer,
/// Toggle window tiling on the focused workspace
/// Toggle the paused state for all window tiling
TogglePause,
/// Toggle window tiling on the focused workspace
ToggleTiling,
@@ -1552,6 +1581,33 @@ fn main() -> Result<()> {
}
}
SubCommand::Quickstart => {
fn write_file_with_prompt(
path: &PathBuf,
content: &str,
created_files: &mut Vec<String>,
) -> Result<()> {
if path.exists() {
print!(
"{} will be overwritten, do you want to continue? (y/N): ",
path.display()
);
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let trimmed = input.trim().to_lowercase();
if trimmed == "y" || trimmed == "yes" {
std::fs::write(path, content)?;
created_files.push(path.display().to_string());
} else {
println!("Skipping {}", path.display());
}
} else {
std::fs::write(path, content)?;
created_files.push(path.display().to_string());
}
Ok(())
}
let local_appdata_dir = data_local_dir().expect("could not find localdata dir");
let data_dir = local_appdata_dir.join("komorebi");
std::fs::create_dir_all(&*WHKD_CONFIG_DIR)?;
@@ -1567,17 +1623,30 @@ fn main() -> Result<()> {
komorebi_json.replace("Env:USERPROFILE", "Env:KOMOREBI_CONFIG_HOME");
}
std::fs::write(HOME_DIR.join("komorebi.json"), komorebi_json)?;
std::fs::write(HOME_DIR.join("komorebi.bar.json"), komorebi_bar_json)?;
let komorebi_path = HOME_DIR.join("komorebi.json");
let bar_path = HOME_DIR.join("komorebi.bar.json");
let applications_path = HOME_DIR.join("applications.json");
let whkdrc_path = WHKD_CONFIG_DIR.join("whkdrc");
let mut written_files = Vec::new();
write_file_with_prompt(&komorebi_path, &komorebi_json, &mut written_files)?;
write_file_with_prompt(&bar_path, &komorebi_bar_json, &mut written_files)?;
let applications_json = include_str!("../applications.json");
std::fs::write(HOME_DIR.join("applications.json"), applications_json)?;
write_file_with_prompt(&applications_path, applications_json, &mut written_files)?;
let whkdrc = include_str!("../../docs/whkdrc.sample");
std::fs::write(WHKD_CONFIG_DIR.join("whkdrc"), whkdrc)?;
println!("Example komorebi.json, komorebi.bar.json, whkdrc and latest applications.json files created");
println!("You can now run komorebic start --whkd --bar");
write_file_with_prompt(&whkdrc_path, whkdrc, &mut written_files)?;
if written_files.is_empty() {
println!("\nNo files were written.")
} else {
println!(
"\nThe following example files were written:\n{}",
written_files.join("\n")
);
}
println!("\nYou can now run komorebic start --whkd --bar");
}
SubCommand::EnableAutostart(args) => {
let mut current_exe = std::env::current_exe().expect("unable to get exec path");
@@ -1929,6 +1998,20 @@ fn main() -> Result<()> {
bottom: arg.bottom,
}))?;
}
SubCommand::WorkspaceWorkAreaOffset(arg) => {
send_message(&SocketMessage::WorkspaceWorkAreaOffset(
arg.monitor,
arg.workspace,
Rect {
left: arg.left,
top: arg.top,
right: arg.right,
bottom: arg.bottom,
},
))?;
}
SubCommand::ToggleWindowBasedWorkAreaOffset => {
send_message(&SocketMessage::ToggleWindowBasedWorkAreaOffset)?;
}
@@ -2625,6 +2708,9 @@ if (Get-Command Get-CimInstance -ErrorAction SilentlyContinue) {
SubCommand::CycleLayout(arg) => {
send_message(&SocketMessage::CycleLayout(arg.cycle_direction))?;
}
SubCommand::ScrollingLayoutColumns(arg) => {
send_message(&SocketMessage::ScrollingLayoutColumns(arg.count))?;
}
SubCommand::LoadCustomLayout(arg) => {
send_message(&SocketMessage::ChangeLayoutCustom(arg.path))?;
}

View File

@@ -80,7 +80,6 @@ nav:
- common-workflows/tray-and-multi-window-applications.md
- common-workflows/mouse-follows-focus.md
- common-workflows/dynamic-layout-switching.md
- common-workflows/set-display-index.md
- common-workflows/multiple-bar-instances.md
- common-workflows/multi-monitor-setup.md
- CLI reference:
@@ -96,6 +95,7 @@ nav:
- cli/state.md
- cli/global-state.md
- cli/gui.md
- cli/toggle-shortcuts.md
- cli/visible-windows.md
- cli/monitor-information.md
- cli/query.md
@@ -158,6 +158,7 @@ nav:
- cli/invisible-borders.md
- cli/global-work-area-offset.md
- cli/monitor-work-area-offset.md
- cli/workspace-work-area-offset.md
- cli/toggle-window-based-work-area-offset.md
- cli/focused-workspace-container-padding.md
- cli/focused-workspace-padding.md
@@ -165,6 +166,7 @@ nav:
- cli/adjust-workspace-padding.md
- cli/change-layout.md
- cli/cycle-layout.md
- cli/scrolling-layout-columns.md
- cli/flip-layout.md
- cli/promote.md
- cli/promote-focus.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "StaticConfig",
"description": "The `komorebi.json` static configuration file reference for `v0.1.37`",
"description": "The `komorebi.json` static configuration file reference for `v0.1.38`",
"type": "object",
"properties": {
"animation": {
@@ -1790,7 +1790,8 @@
"HorizontalStack",
"UltrawideVerticalStack",
"Grid",
"RightMainVerticalStack"
"RightMainVerticalStack",
"Scrolling"
]
},
"layout_flip": {
@@ -1802,6 +1803,27 @@
"HorizontalAndVertical"
]
},
"layout_options": {
"description": "Layout-specific options (default: None)",
"type": "object",
"properties": {
"scrolling": {
"description": "Options related to the Scrolling layout",
"type": "object",
"required": [
"columns"
],
"properties": {
"columns": {
"description": "Desired number of visible columns (default: 3)",
"type": "integer",
"format": "uint",
"minimum": 0.0
}
}
}
}
},
"layout_rules": {
"description": "Layout rules in the format of threshold => layout (default: None)",
"type": "object",
@@ -1815,7 +1837,8 @@
"HorizontalStack",
"UltrawideVerticalStack",
"Grid",
"RightMainVerticalStack"
"RightMainVerticalStack",
"Scrolling"
]
}
},
@@ -1823,6 +1846,10 @@
"description": "Name",
"type": "string"
},
"tile": {
"description": "Enable or disable tiling for the workspace (default: true)",
"type": "boolean"
},
"wallpaper": {
"description": "Specify a wallpaper for this workspace",
"type": "object",
@@ -2115,6 +2142,38 @@
]
}
},
"work_area_offset": {
"description": "Workspace specific work area offset (default: None)",
"type": "object",
"required": [
"bottom",
"left",
"right",
"top"
],
"properties": {
"bottom": {
"description": "The bottom point in a Win32 Rect",
"type": "integer",
"format": "int32"
},
"left": {
"description": "The left point in a Win32 Rect",
"type": "integer",
"format": "int32"
},
"right": {
"description": "The right point in a Win32 Rect",
"type": "integer",
"format": "int32"
},
"top": {
"description": "The top point in a Win32 Rect",
"type": "integer",
"format": "int32"
}
}
},
"workspace_padding": {
"description": "Workspace padding (default: global)",
"type": "integer",
@@ -2498,7 +2557,7 @@
]
},
"mode": {
"description": "Stackbar mode",
"description": "Stackbar mode (default: Never)",
"type": "string",
"enum": [
"Always",
@@ -4627,7 +4686,7 @@
"description": "Which Windows signal to use when hiding windows (default: Cloak)",
"oneOf": [
{
"description": "Use the SW_HIDE flag to hide windows when switching workspaces (has issues with Electron apps)",
"description": "END OF LIFE FEATURE: Use the SW_HIDE flag to hide windows when switching workspaces (has issues with Electron apps)",
"type": "string",
"enum": [
"Hide"