217 Commits

Author SHA1 Message Date
Jake Stanger
dd7a761484 [wip] volume 2023-04-01 13:07:47 +01:00
Jake Stanger
72ba17add3 Merge pull request #92 from JakeStanger/update_flake_lock_action
Update flake.lock
2023-04-01 11:14:03 +01:00
github-actions[bot]
2b07620847 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/7f5639fa3b68054ca0b062866dc62b22c3f11505' (2023-02-26)
  → 'github:nixos/nixpkgs/e3652e0735fbec227f342712f180f4f21f0594f2' (2023-03-30)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/c1df023b1aaded1b65a1f4ad604a98a58ab4db97' (2023-02-28)
  → 'github:oxalica/rust-overlay/aa480d799023141e1b9e5d6108700de63d9ad002' (2023-03-31)
2023-04-01 00:57:22 +00:00
Jake Stanger
ba488ad38f Merge pull request #89 from yavko/fix-hm-module
Fix home manager module, and features
2023-03-29 12:51:55 +01:00
yavko
d0b7bdbafc fix(nix): home manager module, and features 2023-03-29 01:45:40 -07:00
Jake Stanger
0f5ec1fe34 Merge pull request #85 from JakeStanger/refactor/config
Use `universal-config` crate for config
2023-03-19 16:37:20 +00:00
Jake Stanger
6221f7454a refactor: fix new clippy warnings 2023-03-19 16:22:40 +00:00
Jake Stanger
ecdd71a43d refactor(config): use universal-config crate.
XML config is not supported.
2023-03-19 16:22:40 +00:00
Jake Stanger
01a36a9476 build: update gtk deps 2023-03-19 00:14:59 +00:00
Jake Stanger
d4dd8c41ea chore: improve image provider logging 2023-03-04 23:13:35 +00:00
Jake Stanger
83c5dceaa7 chore: clean up println calls 2023-03-04 23:13:22 +00:00
Jake Stanger
711644e190 Merge pull request #81 from JakeStanger/fix/dynamic-string-ordering
Fix dynamic string ordering
2023-03-01 23:20:41 +00:00
Jake Stanger
8cbb73b75e fix(dynamic string): dynamic sections not respecting ordering
Fixes #69.
2023-03-01 23:09:34 +00:00
Jake Stanger
7212bbcf61 refactor(dynamic string): use vec instead of indexmap 2023-03-01 23:09:01 +00:00
Jake Stanger
0125ce5916 docs(examples): update styles example 2023-03-01 20:35:41 +00:00
Jake Stanger
2b26eaf410 docs(clipboard): fix incorrect setting description 2023-03-01 20:35:31 +00:00
Jake Stanger
33676fc4dc ci(nix): fix cachix error 2023-03-01 20:35:12 +00:00
Jake Stanger
7978c48d5c Merge pull request #79 from JakeStanger/update_flake_lock_action
Update flake.lock
2023-03-01 12:55:52 +00:00
Jake Stanger
1d37e010c8 Merge pull request #66 from yavko/add-nix-flags
Add initial nix flags impl
2023-03-01 12:55:25 +00:00
yavko
54b9b28c75 fix: make readme more concise 2023-02-28 19:11:18 -08:00
yavko
3a44d74cf3 style(nix): fmt flake.nix 2023-02-28 19:08:43 -08:00
yavko
b1475a1aff feat(nix): use cargo default features 2023-02-28 19:07:12 -08:00
yavko
b2749fee92 style(nix): fmt flake.nix 2023-02-28 19:07:03 -08:00
yavko
9984b638b5 feat(nix): initial nix feature flags impl 2023-02-28 19:06:53 -08:00
github-actions[bot]
207b60db7e flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/2caf4ef5005ecc68141ecb4aac271079f7371c44' (2023-01-30)
  → 'github:nixos/nixpkgs/7f5639fa3b68054ca0b062866dc62b22c3f11505' (2023-02-26)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/48b1403150c3f5a9aeee8bc4c77c8926f29c6501' (2023-01-31)
  → 'github:oxalica/rust-overlay/c1df023b1aaded1b65a1f4ad604a98a58ab4db97' (2023-02-28)
2023-03-01 01:09:08 +00:00
Jake Stanger
7779c33e0c Merge pull request #77 from JakeStanger/feat/clipboard-manager
Clipboard manager module
2023-02-28 17:54:06 +00:00
Jake Stanger
575d6cc30f feat: new clipboard manager module 2023-02-26 13:42:53 +00:00
Jake Stanger
5bbe64bb86 docs(clock): format table 2023-02-25 14:29:38 +00:00
Jake Stanger
83a49165c4 docs(compiling): add info about build deps 2023-02-25 14:29:38 +00:00
Jake Stanger
d84139a914 refactor: general tidy up
fix clippy warnings from latest stable rust
2023-02-25 14:26:02 +00:00
Jake Stanger
ca4fe422f2 feat(truncate): ability to set fixed length
BREAKING CHANGE: This changes the behaviour of `truncate.length`. A new property, `truncate.max_length`, has been introduced that uses the old behaviour.
2023-02-25 14:26:02 +00:00
Jake Stanger
1ad1961396 Merge pull request #67 from ttoino/feature/margin
Add configurable margins around bar
2023-02-08 19:42:15 +00:00
toino
d253c4bd7f feat: add configurable margins around bar 2023-02-08 18:47:21 +00:00
Jake Stanger
fbee6e8bd4 style: run fmt 2023-02-08 17:30:09 +00:00
Jake Stanger
7c36f5cb0c docs: fix a couple of issues 2023-02-02 20:37:16 +00:00
Jake Stanger
7dff3e6f8b fix(image): widgets missing names 2023-02-02 20:37:02 +00:00
Jake Stanger
2ac507144b fix: not setting layer shell namespace 2023-02-02 20:36:31 +00:00
JakeStanger
82875cde68 docs: update CHANGELOG.md for v0.10.0 [skip ci] 2023-02-01 22:22:19 +00:00
Jake Stanger
d40b3b7d80 chore(release): v0.10.0 2023-02-01 22:21:07 +00:00
Jake Stanger
181561fe2a Merge pull request #64 from JakeStanger/feat/build-flags
feat: add feature flags
2023-02-01 22:09:23 +00:00
Jake Stanger
7b23e61e7d docs(wiki): update screenshots and examples 2023-02-01 22:06:09 +00:00
Jake Stanger
6a39905b43 docs(compiling): add missing full stop 2023-02-01 21:08:03 +00:00
Jake Stanger
2780d98ee0 Merge branch 'master' into feat/build-flags
# Conflicts:
#	src/image/provider.rs
2023-02-01 21:07:36 +00:00
Jake Stanger
51d2c2279f fix(images): incorrectly resolving non-files 2023-02-01 21:05:58 +00:00
Jake Stanger
c347b6c944 feat: add feature flags
Flags allow you to disable certain functionality and compile with only select features to reduce build time.

Resolves #54.
2023-02-01 20:45:52 +00:00
Jake Stanger
e83618b1d6 ci: fix not updating system packages 2023-02-01 17:52:46 +00:00
Jake Stanger
90f57d61b9 docs(music): remove irrelevant icon format token
BREAKING CHANGE: (Missed from #96141d4) The `{icon}` token has been removed from the `music` module due to incompatibility with the new image/icon support. The icon now always displays as a separate widget before the label and should be removed from your formatting string.
2023-02-01 17:52:34 +00:00
Jake Stanger
0b9af6bb26 Merge pull request #63 from JakeStanger/update_flake_lock_action
Update flake.lock
2023-02-01 16:02:09 +00:00
github-actions[bot]
11a65d4fbc flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/9b97ad7b4330aacda9b2343396eb3df8a853b4fc' (2023-01-25)
  → 'github:nixos/nixpkgs/2caf4ef5005ecc68141ecb4aac271079f7371c44' (2023-01-30)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/edd082ca16aa055d5504bea39da36b3ee68e4f1d' (2023-01-29)
  → 'github:oxalica/rust-overlay/48b1403150c3f5a9aeee8bc4c77c8926f29c6501' (2023-01-31)
2023-02-01 01:07:22 +00:00
Jake Stanger
054262365e Merge pull request #61 from JakeStanger/fix/hyprland-workspaces
fix(hyprland): issues with tracking workspaces
2023-01-30 22:38:05 +00:00
Jake Stanger
058c8f4228 fix(hyprland): issues with tracking workspaces 2023-01-30 22:24:00 +00:00
Jake Stanger
d78d851858 Merge pull request #60 from JakeStanger/fix/tray
fix(tray): some init issues
2023-01-30 18:49:45 +00:00
Jake Stanger
db72bc09b4 chore(hyprland): add debug logging 2023-01-30 18:49:30 +00:00
Jake Stanger
5fb412572f fix(tray): some init issues
It ain't perfect but it'll do.

Resolves #2.
2023-01-30 18:36:42 +00:00
Jake Stanger
400ac00d23 Merge pull request #59 from JakeStanger/feat/better-images
Better images
2023-01-30 12:13:10 +00:00
Jake Stanger
80a4b1d177 build(nix): update flake 2023-01-30 11:51:01 +00:00
Jake Stanger
96141d4990 feat(music): support for using images in name_map, additional icon options 2023-01-30 11:51:01 +00:00
Jake Stanger
b054c17d14 feat(workspaces): support for using images in name_map 2023-01-30 11:51:01 +00:00
Jake Stanger
3cf9be89fd feat: global icon theme setting
BREAKING CHANGE: This removes the `icon_theme` option from `launcher` and `focused`. You will need to set this at the top of your config instead.
2023-01-30 11:51:01 +00:00
Jake Stanger
393800aaa2 feat(custom): image widget 2023-01-30 11:51:01 +00:00
Jake Stanger
5772711192 fix(music): remote mpris album art not showing
Fixes #55.
2023-01-30 11:47:56 +00:00
Jake Stanger
15f0857859 refactor: replace icon loading with improved general image loading 2023-01-29 17:46:02 +00:00
Jake Stanger
8ba9826cd9 Merge pull request #58 from JakeStanger/feat/focus-trunc
feat(focused): ability to truncate label text
2023-01-28 23:14:08 +00:00
Jake Stanger
07dbf78010 feat(focused): ability to truncate label text 2023-01-28 23:01:44 +00:00
Jake Stanger
97502559b3 refactor(music): split config code into separate file 2023-01-28 22:43:22 +00:00
Jake Stanger
2b0eb6506a Merge pull request #57 from JakeStanger/feat/music-trunc
feat(music): ability to truncate button text
2023-01-28 22:23:55 +00:00
Jake Stanger
012762e102 refactor: swap out some code for existing macros 2023-01-28 22:07:05 +00:00
Jake Stanger
8691824db1 feat(music): ability to truncate button text
Adds new `truncate.mode` and `truncate.length` options, and `truncate` shorthand for mode.

Resolves #56.
2023-01-28 22:07:05 +00:00
Jake Stanger
ad97550583 build: update deps 2023-01-28 22:06:47 +00:00
JakeStanger
1ed3220733 docs: update CHANGELOG.md for v0.9.0 [skip ci] 2023-01-28 14:50:58 +00:00
Jake Stanger
c906dd40fb chore(release): v0.9.0 2023-01-28 14:49:53 +00:00
Jake Stanger
eb30105fc2 style: fix 1.67 clippy warnings 2023-01-28 14:40:31 +00:00
Jake Stanger
90cd078973 fix(mpd): stops working if connection lost
The client will now attempt to reconnect when a connection loss is detected.

Fixes #21.
2023-01-28 14:40:12 +00:00
Jake Stanger
1cdfebf8db Merge pull request #53 from JakeStanger/feat/hyprland-workspaces
feat(workspaces): hyprland support
2023-01-28 00:53:23 +00:00
Jake Stanger
0cefcbd02b fix(music): wrong widget name on vol slider 2023-01-28 00:51:24 +00:00
Jake Stanger
08cfbbc2ea fix(music): unable to go to prev with mpris 2023-01-28 00:43:02 +00:00
Jake Stanger
e1f523cf2a fix(music): popup artist label using wrong name 2023-01-28 00:27:22 +00:00
Jake Stanger
c223892a57 docs(workspaces): update for hyprland/new ordering option 2023-01-27 23:18:59 +00:00
Jake Stanger
9ba28fe7fa feat(workspaces): better ordering
Includes option to revert to previous (lack of) ordering method if preferred.
2023-01-27 23:18:59 +00:00
Jake Stanger
0d7ab54160 refactor: remove redundant clone 2023-01-27 23:18:59 +00:00
Jake Stanger
6e5d0c1e8c feat(workspaces): hyprland support
Resolves #18.

The bar will now automatically detect whether running under Sway or Hyprland and use the correct IPC client depending.
2023-01-27 23:18:59 +00:00
Jake Stanger
a79900d842 Merge pull request #52 from JakeStanger/feat/mpris
feat: mpris support
2023-01-25 23:20:31 +00:00
Jake Stanger
6d8e647f12 feat: mpris support
Resolves #25.

Completely refactors the MPD module to be the 'music' module. This now supports both MPD and MPRIS with the same UI for both.

BREAKING CHANGE: The `mpd` module has been renamed to `music`. You will need to update the `type` value in your config and add `player_type` to continue using MPD. You will also need to update your styles.
2023-01-25 23:09:49 +00:00
Jake Stanger
1949d07721 chore(github): update issue templates 2023-01-04 17:36:19 +00:00
Jake Stanger
f779520545 Merge pull request #48 from colemickens/master
s/pkgs.system/pkgs.hostPlatform.system/g
2023-01-03 21:30:35 +00:00
Cole Mickens
df7c447e9c s/pkgs.system/pkgs.hostPlatform.system/g 2023-01-02 23:52:06 -08:00
Jake Stanger
90b9d70941 Merge pull request #47 from JakeStanger/update_flake_lock_action
Update flake.lock
2023-01-01 12:07:52 +00:00
github-actions[bot]
da806d38c6 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/e76c78d20685a043d23f5f9e0ccd2203997f1fb1' (2022-11-30)
  → 'github:nixos/nixpkgs/677ed08a50931e38382dbef01cba08a8f7eac8f6' (2022-12-29)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/bfdf688742cf984c4837dbbe1c6cbca550365613' (2022-12-01)
  → 'github:oxalica/rust-overlay/176b6fd3dd3d7cea8d22ab1131364a050228d94c' (2022-12-31)
2023-01-01 01:06:03 +00:00
Jake Stanger
8076412bfc Merge pull request #46 from JakeStanger/feat/mouse-events
feat: mouse event config options
2022-12-15 22:10:40 +00:00
Jake Stanger
fa67d077b1 feat: mouse event config options
Adds `on_click_middle`, `on_click_right`, `on_scroll_up`, `on_scroll_down`.

BREAKING CHANGE: `on_click` is now called `on_click_left` for consistency with new options.

Resolves #44.
2022-12-15 21:37:08 +00:00
Jake Stanger
b2afe78c07 Merge pull request #45 from JakeStanger/feat/config-loading-improvements
Feat/config loading improvements
2022-12-13 18:47:07 +00:00
Jake Stanger
1dd5863431 feat: better surface some config error messages
Resolves #39
2022-12-12 23:28:49 +00:00
Jake Stanger
0a341f6673 build: update libcorn 2022-12-12 23:15:57 +00:00
Jake Stanger
bb81f8e583 Merge pull request #43 from JakeStanger/refactor/general
General refactoring and tidy-up
2022-12-12 19:56:34 +00:00
Jake Stanger
a45ebfc1f5 build: update dependencies 2022-12-11 23:30:42 +00:00
Jake Stanger
ea2c84d1bd refactor: general code tidy-up 2022-12-11 23:17:15 +00:00
Jake Stanger
5e21cbcca6 refactor: macros to reduce repeated code 2022-12-11 22:45:52 +00:00
Jake Stanger
9d5049dde0 refactor: standardise error messages 2022-12-11 21:31:45 +00:00
Jake Stanger
fd2d7e5c7a refactor: move startup logging code to logging module 2022-12-11 21:31:23 +00:00
Jake Stanger
2c1b2924d4 refactor: move most of the horrible add_module macro content into proper functions 2022-12-04 23:23:22 +00:00
Jake Stanger
490f3f3f65 Merge pull request #40 from JakeStanger/update_flake_lock_action
Update flake.lock
2022-12-01 20:58:30 +00:00
github-actions[bot]
843e40ef45 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/af50806f7c6ab40df3e6b239099e8f8385f6c78b' (2022-11-21)
  → 'github:nixos/nixpkgs/e76c78d20685a043d23f5f9e0ccd2203997f1fb1' (2022-11-30)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/9652ef34c7439eca9f86cee11e94dbef5c9adb09' (2022-11-22)
  → 'github:oxalica/rust-overlay/bfdf688742cf984c4837dbbe1c6cbca550365613' (2022-12-01)
2022-12-01 20:57:34 +00:00
Jake Stanger
d8c60d9d47 ci(nix flake lock): fix invalid action version
whoops
2022-12-01 20:56:41 +00:00
JakeStanger
b97f018e81 docs: update CHANGELOG.md for v0.8.0 [skip ci] 2022-11-30 23:01:46 +00:00
Jake Stanger
c1e1743b5e chore(release): v0.8.0 2022-11-30 22:58:31 +00:00
Jake Stanger
37458642df ci(build): reverse order of fmt/clippy/build 2022-11-30 22:56:44 +00:00
Jake Stanger
862c46c7ec style: run rustfmt
d'oh
2022-11-30 22:49:49 +00:00
Jake Stanger
afedf0214d docs: add link to new custom power menu example 2022-11-30 22:42:31 +00:00
Jake Stanger
64f54040ef refactor: move dynamic_label.rs to dynamic_string.rs and fix failing test 2022-11-30 22:40:53 +00:00
Jake Stanger
d20972cb32 feat: dynamic tooltips
Resolves #36
2022-11-30 22:27:56 +00:00
Jake Stanger
1320639d4e docs: add custom power menu example 2022-11-30 22:09:46 +00:00
Jake Stanger
907a565f3d test(dynamic label): do not run if cannot initialise gtk 2022-11-28 22:46:32 +00:00
Jake Stanger
ec69649a04 docs: update example configs 2022-11-28 22:43:12 +00:00
Jake Stanger
c4cdf4be8b docs: update example configs 2022-11-28 22:30:32 +00:00
Jake Stanger
00f973c3a4 style: run rustfmt 2022-11-28 22:30:32 +00:00
Jake Stanger
5d153a02fc feat(custom): ability to embed scripts in labels for dynamic content
Fully resolves #34.
2022-11-28 22:30:31 +00:00
Jake Stanger
e274ba39cd refactor(custom): rename exec to on_click for consistency
BREAKING CHANGE: This changes the option on buttons in the `custom` module. Any uses of the module must be updated to use the new custom widget attribute name.
2022-11-28 22:26:14 +00:00
Jake Stanger
8c75bc46ac refactor(script): rename path to cmd for consistency
BREAKING CHANGE: This changes the option in the `script` module. Any uses of the module must be updated to use the new option name.
2022-11-28 22:25:14 +00:00
Jake Stanger
df77020c52 refactor(sys_info): use snake_case for module tokens for consistency
BREAKING CHANGE: This renames the module from `sys-info` to `sys_info`, and almost every formatting token from `kebab-case` to `snake_case`. Any use of this module will need to be updated.
2022-11-28 22:23:33 +00:00
Jake Stanger
0fb5fa8c2a refactor: use latest libcorn with serde support
This should speed Corn config loading up a bit :)
2022-11-28 22:23:11 +00:00
Jake Stanger
cf87bb4e8d ci(build): run cargo tests 2022-11-28 22:21:47 +00:00
Jake Stanger
badfcc0c2d Merge pull request #38 from JakeStanger/feat/common-options
feat: common module options (`show_if`, `on_click`, `tooltip`)
2022-11-28 22:20:40 +00:00
Jake Stanger
c9e66d4664 feat: common module options (show_if, on_click, tooltip)
The first three of many options that are common to all modules.

Resolves #36. Resolves partially #34.
2022-11-28 22:09:18 +00:00
Yavor Kolev
a3f90adaf1 feat: add nix flake support
* Add nix flake

* Fix readme syntax issue

* Format nix flake

* ci(build): add nix flake support

* ci(build): fix workflow_dispatch casing

* ci(build): fix nix flake lock update job

* ci: add nix flake lock update timer job

old method didn't work

* improve example and add cachix info

Co-authored-by: Jake Stanger <mail@jstanger.dev>
2022-11-26 21:29:16 +00:00
Jake Stanger
47420d83bf Merge pull request #35 from JakeStanger/feat/script-watch
feat(script): new watch mode
2022-11-07 21:26:38 +00:00
Jake Stanger
4662f60ac5 refactor: move various clients to own folder 2022-11-06 23:38:51 +00:00
Jake Stanger
94693c92e3 ci(sync wiki): overhaul with bash script 2022-11-06 23:25:11 +00:00
Jake Stanger
8c774100f1 docs(script): add information on new mode options 2022-11-06 23:04:24 +00:00
Jake Stanger
b4db0226cd Merge branch 'master' into feat/script-watch 2022-11-06 22:55:16 +00:00
Jake Stanger
ff17ec1996 refactor: various changes based on rust 1.65 clippy 2022-11-06 22:53:48 +00:00
Jake Stanger
c48029664d docs(script): improve doc comment 2022-11-06 22:53:29 +00:00
Jake Stanger
58d55db660 docs: migrate wiki into main repo 2022-11-06 22:52:21 +00:00
Jake Stanger
73158c2fce feat(script): new watch mode
Resolves #30
2022-11-06 17:40:01 +00:00
JakeStanger
1c032ae8e3 docs: update CHANGELOG.md for v0.7.0 [skip ci] 2022-11-05 17:34:48 +00:00
Jake Stanger
3b04642148 chore(release): v0.7.0 2022-11-05 17:33:36 +00:00
Jake Stanger
0a331f3138 docs(readme): remove warning about outdated cargo package 2022-11-05 17:33:02 +00:00
Jake Stanger
bc625b929b refactor: clippy & fmt 2022-11-05 17:32:42 +00:00
Jake Stanger
ad77dc4e4c feat: improved logging & error handling 2022-11-05 17:32:29 +00:00
Jake Stanger
3a83bd31ab fix: able to insert duplicate keys into collection
This replaces the custom `Collection` implementation with `IndexMap` from the crate of the same name.

Fixes #28.
2022-11-05 17:32:01 +00:00
Jake Stanger
5ebc84c7b9 refactor(logging): consts for default log levels 2022-11-05 17:29:17 +00:00
Jake Stanger
51d1cd4a16 build: update deps 2022-11-01 22:56:47 +00:00
Jake Stanger
b7792a415e feat: env var to set custom css location
Set `IRONBAR_CSS` to load CSS from that path instead of regular path.
2022-11-01 13:25:46 +00:00
Jake Stanger
9f82ba58cd chore: cleanup println 2022-10-23 17:18:49 +01:00
Jake Stanger
a93700e8fd Merge pull request #27 from JakeStanger/feat/custom-widget
feat: new custom module
2022-10-23 17:11:17 +01:00
Jake Stanger
2a3fe33446 build: remove no longer required patch, reduce build times 2022-10-23 17:01:35 +01:00
Jake Stanger
3750124d8c feat: new custom module
Allows basic modules to be created from a config object, including popup content.
2022-10-23 17:01:35 +01:00
Jake Stanger
e693c1c166 fix(mpd): volume slider causing mpd server errors 2022-10-16 22:23:40 +01:00
Jake Stanger
cbd0c49e25 fix: css watcher not working 2022-10-16 22:21:51 +01:00
Jake Stanger
e23e691bc6 build: use latest release version of stray 2022-10-16 16:44:51 +01:00
Jake Stanger
be0f4c6366 chore: run fmt 2022-10-16 16:44:32 +01:00
Jake Stanger
493df6bb49 feat(mpd): add volume slider to popup 2022-10-16 16:00:18 +01:00
Jake Stanger
b4ac1c9850 Merge pull request #26 from JakeStanger/feat/sysinfo-tokens
Add loads more tokens & interval options to `sysinfo`, optimise info refreshing
2022-10-16 14:04:11 +01:00
Jake Stanger
27f6abad67 chore: format and fix clippy warnings 2022-10-16 13:54:48 +01:00
Jake Stanger
ec1d59677b feat(logging): IRONBAR_LOG and IRONBAR_FILE_LOG env vars 2022-10-16 13:42:59 +01:00
Jake Stanger
70e1b526a9 fix(logging): file log not capturing panics 2022-10-16 13:42:35 +01:00
Jake Stanger
3c43c20c6a fix: weird behaviour when config does not exist 2022-10-16 12:58:11 +01:00
Jake Stanger
b66bd788b2 fix: logging for creating bar incorrect still 2022-10-16 12:57:37 +01:00
Jake Stanger
f17ae7a415 fix(script): not parsing pango markup 2022-10-16 12:56:39 +01:00
Jake Stanger
a06c4bccca docs(examples): add full system info config 2022-10-16 01:03:36 +01:00
Jake Stanger
e4e72d8008 style(sys-info): fix clippy warnings & run fmt 2022-10-16 01:03:20 +01:00
Jake Stanger
9e6dbbd131 fix(sys-info): tokens not replaced if more than one in string 2022-10-16 01:00:43 +01:00
Jake Stanger
91c57edc73 feat(sys-info): pango markup support 2022-10-16 01:00:14 +01:00
Jake Stanger
dec402edd9 feat(sys-info): config options for refresh intervals 2022-10-16 00:59:28 +01:00
Jake Stanger
fad90fdad6 feat(sys-info): add loads more formatting tokens 2022-10-16 00:58:47 +01:00
Jake Stanger
35ce3b4d45 chore: use cargo patch block 2022-10-16 00:56:47 +01:00
Jake Stanger
27d04795af docs(readme): add warning about crate being outdated 2022-10-15 18:51:45 +01:00
JakeStanger
9d9c275313 docs: update CHANGELOG.md for v0.6.0 [skip ci] 2022-10-15 17:27:37 +00:00
Jake Stanger
0669504519 chore(release): v0.6.0 2022-10-15 18:26:51 +01:00
Jake Stanger
eb5170ff6a style: run fmt 2022-10-15 18:08:56 +01:00
Jake Stanger
b7b64886e3 fix: sometimes panicking on startup 2022-10-15 18:01:09 +01:00
Jake Stanger
75339f07ed fix: vertical bars ignoring height config option 2022-10-15 16:35:31 +01:00
Jake Stanger
06cfad62e2 feat: more positioning options (#23)
* feat: more positioning options

Can now display the bar on the left/right, and avoid anchoring to edges to centre the bar.

BREAKING CHANGE: The `left` and `right` config options have been renamed to `start` and `end`
2022-10-15 16:27:25 +01:00
Jake Stanger
1b853bcb71 refactor: fix clippy warning 2022-10-15 16:19:21 +01:00
Jake Stanger
bd5bdf5af5 fix: logging for creating bar incorrect 2022-10-15 15:36:23 +01:00
Jake Stanger
8536ad719a fix(mpd): incorrectly checking for unix sockets 2022-10-15 00:17:40 +01:00
Jake Stanger
006c242f49 style: run fmt 2022-10-15 00:09:38 +01:00
Jake Stanger
2cd59ef5ff build: fix compilation errors caused by package update 2022-10-15 00:07:10 +01:00
Jake Stanger
f411b7c451 chore: update lockfile 2022-10-14 23:53:13 +01:00
Jake Stanger
1dd0a9e52f feat(launcher): add popup css selectors 2022-10-14 23:49:11 +01:00
Jake Stanger
5523e9af46 fix(popup): often opening in wrong place
Fixes #16.
2022-10-14 23:48:28 +01:00
Jake Stanger
9e31107251 chore: update lockfile 2022-10-14 23:46:56 +01:00
Jake Stanger
668fe4a308 Merge pull request #24 from JakeStanger/feat/wayland-protocols
Implement Wayland protocol support
2022-10-14 22:24:57 +01:00
Jake Stanger
994d0f580b docs(readme): update references to sway 2022-10-14 22:14:04 +01:00
Jake Stanger
5ce50b0987 refactor: tidy and format 2022-10-10 21:59:44 +01:00
Jake Stanger
b1c66b9117 feat: wlroots-agnostic support for launcher module 2022-10-10 20:15:24 +01:00
Jake Stanger
bb4fe7f7f5 docs(readme): credit smithay client toolkit
Could not have got the Wayland stuff working without.
2022-10-04 23:26:26 +01:00
Jake Stanger
324f00cdf9 feat: wlroots-agnostic support for focused module 2022-10-04 23:26:07 +01:00
Jake Stanger
b188bc7146 feat: initial support for running outside sway
Progress is being tracked in #18. Currently the workspaces, focused and launcher modules are not supported.
2022-09-27 20:24:16 +01:00
Jake Stanger
d22d954e83 ci: alter changelog categories 2022-09-25 22:50:17 +01:00
Jake Stanger
45e44d7913 chore(intellij): update run configs 2022-09-25 22:50:05 +01:00
Jake Stanger
b352181b3d docs: update json example 2022-09-25 22:49:54 +01:00
Jake Stanger
720ba7bfb0 Major module refactor (#19)
* refactor: major module restructuring

Modules now implement a "controller", which allows for separation of logic from UI code and enforces a tighter structure around how modules should be written. The introduction of this change required major refactoring or even rewriting of all modules.

This also better integrates the popup into modules, making it easier for data to be passed around without fetching the same thing twice

The refactor also improves some client code, switching from `ksway` to the much more stable `swayipc-async`. Partial multi-monitor for the tray module has been added.

BREAKING CHANGE: The `mpd` module config has changed, moving the icons to their own object.
2022-09-25 22:49:00 +01:00
JakeStanger
daafa0943e docs: update CHANGELOG.md for v0.5.2 [skip ci] 2022-09-07 21:49:04 +00:00
Jake Stanger
b801751bda chore(release): v0.5.2 2022-09-07 22:47:57 +01:00
Jake Stanger
ee67b3be28 build: update deps
new corn version allows unicode keys :)
2022-09-07 22:47:47 +01:00
Jake Stanger
6442667961 docs(readme): fix links in install section 2022-09-06 22:50:27 +01:00
JakeStanger
68574d4327 docs: update CHANGELOG.md for v0.5.1 [skip ci] 2022-09-06 21:39:15 +00:00
Jake Stanger
6871126bd8 chore(release): v0.5.1 2022-09-06 22:38:16 +01:00
Jake Stanger
481adfcaa4 chore(intellij): update run configs 2022-09-06 22:37:55 +01:00
Jake Stanger
64650fbf3a Merge pull request #14 from JakeStanger/fix/launcher-state
Fix launcher state issues
2022-09-06 21:56:21 +01:00
Jake Stanger
a35d25520c fix(launcher): item state changes not handled correctly
This completely rewrites the item open state handling code (again) in a more logical way that should prevent incorrect states, and removes some locking issues.
2022-09-06 21:46:02 +01:00
Jake Stanger
78e30b39fe docs: add some rustdoc comments throughout 2022-08-28 16:57:41 +01:00
Jake Stanger
b81927e3a5 fix(launcher): opening new instances when focused/urgent 2022-08-25 22:08:08 +01:00
JakeStanger
5d319e91f2 docs: update CHANGELOG.md for v0.5.0 [skip ci] 2022-08-25 20:55:43 +00:00
Jake Stanger
015dcd3204 chore(release): v0.5.0 2022-08-25 21:54:32 +01:00
Jake Stanger
1e38719996 feat: introduce logging in some areas 2022-08-25 21:53:57 +01:00
Jake Stanger
6dcae66570 fix: avoid creating loads of sway/mpd clients 2022-08-25 21:53:42 +01:00
Jake Stanger
649b0efb19 style: run rustfmt 2022-08-24 21:27:30 +01:00
Jake Stanger
023c2fb118 fix(workspaces): not listening to move event 2022-08-24 21:27:19 +01:00
Jake Stanger
ea57f5e18d Merge remote-tracking branch 'origin/master' 2022-08-24 18:05:38 +01:00
Jake Stanger
53142d1bea ci(release): fix missing build deps [skip ci] 2022-08-22 23:13:45 +01:00
JakeStanger
7e0f2cad1c docs: update CHANGELOG.md for v0.4.0 [skip ci] 2022-08-22 22:09:27 +00:00
Jake Stanger
1d7c3772e4 chore(release): v0.4.0 2022-08-22 23:09:01 +01:00
Jake Stanger
f2ee2dfe7a fix: error when using with swaybar_command 2022-08-22 23:08:41 +01:00
Jake Stanger
ab8f7ecfc8 feat: logging support and proper error handling 2022-08-21 23:36:51 +01:00
Jake Stanger
917838c98c ci(deploy): fix reading wrong branch for changelog 2022-08-15 21:26:13 +01:00
Jake Stanger
5ec46b2a2a ci(build): change job name 2022-08-15 21:26:01 +01:00
115 changed files with 14701 additions and 2885 deletions

50
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,50 @@
---
name: Bug report
about: Report an issue with the bar not working as expected
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
> A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
> A clear and concise description of what you expected to happen.
**System information:**
- Distro: [e.g. Arch Linux, Ubuntu 22.10]
- Compositor: [e.g. Sway]
- Ironbar version: [e.g. 0.8.0]
**Configuration**
> Share your bar configuration and stylesheet as applicable:
<details><summary>Config</summary>
```
```
</details>
<details><summary>Styles</summary>
```css
```
</details>
**Additional context**
> Add any other context about the problem here.
**Screenshots**
> If applicable, add screenshots to help explain your problem.

View File

@@ -0,0 +1,22 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
> A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
> A clear and concise description of what you want to happen.
> The more info here about what you are trying to achieve, the better - there's likely more than one way to go about implementing a solution.
**Describe alternatives you've considered**
> A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
> Add any other context or screenshots about the feature request here.

10
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@@ -0,0 +1,10 @@
---
name: Other
about: Any other issue type
title: ''
labels: ''
assignees: ''
---

43
.github/scripts/sync-wiki.sh vendored Executable file
View File

@@ -0,0 +1,43 @@
#!/bin/sh
set -eu
TEMP_REPO_DIR="wiki_action_$GITHUB_REPOSITORY$GITHUB_SHA"
TEMP_WIKI_DIR="temp_wiki_$GITHUB_SHA"
WIKI_DIR='docs'
if [ -z "$GH_TOKEN" ]; then
echo "Token is not specified"
exit 1
fi
#Clone repo
echo "Cloning repo https://github.com/$GITHUB_REPOSITORY"
git clone "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY" "$TEMP_REPO_DIR"
#Clone wiki repo
echo "Cloning wiki repo https://github.com/$GITHUB_REPOSITORY.wiki.git"
cd "$TEMP_REPO_DIR"
git clone "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git" "$TEMP_WIKI_DIR"
#Get commit details
author='Jake Stanger'
email='mail@jstanger.dev'
message='action: sync wiki'
echo "Copying edited wiki"
cp -R "$TEMP_WIKI_DIR/.git" "$WIKI_DIR/"
echo "Checking if wiki has changes"
cd "$WIKI_DIR"
git config --local user.email "$email"
git config --local user.name "$author"
git add .
if git diff-index --quiet HEAD; then
echo "Nothing changed"
exit 0
fi
echo "Pushing changes to wiki"
git commit -m "$message" && git push "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git"

View File

@@ -1,6 +1,7 @@
name: Rust
name: Build
on:
workflow_dispatch:
push:
branches: [ "master" ]
pull_request:
@@ -22,16 +23,51 @@ jobs:
override: true
- name: Install build deps
run: sudo apt install libgtk-3-dev libgtk-layer-shell-dev
run: |
sudo apt-get update
sudo apt-get install libgtk-3-dev libgtk-layer-shell-dev
- name: Build
run: cargo build --verbose
- name: Check formatting
run: cargo fmt --check
- name: Clippy
- name: Clippy (base features)
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --no-default-features --features config+json
- name: Clippy (all features)
uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features
- name: Check formatting
run: cargo fmt --check
- name: Build
run: cargo build --verbose
- name: Run tests
uses: actions-rs/cargo@v1
with:
command: test
build-nix:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v20
with:
install_url: https://nixos.org/nix/install
extra_nix_config: |
auto-optimise-store = true
experimental-features = nix-command flakes
- uses: cachix/cachix-action@v12
with:
name: jakestanger
signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'
- run: nix build --print-build-logs

View File

@@ -17,12 +17,18 @@ jobs:
toolchain: stable
override: true
- name: Install build deps
run: |
sudo apt-get update
sudo apt-get install libgtk-3-dev libgtk-layer-shell-dev
- name: Update CHANGELOG
id: changelog
uses: Requarks/changelog-action@v1
with:
token: ${{ github.token }}
tag: ${{ github.ref_name }}
excludeTypes: 'build,chore,style'
- name: Create release
uses: ncipollo/release-action@v1
@@ -36,7 +42,7 @@ jobs:
- name: Commit CHANGELOG.md
uses: stefanzweifel/git-auto-commit-action@v4
with:
branch: main
branch: master
commit_message: 'docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]'
file_pattern: CHANGELOG.md

View File

@@ -0,0 +1,27 @@
name: update-nix-flake-lock
on:
workflow_dispatch: # allows manual triggering
schedule:
- cron: '0 0 1 * *' # first day of every month
jobs:
update-nix-flake-lock:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Install Nix
uses: cachix/install-nix-action@v20
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@v15
with:
pr-title: "Update flake.lock" # Title of PR to be created
pr-labels: | # Labels to be set on the PR
dependencies
automated

17
.github/workflows/wiki.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
name: Sync Wiki
on:
push:
branches: [ "master" ]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Sync Wiki
run: ./.github/scripts/sync-wiki.sh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Clippy (Strict)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="clippy -- -W clippy::pedantic -W clippy::nursery -W clippy::unwrap_used -W clippy::expect_used" />
<option name="command" value="clippy -- -W clippy::pedantic -W clippy::nursery -W clippy::unwrap_used" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="false" />

17
.idea/runConfigurations/Format.xml generated Normal file
View File

@@ -0,0 +1,17 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Format" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="fmt" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="channel" value="DEFAULT" />
<option name="requiredFeatures" value="true" />
<option name="allFeatures" value="false" />
<option name="emulateTerminal" value="false" />
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2" />
</configuration>
</component>

View File

@@ -12,6 +12,7 @@
<envs>
<env name="IRONBAR_CONFIG" value="examples/config.json" />
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
<env name="RUST_LOG" value="debug" />
</envs>
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />

View File

@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Run (Debug)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<configuration default="false" name="Run (GTK Debug)" type="CargoCommandRunConfiguration" factoryName="Cargo Command">
<option name="command" value="run --package ironbar --bin ironbar" />
<option name="workingDirectory" value="file://$PROJECT_DIR$" />
<option name="channel" value="DEFAULT" />
@@ -12,6 +12,8 @@
<envs>
<env name="GTK_DEBUG" value="interactive" />
<env name="IRONBAR_CONFIG" value="examples/config.json" />
<env name="RUST_LOG" value="debug" />
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
</envs>
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />

View File

@@ -9,7 +9,10 @@
<option name="withSudo" value="false" />
<option name="buildTarget" value="REMOTE" />
<option name="backtrace" value="SHORT" />
<envs />
<envs>
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
<env name="RUST_LOG" value="debug" />
</envs>
<option name="isRedirectInput" value="false" />
<option name="redirectInputPath" value="" />
<method v="2">

236
CHANGELOG.md Normal file
View File

@@ -0,0 +1,236 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v0.10.0] - 2023-02-01
### :boom: BREAKING CHANGES
- due to [`3cf9be8`](https://github.com/JakeStanger/ironbar/commit/3cf9be89fd74face31806165f66b68052b093bab) - global icon theme setting *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This removes the `icon_theme` option from `launcher` and `focused`. You will need to set this at the top of your config instead.
- due to [`90f57d6`](https://github.com/JakeStanger/ironbar/commit/90f57d61b94c50c98a6f55de18c6edf3d18aa3fa) - remove irrelevant `icon` format token *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
(Missed from #96141d4) The `{icon}` token has been removed from the `music` module due to incompatibility with the new image/icon support. The icon now always displays as a separate widget before the label and should be removed from your formatting string.
### :sparkles: New Features
- [`8691824`](https://github.com/JakeStanger/ironbar/commit/8691824db1a12c3f3589ff8b5315b8dba5cb8aec) - **music**: ability to truncate button text *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`07dbf78`](https://github.com/JakeStanger/ironbar/commit/07dbf780105027b533b0bb34c9ae3e4e96f29f4a) - **focused**: ability to truncate label text *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`393800a`](https://github.com/JakeStanger/ironbar/commit/393800aaa2093b9257c43fde8bdb8399f26ebc74) - **custom**: image widget *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`3cf9be8`](https://github.com/JakeStanger/ironbar/commit/3cf9be89fd74face31806165f66b68052b093bab) - global icon theme setting *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b054c17`](https://github.com/JakeStanger/ironbar/commit/b054c17d14628c9188bfa9aed506ea1de3051f9c) - **workspaces**: support for using images in `name_map` *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`96141d4`](https://github.com/JakeStanger/ironbar/commit/96141d49907412ea26d23ef30c10ade8b32b89b9) - **music**: support for using images in `name_map`, additional icon options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c347b6c`](https://github.com/JakeStanger/ironbar/commit/c347b6c9449ce4e16e2e133d7dd35544ab9a533c) - add feature flags *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`5772711`](https://github.com/JakeStanger/ironbar/commit/57727111923a419f9b7613103283aa4cf6bd082c) - **music**: remote mpris album art not showing *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`5fb4125`](https://github.com/JakeStanger/ironbar/commit/5fb412572f3da60ac482a1960d891f70bc29287b) - **tray**: some init issues *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`058c8f4`](https://github.com/JakeStanger/ironbar/commit/058c8f4228f9f7faa66cda9dd1636ea32e9de68b) - **hyprland**: issues with tracking workspaces *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`51d2c22`](https://github.com/JakeStanger/ironbar/commit/51d2c2279f50add992def0d58cfaa9890ea3d041) - **images**: incorrectly resolving non-files *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`012762e`](https://github.com/JakeStanger/ironbar/commit/012762e10203fb2d58160acdae4dc7ca7689b131) - swap out some code for existing macros *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`9750255`](https://github.com/JakeStanger/ironbar/commit/97502559b30c51e77c1dd9a7d794a88756294c83) - **music**: split config code into separate file *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`15f0857`](https://github.com/JakeStanger/ironbar/commit/15f0857859d5d4a590b60b6b1a4347b4b84a58a1) - replace icon loading with improved general image loading *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`1ed3220`](https://github.com/JakeStanger/ironbar/commit/1ed3220733c2dcb7c5e5cbf377b3324d3183609e) - update CHANGELOG.md for v0.9.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`90f57d6`](https://github.com/JakeStanger/ironbar/commit/90f57d61b94c50c98a6f55de18c6edf3d18aa3fa) - **music**: remove irrelevant `icon` format token *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6a39905`](https://github.com/JakeStanger/ironbar/commit/6a39905b4333582fbcda81a66a9b91055333d698) - **compiling**: add missing full stop *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`7b23e61`](https://github.com/JakeStanger/ironbar/commit/7b23e61e7dedf2736a30580b6c1aa84e002c462c) - **wiki**: update screenshots and examples *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.9.0] - 2023-01-28
### :boom: BREAKING CHANGES
- due to [`fa67d07`](https://github.com/JakeStanger/ironbar/commit/fa67d077b136b109edf6dbaa11a33aebf3e044b4) - mouse event config options *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
`on_click` is now called `on_click_left` for consistency with new options.
- due to [`6d8e647`](https://github.com/JakeStanger/ironbar/commit/6d8e647f123e54ba389c5ab2fe908200aa5e4cf6) - mpris support *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
The `mpd` module has been renamed to `music`. You will need to update the `type` value in your config and add `player_type` to continue using MPD. You will also need to update your styles.
### :sparkles: New Features
- [`1dd5863`](https://github.com/JakeStanger/ironbar/commit/1dd586343143bfd501a44c6556719fac9d582d6b) - better surface some config error messages *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`fa67d07`](https://github.com/JakeStanger/ironbar/commit/fa67d077b136b109edf6dbaa11a33aebf3e044b4) - mouse event config options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6d8e647`](https://github.com/JakeStanger/ironbar/commit/6d8e647f123e54ba389c5ab2fe908200aa5e4cf6) - mpris support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6e5d0c1`](https://github.com/JakeStanger/ironbar/commit/6e5d0c1e8c0b5d7e330608fc835e1e9733f156de) - **workspaces**: hyprland support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`9ba28fe`](https://github.com/JakeStanger/ironbar/commit/9ba28fe7faf84e06febc2ffea089442f8f5b90a2) - **workspaces**: better ordering *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`e1f523c`](https://github.com/JakeStanger/ironbar/commit/e1f523cf2a15b74a5c570dd7440db4c1b476d782) - **music**: popup artist label using wrong name *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`08cfbbc`](https://github.com/JakeStanger/ironbar/commit/08cfbbc2eaf6e74780dd7196efcc15ea6d2e7d12) - **music**: unable to go to prev with mpris *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0cefcbd`](https://github.com/JakeStanger/ironbar/commit/0cefcbd02b0af518352e35060644f281da249d3e) - **music**: wrong widget name on vol slider *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`90cd078`](https://github.com/JakeStanger/ironbar/commit/90cd078973b23b2291cf156e46729842f33c1806) - **mpd**: stops working if connection lost *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`2c1b292`](https://github.com/JakeStanger/ironbar/commit/2c1b2924d4a103183d3974ac066623a80277a79a) - move most of the horrible `add_module` macro content into proper functions *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`fd2d7e5`](https://github.com/JakeStanger/ironbar/commit/fd2d7e5c7ab8de50c4621b19d07d8b012a451564) - move startup logging code to logging module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`9d5049d`](https://github.com/JakeStanger/ironbar/commit/9d5049dde01cdb76f4772f8ce8f61a8b5bad3a50) - standardise error messages *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`5e21cbc`](https://github.com/JakeStanger/ironbar/commit/5e21cbcca6cc30d725acdea0f6561cfd6acdcc3c) - macros to reduce repeated code *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ea2c84d`](https://github.com/JakeStanger/ironbar/commit/ea2c84d1bd15798e32496397c4a6aa42fab39d95) - general code tidy-up *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0d7ab54`](https://github.com/JakeStanger/ironbar/commit/0d7ab541604691455ed39c73e039ac0635307bc8) - remove redundant clone *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`b97f018`](https://github.com/JakeStanger/ironbar/commit/b97f018e81aa55a871a12aa3e1e4b07b1f8eb50f) - update CHANGELOG.md for v0.8.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c223892`](https://github.com/JakeStanger/ironbar/commit/c223892a57b29ae56431fc585b8cec503f3206c7) - **workspaces**: update for hyprland/new ordering option *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.8.0] - 2022-11-30
### :boom: BREAKING CHANGES
- due to [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This renames the module from `sys-info` to `sys_info`, and almost every formatting token from `kebab-case` to `snake_case`. Any use of this module will need to be updated.
- due to [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This changes the option in the `script` module. Any uses of the module must be updated to use the new option name.
- due to [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This changes the option on buttons in the `custom` module. Any uses of the module must be updated to use the new custom widget attribute name.
### :sparkles: New Features
- [`73158c2`](https://github.com/JakeStanger/ironbar/commit/73158c2fce2880347b88d58541dea000534996c8) - **script**: new watch mode *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`a3f90ad`](https://github.com/JakeStanger/ironbar/commit/a3f90adaf19aebed7020eeb44b91250af080d313) - add nix flake support *(commit by [@yavko](https://github.com/yavko))*
- [`c9e66d4`](https://github.com/JakeStanger/ironbar/commit/c9e66d4664137c50aba4aecdc3a3ba43d3da11fe) - common module options (`show_if`, `on_click`, `tooltip`) *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`5d153a0`](https://github.com/JakeStanger/ironbar/commit/5d153a02fc9b113bb77a04596b806edd182fc5d3) - **custom**: ability to embed scripts in labels for dynamic content *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`d20972c`](https://github.com/JakeStanger/ironbar/commit/d20972cb32714627d0cca947021453979c76dd03) - dynamic tooltips *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`ff17ec1`](https://github.com/JakeStanger/ironbar/commit/ff17ec1996cf344663e84e79d11b08dc84b97635) - various changes based on rust 1.65 clippy *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`4662f60`](https://github.com/JakeStanger/ironbar/commit/4662f60ac54165be6fb7aea12c245309db0fe5d6) - move various clients to own folder *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0fb5fa8`](https://github.com/JakeStanger/ironbar/commit/0fb5fa8c2a166c3d46b006ceb0d53af076824ff4) - use latest `libcorn` with serde support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - **sys_info**: use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - **script**: rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - **custom**: rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`64f5404`](https://github.com/JakeStanger/ironbar/commit/64f54040ef626157af6b6a9ce5258507a10a23fb) - move dynamic_label.rs to dynamic_string.rs and fix failing test *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :white_check_mark: Tests
- [`907a565`](https://github.com/JakeStanger/ironbar/commit/907a565f3d418a276dfb454e1189ddede1814291) - **dynamic label**: do not run if cannot initialise gtk *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`1c032ae`](https://github.com/JakeStanger/ironbar/commit/1c032ae8e3a38b82c286bab7d102842f14b708e1) - update CHANGELOG.md for v0.7.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`58d55db`](https://github.com/JakeStanger/ironbar/commit/58d55db6600fe2f9b23ae8ec6a50a686d2acaf65) - migrate wiki into main repo *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c480296`](https://github.com/JakeStanger/ironbar/commit/c48029664d5f58bf73faa2931f34b38b8b184d25) - **script**: improve doc comment *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`8c77410`](https://github.com/JakeStanger/ironbar/commit/8c774100f1c8ea051284c6950339a2c8ed59a52a) - **script**: add information on new mode options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c4cdf4b`](https://github.com/JakeStanger/ironbar/commit/c4cdf4be8ba83f3669158a1552eab4a840085204) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ec69649`](https://github.com/JakeStanger/ironbar/commit/ec69649a04f6199953836e51c2efe1fe2a19e320) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`1320639`](https://github.com/JakeStanger/ironbar/commit/1320639d4e6b7c8cd8f861b26b2b854504775ef0) - add custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`afedf02`](https://github.com/JakeStanger/ironbar/commit/afedf0214d3a71f6283c70bd3a110d24f68d2fdf) - add link to new custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.7.0] - 2022-11-05
### :sparkles: New Features
- [`fad90fd`](https://github.com/JakeStanger/ironbar/commit/fad90fdad683a612497ac7822a66a90f43fce0a2) - **sys-info**: add loads more formatting tokens *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`dec402e`](https://github.com/JakeStanger/ironbar/commit/dec402edd9d6c5b8677ff337699ad99ebc69b776) - **sys-info**: config options for refresh intervals *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`91c57ed`](https://github.com/JakeStanger/ironbar/commit/91c57edc73f15397ea0de70c4a6a6532c35caf2a) - **sys-info**: pango markup support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ec1d596`](https://github.com/JakeStanger/ironbar/commit/ec1d59677b13c9654a98d78f909ba2d0fcfbb72d) - **logging**: `IRONBAR_LOG` and `IRONBAR_FILE_LOG` env vars *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`493df6b`](https://github.com/JakeStanger/ironbar/commit/493df6bb49fec8c465706d3f9b395728ba73a621) - **mpd**: add volume slider to popup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`3750124`](https://github.com/JakeStanger/ironbar/commit/3750124d8cfb4783932a6b3359384f245fcd2394) - new custom module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b7792a4`](https://github.com/JakeStanger/ironbar/commit/b7792a415e09fc535750ea5af530f91aa791c4bc) - env var to set custom css location *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ad77dc4`](https://github.com/JakeStanger/ironbar/commit/ad77dc4e4c2f80fcb4c9604c796be0f981e895ee) - improved logging & error handling *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`9e6dbbd`](https://github.com/JakeStanger/ironbar/commit/9e6dbbd131a09f101b0d490265fe7d4ec564e38c) - **sys-info**: tokens not replaced if more than one in string *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`f17ae7a`](https://github.com/JakeStanger/ironbar/commit/f17ae7a415b931c64942de085e8889f37b3f9b11) - **script**: not parsing pango markup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b66bd78`](https://github.com/JakeStanger/ironbar/commit/b66bd788b23256a2127a1352693fdd3f929d9c4b) - logging for creating bar incorrect still *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`3c43c20`](https://github.com/JakeStanger/ironbar/commit/3c43c20c6ae53a9aa6b67770b0c489806784f4ac) - weird behaviour when config does not exist *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`70e1b52`](https://github.com/JakeStanger/ironbar/commit/70e1b526a9681b16545d7f05d77470d76bd8819e) - **logging**: file log not capturing panics *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`cbd0c49`](https://github.com/JakeStanger/ironbar/commit/cbd0c49e251b5c8e0289ca6200a393d89994992d) - css watcher not working *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`e693c1c`](https://github.com/JakeStanger/ironbar/commit/e693c1c166eef0b5edcdcd033bb12d572e4e5f04) - **mpd**: volume slider causing mpd server errors *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`3a83bd3`](https://github.com/JakeStanger/ironbar/commit/3a83bd31ab165869f7f274b054b2f16485261fd1) - able to insert duplicate keys into collection *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`5ebc84c`](https://github.com/JakeStanger/ironbar/commit/5ebc84c7b98cc648a659ca37fdc0f041057f0ea4) - **logging**: consts for default log levels *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`bc625b9`](https://github.com/JakeStanger/ironbar/commit/bc625b929b8644ce92f275b5d98cdf74b93fe067) - clippy & fmt *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`9d9c275`](https://github.com/JakeStanger/ironbar/commit/9d9c2753137331ae85ac8ab7d75a6de9a9c82042) - update CHANGELOG.md for v0.6.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`27d0479`](https://github.com/JakeStanger/ironbar/commit/27d04795af1c25fe5f765c7480d5dd5d096a8ab7) - **readme**: add warning about crate being outdated *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`a06c4bc`](https://github.com/JakeStanger/ironbar/commit/a06c4bccca6cb51935605ac9239e63024fb7c663) - **examples**: add full system info config *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0a331f3`](https://github.com/JakeStanger/ironbar/commit/0a331f31381f0d967793c0d8b7a14e2a43bf666f) - **readme**: remove warning about outdated cargo package *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.6.0] - 2022-10-15
### :sparkles: New Features
- [`b188bc7`](https://github.com/JakeStanger/ironbar/commit/b188bc714614406935d8bb88a719adab2dfce32f) - initial support for running outside sway *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`324f00c`](https://github.com/JakeStanger/ironbar/commit/324f00cdf9200e3e3ecedfa68ab4c99b170242e2) - wlroots-agnostic support for `focused` module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b1c66b9`](https://github.com/JakeStanger/ironbar/commit/b1c66b9117cf8a10350cdb857a5267a1a72ad914) - wlroots-agnostic support for `launcher` module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`1dd0a9e`](https://github.com/JakeStanger/ironbar/commit/1dd0a9e52f69e672d9ac313c1da0e201c911e6c2) - **launcher**: add popup css selectors *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`06cfad6`](https://github.com/JakeStanger/ironbar/commit/06cfad62e228f7fc63938f2280206450005cb064) - more positioning options *(PR [#23](https://github.com/JakeStanger/ironbar/pull/23) by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`5523e9a`](https://github.com/JakeStanger/ironbar/commit/5523e9af46e457f9d45902debaaacf26b586e457) - **popup**: often opening in wrong place *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`8536ad7`](https://github.com/JakeStanger/ironbar/commit/8536ad719a92aec4166e35b75cb029075ad3ae34) - **mpd**: incorrectly checking for unix sockets *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`bd5bdf5`](https://github.com/JakeStanger/ironbar/commit/bd5bdf5af548304958663d593fccb454afa6c8ff) - logging for creating bar incorrect *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`75339f0`](https://github.com/JakeStanger/ironbar/commit/75339f07ed164fa94838036a604a1dcb6d53564c) - vertical bars ignoring height config option *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b7b6488`](https://github.com/JakeStanger/ironbar/commit/b7b64886e3c48ace3faffbb1e277275aeeac3adf) - sometimes panicking on startup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`5ce50b0`](https://github.com/JakeStanger/ironbar/commit/5ce50b0987812a1ade2d1262e8d7df6916cfc39a) - tidy and format *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`1b853bc`](https://github.com/JakeStanger/ironbar/commit/1b853bcb71197a4bf3ca75725cc010b1d404c2b3) - fix clippy warning *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`daafa09`](https://github.com/JakeStanger/ironbar/commit/daafa0943e5b9886b09fd18d6fff04558fb02335) - update CHANGELOG.md for v0.5.2 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`b352181`](https://github.com/JakeStanger/ironbar/commit/b352181b3d232ccc79ffc1d9e22a633729d01a47) - update json example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`bb4fe7f`](https://github.com/JakeStanger/ironbar/commit/bb4fe7f7f58fa2a6d0a2259bd9442700d2c884f7) - **readme**: credit smithay client toolkit *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`994d0f5`](https://github.com/JakeStanger/ironbar/commit/994d0f580b4d1b6ff750839652a7f06149743172) - **readme**: update references to sway *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :boom: BREAKING CHANGES
- due to [`06cfad6`](https://github.com/JakeStanger/ironbar/commit/06cfad62e228f7fc63938f2280206450005cb064) - more positioning options *(PR [#23](https://github.com/JakeStanger/ironbar/pull/23) by [@JakeStanger](https://github.com/JakeStanger))*:
The `left` and `right` config options have been renamed to `start` and `end`
## [v0.5.2] - 2022-09-07
### :wrench: Chores
- [`b801751`](https://github.com/JakeStanger/ironbar/commit/b801751bdabd8416084f46e6b6d803ea28a259ec) - **release**: v0.5.2 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.5.1] - 2022-09-06
### :bug: Bug Fixes
- [`b81927e`](https://github.com/JakeStanger/ironbar/commit/b81927e3a57808188e31419695a36aa4ea3f2830) - **launcher**: opening new instances when focused/urgent *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`a35d255`](https://github.com/JakeStanger/ironbar/commit/a35d25520cd3fd235cdc77ec6209d88499ca3639) - **launcher**: item state changes not handled correctly *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`481adfc`](https://github.com/JakeStanger/ironbar/commit/481adfcaa41c0d3a1ba7d61edb68db49d959c78f) - **intellij**: update run configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6871126`](https://github.com/JakeStanger/ironbar/commit/6871126bd8def89ccbf2934180d615e781ec32c7) - **release**: v0.5.1 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.5.0] - 2022-08-25
### :sparkles: New Features
- [`1e38719`](https://github.com/JakeStanger/ironbar/commit/1e387199962b81caeb40ffbd99a956f24abdf4e3) - introduce logging in some areas *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`023c2fb`](https://github.com/JakeStanger/ironbar/commit/023c2fb118f46f3592f1dfe1a6704014c062ab3f) - **workspaces**: not listening to move event *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6dcae66`](https://github.com/JakeStanger/ironbar/commit/6dcae66570cf5434e077ec823cded33771b4239c) - avoid creating loads of sway/mpd clients *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`015dcd3`](https://github.com/JakeStanger/ironbar/commit/015dcd3204dfa6a1ebcef1b4f3b345ed733fee2f) - **release**: v0.5.0 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.4.0] - 2022-08-22
### :sparkles: New Features
- [`ab8f7ec`](https://github.com/JakeStanger/ironbar/commit/ab8f7ecfc8fa4b96fce78518af75794641950140) - logging support and proper error handling *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`f2ee2df`](https://github.com/JakeStanger/ironbar/commit/f2ee2dfe7a0f5575d0c3ec09644ca990b088cd85) - error when using with `swaybar_command` *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`1d7c377`](https://github.com/JakeStanger/ironbar/commit/1d7c3772e4b97c7198043cb55fe9c71695a211ab) - **release**: v0.4.0 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
[v0.4.0]: https://github.com/JakeStanger/ironbar/compare/v0.3.0...v0.4.0
[v0.5.0]: https://github.com/JakeStanger/ironbar/compare/v0.4.0...v0.5.0
[v0.5.1]: https://github.com/JakeStanger/ironbar/compare/v0.5.0...v0.5.1
[v0.5.2]: https://github.com/JakeStanger/ironbar/compare/v0.5.1...v0.5.2
[v0.6.0]: https://github.com/JakeStanger/ironbar/compare/v0.5.2...v0.6.0
[v0.7.0]: https://github.com/JakeStanger/ironbar/compare/v0.6.0...v0.7.0
[v0.8.0]: https://github.com/JakeStanger/ironbar/compare/v0.7.0...v0.8.0
[v0.9.0]: https://github.com/JakeStanger/ironbar/compare/v0.8.0...v0.9.0
[v0.10.0]: https://github.com/JakeStanger/ironbar/compare/v0.9.0...v0.10.0

View File

@@ -14,3 +14,4 @@ I welcome contributions of any kind with open arms. That said, please do stick t
- For issues:
- Please provide as much information as you can - share your config, any logs, steps to reproduce...
- If reporting an error, please ensure you use `IRONBAR_LOG` or `IRONBAR_FILE_LOG` set to `debug`.

2742
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,106 @@
[package]
name = "ironbar"
version = "0.3.0"
version = "0.10.0"
edition = "2021"
license = "MIT"
description = "Customisable wlroots/sway bar"
description = "Customisable GTK Layer Shell wlroots/sway bar"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
default = [
"http",
"config+all",
"clipboard",
"clock",
"music+all",
"sys_info",
"tray",
"volume+all",
"workspaces+all"
]
http = ["dep:reqwest"]
"config+all" = ["config+json", "config+yaml", "config+toml", "config+corn"]
"config+json" = ["universal-config/json"]
"config+yaml" = ["universal-config/yaml"]
"config+toml" = ["universal-config/toml"]
"config+corn" = ["universal-config/corn"]
clipboard = ["nix"]
clock = ["chrono"]
music = ["regex"]
"music+all" = ["music", "music+mpris", "music+mpd"]
"music+mpris" = ["music", "mpris"]
"music+mpd" = ["music", "mpd_client"]
sys_info = ["sysinfo", "regex"]
tray = ["stray"]
volume = []
"volume+all" = ["volume", "volume+pulse"]
"volume+pulse" = ["libpulse-binding", "libpulse-glib-binding"]
workspaces = ["futures-util"]
"workspaces+all" = ["workspaces", "workspaces+sway", "workspaces+hyprland"]
"workspaces+sway" = ["workspaces", "swayipc-async"]
"workspaces+hyprland" = ["workspaces", "hyprland"]
[dependencies]
gtk = "0.15.5"
gtk-layer-shell = "0.4.1"
glib = "0.15.12"
stray = "0.1.1"
tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread", "time"] }
futures-util = "0.3.21"
chrono = "0.4.19"
# core
gtk = "0.17.0"
gtk-layer-shell = "0.6.0"
glib = "0.17.5"
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time", "process", "sync", "io-util", "net"] }
tracing = "0.1.37"
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
tracing-error = "0.2.0"
tracing-appender = "0.2.2"
strip-ansi-escapes = "0.1.1"
color-eyre = "0.6.2"
serde = { version = "1.0.141", features = ["derive"] }
serde_json = "1.0.82"
serde_yaml = "0.9.4"
toml = "0.5.9"
cornfig = "0.2.0"
mpd_client = "0.7.5"
regex = "1.6.0"
ksway = "0.1.0"
sysinfo = "0.25.1"
indexmap = "1.9.1"
dirs = "4.0.0"
walkdir = "2.3.2"
notify = "4.0.17"
notify = { version = "5.0.0", default-features = false }
wayland-client = "0.29.5"
wayland-protocols = { version = "0.29.5", features = ["unstable_protocols", "client"] }
smithay-client-toolkit = { version = "0.16.0", default-features = false, features = ["calloop"] }
universal-config = { version = "0.2.1", default_features = false }
lazy_static = "1.4.0"
async_once = "0.2.6"
cfg-if = "1.0.0"
# http
reqwest = { version = "0.11.14", optional = true }
# clipboard
nix = { version = "0.26.2", optional = true }
# clock
chrono = { version = "0.4.19", optional = true }
# music
mpd_client = { version = "1.0.0", optional = true }
mpris = { version = "2.0.0", optional = true }
# sys_info
sysinfo = { version = "0.27.0", optional = true }
# tray
stray = { version = "0.1.3", optional = true }
# volume
libpulse-binding = { version = "2.27.1", optional = true }
libpulse-glib-binding = { version = "2.27.1", optional = true }
# workspaces
swayipc-async = { version = "2.0.1", optional = true }
hyprland = { version = "0.3.0", optional = true }
futures-util = { version = "0.3.21", optional = true }
# shared
regex = { version = "1.6.0", default-features = false, features = ["std"], optional = true } # music, sys_info

138
README.md
View File

@@ -1,19 +1,28 @@
# Ironbar
Ironbar is a customisable and feature-rich bar targeting the Sway compositor, written in Rust.
Ironbar is a customisable and feature-rich bar for wlroots compositors, written in Rust.
It uses GTK3 and gtk-layer-shell.
The bar can be styled to your liking using CSS and hot-loads style changes.
The bar can be styled to your liking using CSS and hot-loads style changes.
For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki).
![Screenshot of fully configured bar with MPD widget open](https://user-images.githubusercontent.com/5057870/184539623-92d56a44-a659-49a9-91f9-5cdc453e5dfb.png)
![Screenshot of fully configured bar with MPD widget open](https://f.jstanger.dev/github/ironbar/bar.png?raw)
## Features
- First-class support for Sway and Hyprland, but should (mostly) work on any wlroots compositor.
- Fully themeable with CSS and hot-loaded styles.
- Support for multiple configuration languages.
- Popups used by widgets to show rich content and controls on click.
- Out of the box widgets which can be used to create anything from a lightweight to a more traditional desktop experience.
- Ability to create custom widgets (including popups), run scripts and inject dynamic content.
## Installation
Run using `ironbar`.
### Cargo
Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed.
```sh
cargo install ironbar
```
@@ -28,55 +37,116 @@ yay -S ironbar-git
[aur package](https://aur.archlinux.org/packages/ironbar-git)
### Nix Flake
A flake is included with the repo which can be used with home-manager.
#### Example
Here is an example nix flake that uses Ironbar.
```nix
{
# Add the ironbar flake input
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
inputs.ironbar = {
url = "github:JakeStanger/ironbar";
inputs.nixpkgs.follows = "nixpkgs";
};
inputs.hm = {
url = "github:nix-community/home-manager";
inputs.nixpkgs.follows = "nixpkgs";
};
outputs = inputs: {
homeManagerConfigurations."USER@HOSTNAME" = inputs.hm.lib.homeManagerConfiguration {
pkgs = nixpkgs.legacyPackages.x86_64-linux;
modules = [
# And add the home-manager module
inputs.ironbar.homeManagerModules.default
{
# And configure
programs.ironbar = {
enable = true;
config = {};
style = "";
package = inputs.ironbar;
features = ["feature" "another_feature"];
};
}
];
};
};
}
```
#### Binary Caching
There is a Cachix cache available at `https://app.cachix.org/cache/jakestanger`
in case you don't want to compile Ironbar.
### Source
Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed.
```sh
git clone https://github.com/jakestanger/ironbar.git
cd ironbar
cargo build --release
# change path to wherever you want to install
install target/release/ironbar ~/.local/bin/ironbar
```
By default, all features are enabled.
See [here](https://github.com/JakeStanger/ironbar/wiki/compiling#features) for controlling which features are included.
[repo](https://github.com/jakestanger/ironbar)
## Running
All of the above installation methods provide a binary called `ironbar`.
You can set the `IRONBAR_LOG` or `IRONBAR_FILE_LOG` environment variables to
`error`, `warn`, `info`, `debug` or `trace` to configure the log output level.
These default to `IRONBAR_LOG=info` and `IRONBAR_FILE_LOG=error`.
File output can be found at `~/.local/share/ironbar/error.log`.
## Configuration
Ironbar gives a lot of flexibility when configuring, including multiple file formats
and options for scaling complexity: you can use a single config across all monitors,
or configure different/multiple bars per monitor.
Ironbar gives a lot of flexibility when configuring, including multiple file formats
and options for scaling complexity: you can use a single config across all monitors,
or configure different/multiple bars per monitor.
A full configuration guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/configuration-guide).
## Styling
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the file.
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the
file.
A full styling guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/styling-guide).
## Project Status
This project is in very early stages:
This project is in alpha, but should be usable.
Everything that is implemented works and should be documented.
Proper error handling is in place so things should either fail gracefully with detail, or not fail at all.
- Error handling is barely implemented - expect crashes
- There will be bugs!
- Lots of modules need more configuration options
- There's room for lots of modules
- The code is messy and quite prototypal in places
- Config options aren't set in stone - expect breaking changes
- Documentation is probably missing in lots of places
There is currently room for lots more modules, and lots more configuration options for the existing modules.
The current configuration schema is not set in stone and breaking changes could come along at any point;
until the project matures I am more interested in ease of use than backwards compatibility.
That said, it will be *actively developed* as I am using it on my daily driver.
A few bugs do exist, and I am sure there are plenty more to be found.
The project will be *actively developed* as I am using it on my daily driver.
Bugs will be fixed, features will be added, code will be refactored.
## Contribution Guidelines
I welcome contributions of any kind with open arms. That said, please do stick to some basics:
- For code contributions:
- Fix any `cargo clippy` warnings, using at least the default configuration.
- Make sure your code is formatted using `cargo fmt`.
- Keep any documentation up to date.
- I won't enforce it, but preferably stick to [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) messages.
- For PRs:
- Please open an issue or discussion beforehand.
I'll accept most contributions, but it's best to make sure you're not working on something that won't get accepted :)
- For issues:
- Please provide as much information as you can - share your config, any logs, steps to reproduce...
Please check [here](https://github.com/JakeStanger/ironbar/blob/master/CONTRIBUTING.md).
## Acknowledgements
- [Waybar](https://github.com/Alexays/Waybar) - A lot of the initial inspiration, and a pretty great bar.
- [Rustbar](https://github.com/zeroeightysix/rustbar) - Served as a good demo for writing a basic GTK bar in Rust
- [Smithay Client Toolkit](https://github.com/Smithay/client-toolkit) - Essential in being able to communicate to Wayland

74
docs/Compiling.md Normal file
View File

@@ -0,0 +1,74 @@
You can compile Ironbar from source using `cargo`.
Just clone the repo and build:
```sh
git clone https://github.com/jakestanger/ironbar.git
cd ironbar
cargo build --release
# change path to wherever you want to install
install target/release/ironbar ~/.local/bin/ironbar
```
## Build requirements
To build from source, you must have GTK (>= 3.22) and GTK Layer Shell installed.
### Arch
```shell
pacman -S gtk3 gtk-layer-shell
```
### Ubuntu/Debian
```shell
apt install libgtk-3-dev libgtk-layer-shell-dev
```
### Fedora
```shell
dnf install gtk3 gtk-layer-shell
```
## Features
By default, all features are enabled for convenience. This can result in a significant compile time.
If you know you are not going to need all the features, you can compile with only the features you need.
As of `v0.10.0`, compiling with no features is about 33% faster.
On a 3800X, it takes about 60 seconds for no features and 90 seconds for all.
This difference is expected to increase as the bar develops.
Features containing a `+` can be stacked, for example `config+json` and `config+yaml` could both be enabled.
To build using only specific features, disable default features and pass a comma separated list to `cargo build`:
```shell
cargo build --release --no-default-features \
--features http,config+json,clock
```
> ⚠ Make sure you enable at least one `config` feature otherwise you will not be able to start the bar!
| Feature | Description |
|---------------------|-----------------------------------------------------------------------------------|
| **Core** | |
| http | Enables HTTP features. Currently this includes the ability to load remote images. |
| config+all | Enables support for all configuration languages. |
| config+json | Enables configuration support for JSON. |
| config+yaml | Enables configuration support for YAML. |
| config+toml | Enables configuration support for TOML. |
| config+corn | Enables configuration support for [Corn](https://github.com/jakestanger.corn). |
| **Modules** | |
| clipboard | Enables the `clipboard` module. |
| clock | Enables the `clock` module. |
| music+all | Enables the `music` module with support for all player types. |
| music+mpris | Enables the `music` module with MPRIS support. |
| music+mpd | Enables the `music` module with MPD support. |
| sys_info | Enables the `sys_info` module. |
| tray | Enables the `tray` module. |
| workspaces+all | Enables the `workspaces` module with support for all compositors. |
| workspaces+sway | Enables the `workspaces` module with support for Sway. |
| workspaces+hyprland | Enables the `workspaces` module with support for Hyprland. |

299
docs/Configuration guide.md Normal file
View File

@@ -0,0 +1,299 @@
By default, you get a single bar at the bottom of all your screens.
To change that, you'll unsurprisingly need a config file.
This page details putting together the skeleton for your config to get you to a stage where you can start configuring
modules.
It may look long and overwhelming, but that is just because the bar supports a lot of scenarios!
If you want to see some ready-to-go config files check
the [examples folder](https://github.com/JakeStanger/ironbar/tree/master/examples)
and the example pages in the sidebar.
## 1. Create config file
The config file lives inside the `ironbar` directory in your XDG_CONFIG_DIR, which is usually `~/.config/ironbar`.
Ironbar supports a range of configuration formats, so you can pick your favourite:
- `config.json`
- `config.toml`
- `config.yaml`
- `config.corn` (Experimental, includes variable support for re-using blocks.
See [here](https://github.com/jakestanger/corn) for info)
You can also override the default config path using the `IRONBAR_CONFIG` environment variable.
## 2. Pick your use-case
Ironbar gives you a few ways to configure the bar to suit your needs.
This allows you to keep your config simple and relatively flat if your use-case is simple,
and make it more complex if required.
### a) I want the same bar across all monitors
Place the bar config inside the top-level object. This is automatically applied to each of your monitors.
<details>
<summary>JSON</summary>
```json
{
"position": "bottom",
"height": 42,
"start": [],
"center": [],
"end": []
}
```
</details>
<details>
<summary>TOML</summary>
```toml
position = "bottom"
height = 42
start = []
center = []
end = []
```
</details>
<details>
<summary>YAML</summary>
```yaml
position: "bottom"
height: 42
start: [ ]
center: [ ]
end: [ ]
```
</details>
<details>
<summary>Corn</summary>
```
{
position = "bottom"
height = 42
start = []
center = []
end = []
}
```
</details>
### b) I want my config to differ across one or more monitors
Create a map/object called `monitors` inside the top-level object.
Each of the map's keys should be an output name,
and each value should be an object containing the bar config.
To find your output names, run `wayland-info | grep wl_output -A1`.
<details>
<summary>JSON</summary>
```json
{
"monitors": {
"DP-1": {
"start": []
},
"DP-2": {
"position": "bottom",
"height": 30,
"start": []
}
}
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[monitors]
[monitors.DP-1]
start = []
[monitors.DP-2]
position = "bottom"
height = 30
start = []
```
</details>
<details>
<summary>YAML</summary>
```yaml
monitors:
DP-1:
start: [ ]
DP-2:
position: "bottom"
height: 30
start: [ ]
```
</details>
<details>
<summary>Corn</summary>
```
{
monitors.DP-1.start = []
monitors.DP-2 = {
position = "bottom"
height = 30
start = []
}
}
```
</details>
### c) I want one or more monitors to have multiple bars
Create a map/object called `monitors` inside the top-level object.
Each of the map's keys should be an output name.
If you want the screen to have multiple bars, use an array of bar config objects.
If you want the screen to have a single bar, use an object.
To find your output names, run `wayland-info | grep wl_output -A1`.
<details>
<summary>JSON</summary>
```json
{
"monitors": {
"DP-1": [
{
"start": []
},
{
"position": "top",
"start": []
}
],
"DP-2": {
"position": "bottom",
"height": 30,
"start": []
}
}
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[monitors]
[[monitors.DP-1]]
start = []
[[monitors.DP-2]]
position = "top"
start = []
[monitors.DP-2]
position = "bottom"
height = 30
start = []
```
</details>
<details>
<summary>YAML</summary>
```yaml
monitors:
DP-1:
- start: [ ]
- position: "top"
start: [ ]
DP-2:
position: "bottom"
height: 30
start: [ ]
```
</details>
<details>
<summary>Corn</summary>
```corn
{
monitors.DP-1 = [
{ start = [] }
{ position = "top" start = [] }
]
monitors.DP-2 = {
position = "bottom"
height = 30
start = []
}
}
```
</details>
## 3. Write your bar config(s)
Once you have the basic config structure set up, it's time to actually configure your bar(s).
Check [here](config) for an example config file for a fully configured bar in each format.
### 3.1 Top-level options
The following table lists each of the top-level bar config options:
| Name | Type | Default | Description |
|-------------------|----------------------------------------|----------|-----------------------------------------------------------------------------------------|
| `position` | `top` or `bottom` or `left` or `right` | `bottom` | The bar's position on screen. |
| `anchor_to_edges` | `boolean` | `false` | Whether to anchor the bar to the edges of the screen. Setting to false centres the bar. |
| `height` | `integer` | `42` | The bar's height in pixels. |
| `margin.top` | `integer` | `0` | The margin on the top of the bar |
| `margin.bottom` | `integer` | `0` | The margin on the bottom of the bar |
| `margin.left` | `integer` | `0` | The margin on the left of the bar |
| `margin.right` | `integer` | `0` | The margin on the right of the bar |
| `icon_theme` | `string` | `null` | Name of the GTK icon theme to use. Leave blank to use default. |
| `start` | `Module[]` | `[]` | Array of left or top modules. |
| `center` | `Module[]` | `[]` | Array of center modules. |
| `end` | `Module[]` | `[]` | Array of right or bottom modules. |
### 3.2 Module-level options
The following table lists each of the module-level options that are present on **all** modules.
For details on available modules and each of their config options, check the sidebar.
For information on the `Script` type, and embedding scripts in strings, see [here](script).
| Name | Type | Default | Description |
|-------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
| `show_if` | `Script [polling]` | `null` | Polls the script to check its exit code. If exit code is zero, the module is shown. For other codes, it is hidden. |
| `on_click_left` | `Script [oneshot]` | `null` | Runs the script when the module is left clicked. |
| `on_click_middle` | `Script [oneshot]` | `null` | Runs the script when the module is middle clicked. |
| `on_click_right` | `Script [oneshot]` | `null` | Runs the script when the module is right clicked. |
| `on_scroll_up` | `Script [oneshot]` | `null` | Runs the script when the module is scroll up on. |
| `on_scroll_down` | `Script [oneshot]` | `null` | Runs the script when the module is scrolled down on. |
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |

4
docs/Home.md Normal file
View File

@@ -0,0 +1,4 @@
Welcome to the Ironbar wiki.
Detail about each module, and their configuration and styling options can be found on the sidebar.
You can also find an example configuration and stylesheet there.

15
docs/Images.md Normal file
View File

@@ -0,0 +1,15 @@
Ironbar is capable of loading images from multiple sources.
In any situation where an option takes text or an icon,
you can use a string in any of the following formats, and it will automatically be detected as an image:
| Source | Example |
|-------------------------------|---------------------------------|
| GTK icon theme | `icon:firefox` |
| Local file | `file:///path/to/file.jpg` |
| Remote file (over HTTP/HTTPS) | `https://example.com/image.jpg` |
Remote images are loaded asynchronously to avoid blocking the UI thread.
Be aware this can cause elements to change size upon load if the image is large enough.
Note that mixing text and images is not supported.
Your best option here is to use Nerd Font icons instead.

107
docs/Scripts.md Normal file
View File

@@ -0,0 +1,107 @@
There are various places inside the configuration (other than the `script` module)
that allow script input to dynamically set values.
Scripts are passed to `sh -c`.
Three types of scripts exist: polling, oneshot and watching:
- **Polling** scripts will run and wait for exit.
Normally they will repeat this at an interval, hence the name, although in some cases they may only run on a user
event.
If the script exited code 0, the `stdout` will be used. Otherwise, `stderr` will be printed to the log.
- **Oneshot** scripts are a variant of polling scripts.
They wait for script to exit, and may do something with the output, but are only fired by user events instead of the interval.
Generally options that accept oneshot scripts do not support the other types.
- **Watching** scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
and used.
One should prefer to use watch-mode where possible, as it removes the overhead of regularly spawning processes.
That said, there are some cases which only support polling. These are indicated by `Script [polling]` as the option
type.
## Writing script configs
There are two available config formats for scripts, shorthand as a string, or longhand as an object.
Shorthand can be used in all cases, but there are some cases (such as embedding scripts inside strings) where longhand
cannot be used.
In both formats, `mode` is one of `poll` or `watch` and `interval` is the number of milliseconds to wait between
spawning the script.
Both `mode` and `interval` are optional and can be excluded to fall back to their defaults of `poll` and `5000`
respectively.
For oneshot scripts, both the mode and interval are ignored.
### Shorthand (string)
Shorthand scripts should be written in the format:
```
mode:interval:script
```
For example:
```
poll:5000:uptime -p | cut -d ' ' -f2-
```
#### Embedding
Some string config options support "embedding scripts". This allows you to mix static/dynamic content.
An example of this is the common `tooltip` option.
Scripts can be embedded in these cases using `{{double braces}}` and the shorthand syntax:
```json
"Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
```
### Longhand (object)
An object consisting of the `cmd` key and optionally the `mode` and/or `interval` keys.
<details>
<summary>JSON</summary>
```json
{
"mode": "poll",
"interval": 5000,
"cmd": "uptime -p | cut -d ' ' -f2-"
}
```
</details>
<details>
<summary>YAML</summary>
```yaml
mode: poll
interval: 5000
cmd: "uptime -p | cut -d ' ' -f2-"
```
</details>
<details>
<summary>YAML</summary>
```toml
mode = "poll"
interval = 5000
cmd = "uptime -p | cut -d ' ' -f2-"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
mode = "poll"
interval = 5000
cmd = "uptime -p | cut -d ' ' -f2-"
}
```
</details>

20
docs/Styling guide.md Normal file
View File

@@ -0,0 +1,20 @@
Ironbar ships with no styles by default, so will fall back to the default GTK styles.
To style the bar, create a file at `~/.config/ironbar/style.css`.
Style changes are hot-loaded so there is no need to reload the bar.
A reminder: since the bar is GTK-based, it uses GTK's implementation of CSS,
which only includes a subset of the full web spec (plus a few non-standard properties).
The below table describes the selectors provided by the bar itself.
Information on styling individual modules can be found on their pages in the sidebar.
| Selector | Description |
|----------------|-------------------------------------------|
| `.background` | Top-level window |
| `#bar` | Bar root box |
| `#bar #start` | Bar left or top modules container box |
| `#bar #center` | Bar center modules container box |
| `#bar #end` | Bar right or bottom modules container box |
| `.container` | All of the above |

29
docs/_Sidebar.md Normal file
View File

@@ -0,0 +1,29 @@
# Guides
- [Compiling from source](compiling)
- [Configuration guide](configuration-guide)
- [Scripts](scripts)
- [Images](images)
- [Styling guide](styling-guide)
# Examples
- [Config](config)
- [Stylesheet](https://github.com/JakeStanger/ironbar/blob/master/examples/style.css)
## Custom
- [Power Menu](power-menu)
# Modules
- [Clipboard](clipboard)
- [Clock](clock)
- [Custom](custom)
- [Focused](focused)
- [Launcher](launcher)
- [Music](music)
- [Script](script)
- [Sys_Info](sys-info)
- [Tray](tray)
- [Workspaces](workspaces)

10
docs/examples/Config.md Normal file
View File

@@ -0,0 +1,10 @@
The configs linked below show a module of each type being used.
The Corn format makes heavy use of variables
to show how module configs can be easily referenced to improve readability
and reduce config length when using multiple bars.
- [JSON](https://github.com/JakeStanger/ironbar/blob/master/examples/config.json)
- [TOML](https://github.com/JakeStanger/ironbar/blob/master/examples/config.toml)
- [YAML](https://github.com/JakeStanger/ironbar/blob/master/examples/config.yaml)
- [Corn](https://github.com/JakeStanger/ironbar/blob/master/examples/config.corn)

View File

@@ -0,0 +1,241 @@
Creates a button on the bar, which opens a popup. The popup contains a header, shutdown button, restart button, and uptime.
![A screenshot of the custom power menu module open, with some other modules present on the bar](https://f.jstanger.dev/github/ironbar/custom-power-menu.png)
## Configuration
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "clock"
},
{
"bar": [
{
"label": "",
"name": "power-btn",
"on_click": "popup:toggle",
"type": "button"
}
],
"class": "power-menu",
"popup": [
{
"orientation": "vertical",
"type": "box",
"widgets": [
{
"label": "Power menu",
"name": "header",
"type": "label"
},
{
"type": "box",
"widgets": [
{
"class": "power-btn",
"label": "<span font-size='40pt'></span>",
"on_click": "!shutdown now",
"type": "button"
},
{
"class": "power-btn",
"label": "<span font-size='40pt'></span>",
"on_click": "!reboot",
"type": "button"
}
]
},
{
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
"name": "uptime",
"type": "label"
}
]
}
],
"tooltip": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
"type": "custom"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = 'clock'
[[end]]
class = 'power-menu'
tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
type = 'custom'
[[end.bar]]
label = ''
name = 'power-btn'
on_click = 'popup:toggle'
type = 'button'
[[end.popup]]
orientation = 'vertical'
type = 'box'
[[end.popup.widgets]]
label = 'Power menu'
name = 'header'
type = 'label'
[[end.popup.widgets]]
type = 'box'
[[end.popup.widgets.widgets]]
class = 'power-btn'
label = '''<span font-size='40pt'></span>'''
on_click = '!shutdown now'
type = 'button'
[[end.popup.widgets.widgets]]
class = 'power-btn'
label = '''<span font-size='40pt'></span>'''
on_click = '!reboot'
type = 'button'
[[end.popup.widgets]]
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
name = 'uptime'
type = 'label'
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: clock
- bar:
- label:
name: power-btn
on_click: popup:toggle
type: button
class: power-menu
popup:
- orientation: vertical
type: box
widgets:
- label: Power menu
name: header
type: label
- type: box
widgets:
- class: power-btn
label: <span font-size='40pt'></span>
on_click: '!shutdown now'
type: button
- class: power-btn
label: <span font-size='40pt'></span>
on_click: '!reboot'
type: button
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
name: uptime
type: label
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
type: custom
```
</details>
<details>
<summary>Corn</summary>
```corn
let {
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
$popup = {
type = "box"
orientation = "vertical"
widgets = [
{ type = "label" name = "header" label = "Power menu" }
{
type = "box"
widgets = [
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
]
}
{ type = "label" name = "uptime" label = "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}" }
]
}
$power_menu = {
type = "custom"
class = "power-menu"
bar = [ $button ]
popup = [ $popup ]
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
}
$clock = { type = "clock" }
} in {
end = [ $power_menu $clock ]
}
```
</details>
## Styling
```css
.power-menu {
margin-left: 10px;
}
.power-menu #power-btn {
color: white;
background-color: #2d2d2d;
}
.power-menu #power-btn:hover {
background-color: #1c1c1c;
}
.popup-power-menu {
padding: 1em;
}
.popup-power-menu #header {
color: white;
font-size: 1.4em;
border-bottom: 1px solid white;
padding-bottom: 0.4em;
margin-bottom: 0.8em;
}
.popup-power-menu .power-btn {
color: white;
background-color: #2d2d2d;
border: 1px solid white;
padding: 0.6em 1em;
}
.popup-power-menu .power-btn + .power-btn {
margin-left: 1em;
}
.popup-power-menu .power-btn:hover {
background-color: #1c1c1c;
}
```

93
docs/modules/Clipboard.md Normal file
View File

@@ -0,0 +1,93 @@
Shows recent clipboard items, allowing you to switch between them to re-copy previous values.
Clicking the icon button opens the popup containing all functionality.
Supports plain text and images.
![Screenshot of clipboard popup open, with two textual values and an image copied. Several other unrelated widgets are visible on the bar.](https://f.jstanger.dev/github/ironbar/clipboard.png?raw)
## Configuration
> Type: `clipboard`
| Name | Type | Default | Description |
|-----------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `icon` | `string/image` | `󰨸` | Icon to show on the widget button. |
| `max_items` | `integer` | `10` | Maximum number of items to show in the popup. |
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
See [here](images) for information on images.
<details>
<summary>JSON</summary>
```json
{
"end": {
"type": "clipboard",
"max_items": 3,
"truncate": {
"mode": "end",
"length": 50
}
}
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "clipboard"
max_items = 3
[[end.truncate]]
mode = "end"
length = 50
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: 'clipboard'
max_items: 3
truncate:
mode: 'end'
length: 50
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [ {
type = "clipboard"
max_items = 3
truncate.mode = "end"
truncate.length = 50
} ]
}
```
</details>
## Styling
| Selector | Description |
|--------------------------------------|------------------------------------------------------|
| `#clipboard` | Clipboard widget. |
| `#clipboard .btn` | Clipboard widget button. |
| `#popup-clipboard` | Clipboard popup box. |
| `#popup-clipboard .item` | Clipboard row item inside the popup. |
| `#popup-clipboard .item .btn` | Clipboard row item radio button. |
| `#popup-clipboard .item .btn.text` | Clipboard row item radio button (text values only). |
| `#popup-clipboard .item .btn.image` | Clipboard row item radio button (image values only). |
| `#popup-clipboard .item .btn-remove` | Clipboard row item remove button. |

77
docs/modules/Clock.md Normal file
View File

@@ -0,0 +1,77 @@
Displays the current date and time.
Clicking on the widget opens a popup with the time and a calendar.
![Screenshot of clock widget with popup open](https://user-images.githubusercontent.com/5057870/184540521-2278bdec-9742-46f0-9ac2-58a7b6f6ea1d.png)
## Configuration
> Type: `clock`
| Name | Type | Default | Description |
|----------|----------|------------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `format` | `string` | `%d/%m/%Y %H:%M` | Date/time format string. Detail on available tokens can be found here: <https://docs.rs/chrono/latest/chrono/format/strftime/index.html> |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "clock",
"format": "%d/%m/%Y %H:%M"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "clock"
format = "%d/%m/%Y %H:%M"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "clock"
format: "%d/%m/%Y %H:%M"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "clock"
format = "%d/%m/%Y %H:%M"
}
]
}
```
</details>
## Styling
| Selector | Description |
|--------------------------------|------------------------------------------------------------------------------------|
| `#clock` | Clock widget button |
| `#popup-clock` | Clock popup box |
| `#popup-clock #calendar-clock` | Clock inside the popup |
| `#popup-clock #calendar` | Calendar widget inside the popup. GTK provides some OOTB styling options for this. |

276
docs/modules/Custom.md Normal file
View File

@@ -0,0 +1,276 @@
Allows you to compose custom modules consisting of multiple widgets, including popups.
Labels can display dynamic content from scripts, and buttons can interact with the bar or execute commands on click.
![Custom module with a button on the bar, and the popup open. The popup contains a header, shutdown button and restart button.](https://f.jstanger.dev/github/ironbar/custom-power-menu.png)
## Configuration
> Type: `custom`
This module can be quite fiddly to configure as you effectively have to build a tree of widgets by hand.
It is well worth looking at the examples.
| Name | Type | Default | Description |
|---------|------------|---------|--------------------------------------|
| `class` | `string` | `null` | Container class name. |
| `bar` | `Widget[]` | `null` | List of widgets to add to the bar. |
| `popup` | `Widget[]` | `[]` | List of widgets to add to the popup. |
### `Widget`
| Name | Type | Default | Description |
|---------------|-----------------------------------------|--------------|---------------------------------------------------------------------------|
| `widget_type` | `box` or `label` or `button` or `image` | `null` | Type of GTK widget to create. |
| `name` | `string` | `null` | Widget name. |
| `class` | `string` | `null` | Widget class name. |
| `label` | `string` | `null` | [`label` and `button`] Widget text label. Pango markup supported. |
| `on_click` | `string` | `null` | [`button`] Command to execute. More on this [below](#commands). |
| `src` | `image` | `null` | [`image`] Image source. See [here](images) for information on images. |
| `size` | `integer` | `null` | [`image`] Width/height of the image. Aspect ratio is preserved. |
| `orientation` | `horizontal` or `vertical` | `horizontal` | [`box`] Whether child widgets should be horizontally or vertically added. |
| `widgets` | `Widget[]` | `[]` | [`box`] List of widgets to add to this box. |
### Labels
Labels can interpolate text from scripts to dynamically show content.
This can be done by including scripts in `{{double braces}}` using the shorthand script syntax.
For example, the following label would output your system uptime, updated every 30 seconds.
```
Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}
```
Both polling and watching mode are supported. For more information on script syntax, see [here](scripts).
### Commands
Buttons can execute commands that interact with the bar,
as well as any arbitrary shell command.
To execute shell commands, prefix them with an `!`.
For example, if you want to run `~/.local/bin/my-script.sh` on click,
you'd set `on_click` to `!~/.local/bin/my-script.sh`.
The following bar commands are supported:
- `popup:toggle`
- `popup:open`
- `popup:close`
---
XML is arguably better-suited and easier to read for this sort of markup,
but currently is not supported.
Nonetheless, it may be worth comparing the examples to the below equivalent
to help get your head around what's going on:
```xml
<?xml version="1.0" encoding="utf-8" ?>
<custom class="power-menu">
<bar>
<button name="power-btn" label="" on_click="popup:toggle"/>
</bar>
<popup>
<box orientation="vertical">
<label name="header" label="Power menu" />
<box>
<button class="power-btn" label="" on_click="!shutdown now" />
<button class="power-btn" label="" on_click="!reboot" />
</box>
<label name="uptime" label="Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}" />
</box>
</popup>
</custom>
```
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "clock"
},
{
"bar": [
{
"on_click": "popup:toggle",
"label": "",
"name": "power-btn",
"type": "button"
}
],
"class": "power-menu",
"popup": [
{
"orientation": "vertical",
"type": "box",
"widgets": [
{
"label": "Power menu",
"name": "header",
"type": "label"
},
{
"type": "box",
"widgets": [
{
"class": "power-btn",
"on_click": "!shutdown now",
"label": "<span font-size='40pt'></span>",
"type": "button"
},
{
"class": "power-btn",
"on_click": "!reboot",
"label": "<span font-size='40pt'></span>",
"type": "button"
}
]
},
{
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
"name": "uptime",
"type": "label"
}
]
}
],
"type": "custom"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = 'clock'
[[end]]
class = 'power-menu'
type = 'custom'
[[end.bar]]
on_click = 'popup:toggle'
label = ''
name = 'power-btn'
type = 'button'
[[end.popup]]
orientation = 'vertical'
type = 'box'
[[end.popup.widgets]]
label = 'Power menu'
name = 'header'
type = 'label'
[[end.popup.widgets]]
type = 'box'
[[end.popup.widgets.widgets]]
class = 'power-btn'
on_click = '!shutdown now'
label = '''<span font-size='40pt'></span>'''
type = 'button'
[[end.popup.widgets.widgets]]
class = 'power-btn'
on_click = '!reboot'
label = '''<span font-size='40pt'></span>'''
type = 'button'
[[end.popup.widgets]]
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
name = 'uptime'
type = 'label'
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: clock
- bar:
- on_click: popup:toggle
label:
name: power-btn
type: button
class: power-menu
popup:
- orientation: vertical
type: box
widgets:
- label: Power menu
name: header
type: label
- type: box
widgets:
- class: power-btn
on_click: '!shutdown now'
label: <span font-size='40pt'></span>
type: button
- class: power-btn
on_click: '!reboot'
label: <span font-size='40pt'></span>
type: button
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
name: uptime
type: label
type: custom
```
</details>
<details>
<summary>Corn</summary>
```corn
let {
$power_menu = {
type = "custom"
class = "power-menu"
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
popup = [ {
type = "box"
orientation = "vertical"
widgets = [
{ type = "label" name = "header" label = "Power menu" }
{
type = "box"
widgets = [
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
]
}
{ type = "label" name = "uptime" label = "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}" }
]
} ]
}
} in {
end = [ $power_menu ]
}
```
</details>
## Styling
Since the widgets are all custom, you can target them using `#name` and `.class`.
| Selector | Description |
|-----------|-------------------------|
| `#custom` | Custom widget container |

93
docs/modules/Focused.md Normal file
View File

@@ -0,0 +1,93 @@
Displays the title and/or icon of the currently focused window.
![Screenshot of focused widget, showing this page open on firefox](https://user-images.githubusercontent.com/5057870/184714118-c1fb1c67-cd8c-4cc0-b5cd-6faccff818ac.png)
## Configuration
> Type: `focused`
| Name | Type | Default | Description |
|-----------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `show_icon` | `boolean` | `true` | Whether to show the app's icon |
| `show_title` | `boolean` | `true` | Whether to show the app's title |
| `icon_size` | `integer` | `32` | Size of icon in pixels |
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "focused",
"show_icon": true,
"show_title": true,
"icon_size": 32,
"truncate": "end"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "focused"
show_icon = true
show_title = true
icon_size = 32
truncate = "end"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "focused"
show_icon: true
show_title: true
icon_size: 32
truncate: "end"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "focused"
show_icon = true
show_title = true
icon_size = 32
truncate = "end"
}
]
}
```
</details>
## Styling
| Selector | Description |
|--------------------------|--------------------|
| `#focused` | Focused widget box |
| `#focused #icon` | App icon |
| `#focused #label` | App name |

98
docs/modules/Launcher.md Normal file
View File

@@ -0,0 +1,98 @@
Windows-style taskbar that displays running windows, grouped by program.
Hovering over a program with multiple windows open shows a popup with each window.
Clicking an icon/popup item focuses or launches the program.
Optionally displays a launchable set of favourites.
![Screenshot showing several open applications, including a popup showing multiple terminal windows.](https://f.jstanger.dev/github/ironbar/launcher.png)
## Configuration
> Type: `launcher`
| | Type | Default | Description |
|--------------|------------|---------|-----------------------------------------------------------------------------------------------------|
| `favorites` | `string[]` | `[]` | List of app IDs (or classes) to always show at the start of the launcher |
| `show_names` | `boolean` | `false` | Whether to show app names on the button label. Names will still show on tooltips when set to false. |
| `show_icons` | `boolean` | `true` | Whether to show app icons on the button. |
<details>
<summary>JSON</summary>
```json
{
"start": [
{
"type": "launcher",
"favourites": [
"firefox",
"discord"
],
"show_names": false,
"show_icons": true
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[start]]
type = "launcher"
favorites = ["firefox", "discord"]
show_names = false
show_icons = true
```
</details>
<details>
<summary>YAML</summary>
```yaml
start:
- type: "launcher"
favorites:
- firefox
- discord
show_names: false
show_icons: true
```
</details>
<details>
<summary>Corn</summary>
```corn
{
start = [
{
type = "launcher"
favorites = [ "firefox" "discord" ]
show_names = false
show_icons = true
}
]
}
```
</details>
## Styling
| Selector | Description |
|-------------------------------|--------------------------|
| `#launcher` | Launcher widget box |
| `#launcher .item` | App button |
| `#launcher .item.open` | App button (open app) |
| `#launcher .item.focused` | App button (focused app) |
| `#launcher .item.urgent` | App button (urgent app) |
| `#launcher-popup` | Popup container |
| `#launcher-popup .popup-item` | Window button in popup |

154
docs/modules/Music.md Normal file
View File

@@ -0,0 +1,154 @@
Displays currently playing song from your music player.
This module supports both MPRIS players and MPD servers.
Clicking on the widget opens a popout displaying info about the current song, album art
and playback controls.
in MPRIS mode, the widget will listen to all players and automatically detect/display the active one.
![Screenshot showing MPD widget with track playing with popout open](https://f.jstanger.dev/github/ironbar/music.png)
## Configuration
> Type: `music`
| | Type | Default | Description |
|-----------------------|---------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| `player_type` | `mpris` or `mpd` | `mpris` | Whether to connect to MPRIS players or an MPD server. |
| `format` | `string` | `{title} / {artist}` | Format string for the widget. More info below. |
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
| `icons.play` | `string/image` | `` | Icon to show when playing. |
| `icons.pause` | `string/image` | `` | Icon to show when paused. |
| `icons.prev` | `string/image` | `玲` | Icon to show on previous button. |
| `icons.next` | `string/image` | `怜` | Icon to show on next button. |
| `icons.volume` | `string/image` | `墳` | Icon to show under popup volume slider. |
| `icons.track` | `string/image` | `` | Icon to show next to track title. |
| `icons.album` | `string/image` | `` | Icon to show next to album name. |
| `icons.artist` | `string/image` | `ﴁ` | Icon to show next to artist name. |
| `host` | `string/image` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. |
| `music_dir` | `string/image` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. |
See [here](images) for information on images.
<details>
<summary>JSON</summary>
```json
{
"start": [
{
"type": "music",
"player_type": "mpd",
"format": "{title} / {artist}",
"truncate": "end",
"icons": {
"play": "",
"pause": ""
},
"music_dir": "/home/jake/Music"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[start]]
type = "music"
player_type = "mpd"
format = "{title} / {artist}"
music_dir = "/home/jake/Music"
truncate = "end"
[[start.icons]]
play = ""
pause = ""
```
</details>
<details>
<summary>YAML</summary>
```yaml
start:
- type: "music"
player_type: "mpd"
format: "{title} / {artist}"
truncate: "end"
icons:
play: ""
pause: ""
music_dir: "/home/jake/Music"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
start = [
{
type = "music"
player_type = "mpd"
format = "{title} / {artist}"
truncate = "end"
icons.play = ""
icons.pause = ""
music_dir = "/home/jake/Music"
}
]
}
```
</details>
### Formatting Tokens
The following tokens can be used in the `format` config option,
and will be replaced with values from the currently playing track:
| Token | Description |
|--------------|--------------------------------------|
| `{title}` | Title |
| `{album}` | Album name |
| `{artist}` | Artist name |
| `{date}` | Release date |
| `{track}` | Track number |
| `{disc}` | Disc number |
| `{genre}` | Genre |
| `{duration}` | Duration in `mm:ss` |
| `{elapsed}` | Time elapsed in `mm:ss` |
## Styling
| Selector | Description |
|-------------------------------------|------------------------------------------|
| `#music` | Tray widget button |
| `#popup-music` | Popup box |
| `#popup-music #album-art` | Album art image inside popup box |
| `#popup-music #title` | Track title container inside popup box |
| `#popup-music #title .icon` | Track title icon label inside popup box |
| `#popup-music #title .label` | Track title label inside popup box |
| `#popup-music #album` | Track album container inside popup box |
| `#popup-music #album .icon` | Track album icon label inside popup box |
| `#popup-music #album .label` | Track album label inside popup box |
| `#popup-music #artist` | Track artist container inside popup box |
| `#popup-music #artist .icon` | Track artist icon label inside popup box |
| `#popup-music #artist .label` | Track artist label inside popup box |
| `#popup-music #controls` | Controls container inside popup box |
| `#popup-music #controls #btn-prev` | Previous button inside popup box |
| `#popup-music #controls #btn-play` | Play button inside popup box |
| `#popup-music #controls #btn-pause` | Pause button inside popup box |
| `#popup-music #controls #btn-next` | Next button inside popup box |
| `#popup-music #volume` | Volume container inside popup box |
| `#popup-music #volume #slider` | Volume slider popup box |
| `#popup-music #volume .icon` | Volume icon label inside popup box |

87
docs/modules/Script.md Normal file
View File

@@ -0,0 +1,87 @@
Executes a script and shows the result of `stdout` on a label.
Pango markup is supported.
## Configuration
> Type: `script`
| Name | Type | Default | Description |
|------------|-----------------------|---------|---------------------------------------------------------|
| `cmd` | `string` | `null` | Path to the script on disk |
| `mode` | `'poll'` or `'watch'` | `poll` | See [#modes](#modes) |
| `interval` | `number` | `5000` | Number of milliseconds to wait between executing script |
### Modes
- Use `poll` to run the script wait for it to exit. On exit, the label is updated to show everything the script wrote to `stdout`.
- Use `watch` to start a long-running script. Every time the script writes to `stdout`, the label is updated to show the latest line.
Note this does not work for all programs as they may use block-buffering instead of line-buffering when they detect output being piped.
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "script",
"cmd": "/home/jake/.local/bin/phone-battery",
"mode": "poll",
"interval": 5000
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "script"
cmd = "/home/jake/.local/bin/phone-battery"
mode = "poll"
interval = 5000
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "script"
cmd: "/home/jake/.local/bin/phone-battery"
mode: 'poll'
interval : 5000
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "script"
cmd = "/home/jake/.local/bin/phone-battery"
mode = "poll"
interval = 5000
}
]
}
```
</details>
## Styling
| Selector | Description |
|---------------|---------------------|
| `#script` | Script widget label |

176
docs/modules/Sys-Info.md Normal file
View File

@@ -0,0 +1,176 @@
Displays one or more labels containing system information.
Separating information across several labels allows for styling each one independently.
Pango markup is supported.
![Screenshot showing sys-info module with widgets for all of the types of formatting tokens](https://user-images.githubusercontent.com/5057870/196059090-4056d083-69f0-4e6f-9673-9e35dc29d9f0.png)
## Configuration
> Type: `sys_info`
| Name | Type | Default | Description |
|--------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------------------|
| `format` | `string[]` | `null` | Array of strings including formatting tokens. For available tokens see below. |
| `interval` | `integer` or `Map` | `5` | Seconds between refreshing. Can be a single value for all data or a map of individual refresh values for different data types. |
| `interval.memory` | `integer` | `5` | Seconds between refreshing memory data |
| `interval.cpu` | `integer` | `5` | Seconds between refreshing cpu data |
| `interval.temps` | `integer` | `5` | Seconds between refreshing temperature data |
| `interval.disks` | `integer` | `5` | Seconds between refreshing disk data |
| `interval.network` | `integer` | `5` | Seconds between refreshing network data |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"format": [
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C",
" {memory_used} / {memory_total} GB ({memory_percent}%)",
"| {swap_used} / {swap_total} GB ({swap_percent}%)",
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)",
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps",
"猪 {load_average:1} | {load_average:5} | {load_average:15}",
" {uptime}"
],
"interval": {
"cpu": 1,
"disks": 300,
"memory": 30,
"networks": 3,
"temps": 5
},
"type": "sys_info"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = 'sys_info'
format = [
' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C',
' {memory_used} / {memory_total} GB ({memory_percent}%)',
'| {swap_used} / {swap_total} GB ({swap_percent}%)',
' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)',
'李 {net_down:enp39s0} / {net_up:enp39s0} Mbps',
'猪 {load_average:1} | {load_average:5} | {load_average:15}',
' {uptime}',
]
[end.interval]
cpu = 1
disks = 300
memory = 30
networks = 3
temps = 5
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- format:
- ' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C'
- ' {memory_used} / {memory_total} GB ({memory_percent}%)'
- '| {swap_used} / {swap_total} GB ({swap_percent}%)'
- ' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)'
- '李 {net_down:enp39s0} / {net_up:enp39s0} Mbps'
- '猪 {load_average:1} | {load_average:5} | {load_average:15}'
- ' {uptime}'
interval:
cpu: 1
disks: 300
memory: 30
networks: 3
temps: 5
type: sys_info
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "sys_info"
interval.memory = 30
interval.cpu = 1
interval.temps = 5
interval.disks = 300
interval.networks = 3
format = [
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C"
" {memory_used} / {memory_total} GB ({memory_percent}%)"
"| {swap_used} / {swap_total} GB ({swap_percent}%)"
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)"
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps"
"猪 {load_average:1} | {load_average:5} | {load_average:15}"
" {uptime}"
]
}
]
}
```
</details>
### Formatting Tokens
The following tokens can be used in the `format` configuration option:
| Token | Description |
|--------------------------|------------------------------------------------------------------------------------|
| **CPU** | |
| `{cpu_percent}` | Total CPU utilisation percentage |
| **Memory** | |
| `{memory_free}` | Memory free in GB. |
| `{memory_used}` | Memory used in GB. |
| `{memory_total}` | Memory total in GB. |
| `{memory_percent}` | Memory utilisation percentage. |
| `{swap_free}` | Swap free in GB. |
| `{swap_used}` | Swap used in GB. |
| `{swap_total}` | Swap total in GB. |
| `{swap_percent}` | Swap utilisation percentage. |
| **Temperature** | |
| `{temp_c:[sensor]}` | Temperature in degrees C. Replace `[sensor]` with the sensor label. |
| `{temp_f:[sensor]}` | Temperature in degrees F. Replace `[sensor]` with the sensor label. |
| **Disk** | |
| `{disk_free:[mount]}` | Disk free space in GB. Replace `[mount]` with the disk mountpoint. |
| `{disk_used:[mount]}` | Disk used space in GB. Replace `[mount]` with the disk mountpoint. |
| `{disk_total:[mount]}` | Disk total space in GB. Replace `[mount]` with the disk mountpoint. |
| `{disk_percent:[mount]}` | Disk utilisation percentage. Replace `[mount]` with the disk mountpoint. |
| **Network** | |
| `{net_down:[adapter]}` | Average network download speed in Mbps. Replace `[adapter]` with the adapter name. |
| `{net_up:[adapter]}` | Average network upload speed in Mbps. Replace `[adapter]` with the adapter name. |
| **System** | |
| `{load_average:1}` | 1-minute load average. |
| `{load_average:5}` | 5-minute load average. |
| `{load_average:15}` | 15-minute load average. |
| `{uptime}` | System uptime formatted as `HH:mm`. |
## Styling
| Selector | Description |
|------------------|------------------------------|
| `#sysinfo` | Sysinfo widget box |
| `#sysinfo #item` | Individual information label |

64
docs/modules/Tray.md Normal file
View File

@@ -0,0 +1,64 @@
Displays a fully interactive icon tray using the KDE `libappindicator` protocol.
![Screenshot showing icon tray widget](https://user-images.githubusercontent.com/5057870/184540135-78ffd79d-f802-4c79-b09a-05a733dadc55.png)
## Configuration
> Type: `tray`
***This module provides no configuration options.***
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "tray"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "tray"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "tray"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{ type = "tray" }
]
}
```
</details>
## Styling
| Selector | Description |
|---------------|------------------|
| `#tray` | Tray widget box |
| `#tray .item` | Tray icon button |

View File

@@ -0,0 +1,95 @@
> ⚠ **This module is currently only supported on Sway and Hyprland**
Shows all current workspaces. Clicking a workspace changes focus to it.
![Screenshot showing workspaces widget using custom icons with browser workspace focused](https://user-images.githubusercontent.com/5057870/184540156-26cfe4ec-ab8d-4e0f-a883-8b641025366b.png)
## Configuration
> Type: `workspaces`
| Name | Type | Default | Description |
|----------------|-----------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `name_map` | `Map<string, string/image>` | `{}` | A map of actual workspace names to their display labels/images. Workspaces use their actual name if not present in the map. See [here](images) for information on images. |
| `all_monitors` | `boolean` | `false` | Whether to display workspaces from all monitors. When `false`, only shows workspaces on the current monitor. |
| `sort` | `added` or `alphanumeric` | `alphanumeric` | The method used for sorting workspaces. `added` always appends to the end, `alphanumeric` sorts by number/name. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "workspaces",
"name_map": {
"1": "",
"2": "",
"3": ""
},
"all_monitors": false
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "workspaces"
all_monitors = false
[[end.name_map]]
1 = ""
2 = ""
3 = ""
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "workspaces"
name_map:
1: ""
2: ""
3: ""
all_monitors: false
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "workspaces",
name_map.1 = ""
name_map.2 = ""
name_map.3 = ""
all_monitors = false
}
]
}
```
</details>
## Styling
| Selector | Description |
|-----------------------------|--------------------------------------|
| `#workspaces` | Workspaces widget box |
| `#workspaces .item` | Workspace button |
| `#workspaces .item.focused` | Workspace button (workspace focused) |

View File

@@ -4,40 +4,107 @@ let {
all_monitors = false
name_map = {
1 = "ﭮ"
2 = ""
2 = "icon:firefox"
3 = ""
Games = ""
Games = "icon:steam"
Code = ""
}
}
$focused = { type = "focused" }
$launcher = {
type = "launcher"
favorites = ["firefox" "discord" "Steam"]
show_names = false
show_icons = true
icon_theme = "Paper"
}
$mpd_local = { type = "mpd" music_dir = "/home/jake/Music" }
$mpd_server = { type = "mpd" host = "chloe:6600" }
$mpris = {
type = "music"
player_type = "mpris"
on_click_middle = "playerctl play-pause"
on_scroll_up = "playerctl volume +5"
on_scroll_down = "playerctl volume -5"
}
$mpd_local = { type = "music" player_type = "mpd" music_dir = "/home/jake/Music" truncate.mode = "end" truncate.max_length = 100 }
$mpd_server = { type = "music" player_type = "mpd" host = "chloe:6600" truncate = "end" }
$sys_info = {
type = "sys-info"
format = ["{cpu-percent}% " "{memory-percent}% "]
type = "sys_info"
interval.memory = 30
interval.cpu = 1
interval.temps = 5
interval.disks = 300
interval.networks = 3
format = [
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C"
" {memory_used} / {memory_total} GB ({memory_percent}%)"
"| {swap_used} / {swap_total} GB ({swap_percent}%)"
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)"
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps"
"猪 {load_average:1} | {load_average:5} | {load_average:15}"
" {uptime}"
]
}
$tray = { type = "tray" }
$clock = { type = "clock" }
$phone_battery = {
type = "script"
path = "/home/jake/bin/phone-battery"
cmd = "/home/jake/bin/phone-battery"
show_if.cmd = "/home/jake/bin/phone-connected"
show_if.interval = 500
}
$clipboard = { type = "clipboard" max_items = 3 truncate.mode = "end" truncate.length = 50 }
// -- begin custom --
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
$popup = {
type = "box"
orientation = "vertical"
widgets = [
{ type = "label" name = "header" label = "Power menu" }
{
type = "box"
widgets = [
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
]
}
{ type = "label" name = "uptime" label = "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}" }
]
}
$power_menu = {
type = "custom"
class = "power-menu"
bar = [ $button ]
popup = [ $popup ]
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
}
// -- end custom --
$left = [ $workspaces $launcher ]
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clipboard $power_menu $clock ]
}
in {
left = $left right = $right
anchor_to_edges = true
position = "bottom"
icon_theme = "Paper"
start = $left
end = $right
}

View File

@@ -1,18 +1,131 @@
{
"monitors": {
"DP-1": [
{
"left": [{"type": "clock"}]
"anchor_to_edges": true,
"end": [
{
"music_dir": "/home/jake/Music",
"player_type": "mpd",
"truncate": {
"max_length": 100,
"mode": "end"
},
{
"position": "top",
"left": []
}
],
"DP-2": {
"position": "bottom",
"height": 30,
"left": []
"type": "music"
},
{
"host": "chloe:6600",
"player_type": "mpd",
"truncate": "end",
"type": "music"
},
{
"cmd": "/home/jake/bin/phone-battery",
"show_if": {
"cmd": "/home/jake/bin/phone-connected",
"interval": 500
},
"type": "script"
},
{
"format": [
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C",
" {memory_used} / {memory_total} GB ({memory_percent}%)",
"| {swap_used} / {swap_total} GB ({swap_percent}%)",
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)",
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps",
"猪 {load_average:1} | {load_average:5} | {load_average:15}",
" {uptime}"
],
"interval": {
"cpu": 1,
"disks": 300,
"memory": 30,
"networks": 3,
"temps": 5
},
"type": "sys_info"
},
{
"max_items": 3,
"truncate": {
"length": 50,
"mode": "end"
},
"type": "clipboard"
},
{
"bar": [
{
"label": "",
"name": "power-btn",
"on_click": "popup:toggle",
"type": "button"
}
],
"class": "power-menu",
"popup": [
{
"orientation": "vertical",
"type": "box",
"widgets": [
{
"label": "Power menu",
"name": "header",
"type": "label"
},
{
"type": "box",
"widgets": [
{
"class": "power-btn",
"label": "<span font-size='40pt'></span>",
"on_click": "!shutdown now",
"type": "button"
},
{
"class": "power-btn",
"label": "<span font-size='40pt'></span>",
"on_click": "!reboot",
"type": "button"
}
]
},
{
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
"name": "uptime",
"type": "label"
}
]
}
],
"tooltip": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
"type": "custom"
},
{
"type": "clock"
}
}
}
],
"icon_theme": "Paper",
"position": "bottom",
"start": [
{
"all_monitors": false,
"name_map": {
"1": "ﭮ",
"2": "icon:firefox",
"3": "",
"Code": "",
"Games": "icon:steam"
},
"type": "workspaces"
},
{
"favorites": [
"firefox",
"discord",
"Steam"
],
"show_icons": true,
"show_names": false,
"type": "launcher"
}
]
}

118
examples/config.toml Normal file
View File

@@ -0,0 +1,118 @@
anchor_to_edges = true
icon_theme = 'Paper'
position = 'bottom'
[[end]]
music_dir = '/home/jake/Music'
player_type = 'mpd'
type = 'music'
[end.truncate]
max_length = 100
mode = 'end'
[[end]]
host = 'chloe:6600'
player_type = 'mpd'
truncate = 'end'
type = 'music'
[[end]]
cmd = '/home/jake/bin/phone-battery'
type = 'script'
[end.show_if]
cmd = '/home/jake/bin/phone-connected'
interval = 500
[[end]]
type = 'sys_info'
format = [
' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C',
' {memory_used} / {memory_total} GB ({memory_percent}%)',
'| {swap_used} / {swap_total} GB ({swap_percent}%)',
' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)',
'李 {net_down:enp39s0} / {net_up:enp39s0} Mbps',
'猪 {load_average:1} | {load_average:5} | {load_average:15}',
' {uptime}',
]
[end.interval]
cpu = 1
disks = 300
memory = 30
networks = 3
temps = 5
[[end]]
max_items = 3
type = 'clipboard'
[end.truncate]
length = 50
mode = 'end'
[[end]]
class = 'power-menu'
tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
type = 'custom'
[[end.bar]]
label = ''
name = 'power-btn'
on_click = 'popup:toggle'
type = 'button'
[[end.popup]]
orientation = 'vertical'
type = 'box'
[[end.popup.widgets]]
label = 'Power menu'
name = 'header'
type = 'label'
[[end.popup.widgets]]
type = 'box'
[[end.popup.widgets.widgets]]
class = 'power-btn'
label = '''<span font-size='40pt'></span>'''
on_click = '!shutdown now'
type = 'button'
[[end.popup.widgets.widgets]]
class = 'power-btn'
label = '''<span font-size='40pt'></span>'''
on_click = '!reboot'
type = 'button'
[[end.popup.widgets]]
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
name = 'uptime'
type = 'label'
[[end]]
type = 'clock'
[[start]]
all_monitors = false
type = 'workspaces'
[start.name_map]
1 = 'ﭮ'
2 = 'icon:firefox'
3 = ''
Code = ''
Games = 'icon:steam'
[[start]]
show_icons = true
show_names = false
type = 'launcher'
favorites = [
'firefox',
'discord',
'Steam',
]

85
examples/config.yaml Normal file
View File

@@ -0,0 +1,85 @@
anchor_to_edges: true
end:
- music_dir: /home/jake/Music
player_type: mpd
truncate:
max_length: 100
mode: end
type: music
- host: chloe:6600
player_type: mpd
truncate: end
type: music
- cmd: /home/jake/bin/phone-battery
show_if:
cmd: /home/jake/bin/phone-connected
interval: 500
type: script
- format:
-  {cpu_percent}% | {temp_c:k10temp_Tccd1}°C
-  {memory_used} / {memory_total} GB ({memory_percent}%)
- '| {swap_used} / {swap_total} GB ({swap_percent}%)'
-  {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)
- 李 {net_down:enp39s0} / {net_up:enp39s0} Mbps
- 猪 {load_average:1} | {load_average:5} | {load_average:15}
-  {uptime}
interval:
cpu: 1
disks: 300
memory: 30
networks: 3
temps: 5
type: sys_info
- max_items: 3
truncate:
length: 50
mode: end
type: clipboard
- bar:
- label:
name: power-btn
on_click: popup:toggle
type: button
class: power-menu
popup:
- orientation: vertical
type: box
widgets:
- label: Power menu
name: header
type: label
- type: box
widgets:
- class: power-btn
label: <span font-size='40pt'></span>
on_click: '!shutdown now'
type: button
- class: power-btn
label: <span font-size='40pt'></span>
on_click: '!reboot'
type: button
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
name: uptime
type: label
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
type: custom
- type: clock
icon_theme: Paper
position: bottom
start:
- all_monitors: false
name_map:
'1':
'2': icon:firefox
'3':
Code:
Games: icon:steam
type: workspaces
- favorites:
- firefox
- discord
- Steam
show_icons: true
show_names: false
type: launcher

View File

@@ -1,31 +1,19 @@
* {
/* `otf-font-awesome` is required to be installed for icons */
font-family: Noto Sans Nerd Font, sans-serif;
/* font-family: 'Jetbrains Mono', monospace;*/
font-size: 16px;
/*color: white;*/
/*background-color: #2d2d2d;*/
/*background-color: red;*/
border: none;
/*opacity: 0.4;*/
}
#bar {
border-top: 1px solid #424242;
}
.container {
.background, .container {
background-color: #2d2d2d;
}
/* test 34543*/
#right > * + * {
margin-left: 20px;
}
#workspaces .item {
color: white;
background-color: #2d2d2d;
@@ -57,7 +45,7 @@
#launcher .focused {
color: white;
background-color: black;
background-color: #1c1c1c;
border-bottom: 4px solid #6699cc;
}
@@ -66,25 +54,54 @@
background-color: #8f0a0a;
}
#popup-launcher .popup-item {
color: white;
background-color: #2d2d2d;
border-radius: 0;
}
#popup-launcher .popup-item:hover {
background-color: #1c1c1c;
}
#popup-launcher .popup-item:not(:first-child) {
border-top: 1px solid white;
}
#clock {
color: white;
background-color: #2d2d2d;
font-weight: bold;
margin-left: 5px;
}
#clock:hover {
background-color: #1c1c1c;
}
#script {
padding-left: 10px;
color: white;
}
#sysinfo {
margin-left: 10px;
color: white;
}
#sysinfo #item {
margin-left: 5px;
}
#tray {
margin-left: 10px;
}
#tray .item {
background-color: #2d2d2d;
}
#mpd {
#music {
background-color: #2d2d2d;
color: white;
}
@@ -119,30 +136,105 @@
background-color: #6699cc;
}
#popup-mpd {
#music:hover {
background-color: #1c1c1c;
}
#popup-music {
color: white;
padding: 1em;
}
#popup-mpd #album-art {
/*border: 1px solid #424242;*/
#popup-music #album-art {
margin-right: 1em;
}
#popup-mpd #title .icon, #popup-mpd #title .label {
#popup-music #title .icon *, #popup-music #title .label {
font-size: 1.7em;
}
#popup-mpd #controls * {
#popup-music #controls * {
border-radius: 0;
background-color: #2d2d2d;
color: white;
}
#popup-mpd #controls *:disabled {
#popup-music #controls *:disabled {
color: #424242;
}
#popup-music #volume > box:last-child label {
margin-left: 6px;
}
#focused {
color: white;
}
.power-menu {
margin-left: 10px;
}
.power-menu #power-btn {
color: white;
background-color: #2d2d2d;
}
.power-menu #power-btn:hover {
background-color: #1c1c1c;
}
.popup-power-menu {
padding: 1em;
}
.popup-power-menu #header {
color: white;
font-size: 1.4em;
border-bottom: 1px solid white;
padding-bottom: 0.4em;
margin-bottom: 0.8em;
}
.popup-power-menu .power-btn {
color: white;
background-color: #2d2d2d;
border: 1px solid white;
padding: 0.6em 1em;
}
.popup-power-menu .power-btn + .power-btn {
margin-left: 1em;
}
.popup-power-menu .power-btn:hover {
background-color: #1c1c1c;
}
#clipboard * {
font-size: 1.8em;
}
#popup-clipboard {
padding: 1em;
color: white;
}
#popup-clipboard .item {
border-bottom: 1px solid #424242;
}
#popup-clipboard .btn > *:nth-child(2) {
padding: 10px;
}
#popup-clipboard .btn-remove {
background-color: #2d2d2d;
color: white;
font-size: 1.2em;
border-left: 1px solid #424242;
}
#popup-clipboard .btn-remove:hover {
color: #fcc;
}

64
flake.lock generated Normal file
View File

@@ -0,0 +1,64 @@
{
"nodes": {
"flake-utils": {
"locked": {
"lastModified": 1659877975,
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1680213900,
"narHash": "sha256-cIDr5WZIj3EkKyCgj/6j3HBH4Jj1W296z7HTcWj1aMA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "e3652e0735fbec227f342712f180f4f21f0594f2",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs",
"rust-overlay": "rust-overlay"
}
},
"rust-overlay": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": [
"nixpkgs"
]
},
"locked": {
"lastModified": 1680229280,
"narHash": "sha256-9UoyQCeKUmHcsIdpsAgcz41LAIDkWhI2PhVDjckrpg0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "aa480d799023141e1b9e5d6108700de63d9ad002",
"type": "github"
},
"original": {
"owner": "oxalica",
"repo": "rust-overlay",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

150
flake.nix Normal file
View File

@@ -0,0 +1,150 @@
{
description = "Nix Flake for ironbar";
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
rust-overlay = {
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs = {
self,
nixpkgs,
rust-overlay,
...
}: let
inherit (nixpkgs) lib;
genSystems = lib.genAttrs [
"aarch64-linux"
"x86_64-linux"
];
pkgsFor = system:
import nixpkgs {
inherit system;
overlays = [
self.overlays.default
rust-overlay.overlays.default
];
};
mkRustToolchain = pkgs: pkgs.rust-bin.stable.latest.default;
in {
overlays.default = final: prev: let
rust = mkRustToolchain final;
rustPlatform = prev.makeRustPlatform {
cargo = rust;
rustc = rust;
};
props = builtins.fromTOML (builtins.readFile ./Cargo.toml);
mkDate = longDate: (lib.concatStringsSep "-" [
(builtins.substring 0 4 longDate)
(builtins.substring 4 2 longDate)
(builtins.substring 6 2 longDate)
]);
in {
ironbar = prev.callPackage ./nix/default.nix {
version = props.package.version + "+date=" + (mkDate (self.lastModifiedDate or "19700101")) + "_" + (self.shortRev or "dirty");
inherit rustPlatform;
};
};
packages = genSystems (
system: let
pkgs = pkgsFor system;
in
(self.overlays.default pkgs pkgs)
// {
default = self.packages.${system}.ironbar;
}
);
devShells = genSystems (system: let
pkgs = pkgsFor system;
rust = mkRustToolchain pkgs;
in {
default = pkgs.mkShell {
packages = with pkgs; [
rust
rust-analyzer-unwrapped
gcc
gtk3
gtk-layer-shell
pkg-config
openssl
];
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
};
});
homeManagerModules.default = {
config,
lib,
pkgs,
...
}: let
cfg = config.programs.ironbar;
defaultIronbarPackage = self.packages.${pkgs.hostPlatform.system}.default;
jsonFormat = pkgs.formats.json {};
in {
options.programs.ironbar = {
enable = lib.mkEnableOption "ironbar status bar";
package = lib.mkOption {
type = with lib.types; package;
default = defaultIronbarPackage;
description = "The package for ironbar to use.";
};
systemd = lib.mkOption {
type = lib.types.bool;
default = pkgs.stdenv.isLinux;
description = "Whether to enable to systemd service for ironbar.";
};
style = lib.mkOption {
type = lib.types.lines;
default = "";
description = "The stylesheet to apply to ironbar.";
};
config = lib.mkOption {
type = jsonFormat.type;
default = {};
description = "The config to pass to ironbar.";
};
features = lib.mkOption {
type = lib.types.listOf lib.types.nonEmptyStr;
default = [];
description = "The features to be used.";
};
};
config = let
pkg = cfg.package.override {features = cfg.features;};
in
lib.mkIf cfg.enable {
home.packages = [pkg];
xdg.configFile = {
"ironbar/config.json" = lib.mkIf (cfg.config != "") {
source = jsonFormat.generate "ironbar-config" cfg.config;
};
"ironbar/style.css" = lib.mkIf (cfg.style != "") {
text = cfg.style;
};
};
systemd.user.services.ironbar = lib.mkIf cfg.systemd {
Unit = {
Description = "Systemd service for Ironbar";
Requires = ["graphical-session.target"];
};
Service = {
Type = "simple";
ExecStart = "${pkg}/bin/ironbar";
};
Install.WantedBy = [
(lib.mkIf config.wayland.windowManager.hyprland.systemdIntegration "hyprland-session.target")
(lib.mkIf config.wayland.windowManager.sway.systemdIntegration "sway-session.target")
];
};
};
};
};
nixConfig = {
extra-substituters = ["https://jakestanger.cachix.org"];
extra-trusted-public-keys = ["jakestanger.cachix.org-1:VWJE7AWNe5/KOEvCQRxoE8UsI2Xs2nHULJ7TEjYm7mM="];
};
}

36
nix/default.nix Normal file
View File

@@ -0,0 +1,36 @@
{
gtk3,
gdk-pixbuf,
gtk-layer-shell,
libxkbcommon,
openssl,
pkg-config,
rustPlatform,
lib,
version ? "git",
features ? [],
}:
rustPlatform.buildRustPackage {
inherit version;
pname = "ironbar";
src = builtins.path {
name = "ironbar";
path = lib.cleanSource ../.;
};
buildNoDefaultFeatures =
if features == []
then false
else true;
buildFeatures = features;
cargoDeps = rustPlatform.importCargoLock {lockFile = ../Cargo.lock;};
cargoLock.lockFile = ../Cargo.lock;
nativeBuildInputs = [pkg-config];
buildInputs = [gtk3 gdk-pixbuf gtk-layer-shell libxkbcommon openssl];
meta = with lib; {
homepage = "https://github.com/JakeStanger/ironbar";
description = "Customisable gtk-layer-shell wlroots/sway bar written in rust.";
license = licenses.mit;
platforms = platforms.linux;
mainProgram = "Hyprland";
};
}

View File

@@ -1,47 +1,144 @@
use crate::config::{BarPosition, ModuleConfig};
use crate::modules::{Module, ModuleInfo, ModuleLocation};
use crate::Config;
use gtk::gdk::Monitor;
use crate::bridge_channel::BridgeChannel;
use crate::config::{BarPosition, CommonConfig, MarginConfig, ModuleConfig};
use crate::dynamic_string::DynamicString;
use crate::modules::{Module, ModuleInfo, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::popup::Popup;
use crate::script::{OutputStream, Script};
use crate::{await_sync, read_lock, send, write_lock, Config};
use color_eyre::Result;
use gtk::gdk::{EventMask, Monitor, ScrollDirection};
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Orientation};
use gtk::{Application, ApplicationWindow, EventBox, IconTheme, Orientation, Widget};
use std::sync::{Arc, RwLock};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace};
pub fn create_bar(app: &Application, monitor: &Monitor, monitor_name: &str, config: Config) {
/// Creates a new window for a bar,
/// sets it up and adds its widgets.
pub fn create_bar(
app: &Application,
monitor: &Monitor,
monitor_name: &str,
config: Config,
) -> Result<()> {
let win = ApplicationWindow::builder().application(app).build();
setup_layer_shell(&win, monitor, &config.position);
setup_layer_shell(
&win,
monitor,
config.position,
config.anchor_to_edges,
config.margin,
);
let orientation = config.position.get_orientation();
let content = gtk::Box::builder()
.orientation(Orientation::Horizontal)
.orientation(orientation)
.spacing(0)
.hexpand(false)
.height_request(config.height)
.name("bar")
.build();
.name("bar");
let left = gtk::Box::builder().spacing(0).name("left").build();
let center = gtk::Box::builder().spacing(0).name("center").build();
let right = gtk::Box::builder().spacing(0).name("right").build();
let content = if orientation == Orientation::Horizontal {
content.height_request(config.height)
} else {
content.width_request(config.height)
}
.build();
content.style_context().add_class("container");
left.style_context().add_class("container");
center.style_context().add_class("container");
right.style_context().add_class("container");
content.add(&left);
let start = create_container("start", orientation);
let center = create_container("center", orientation);
let end = create_container("end", orientation);
content.add(&start);
content.set_center_widget(Some(&center));
content.pack_end(&right, false, false, 0);
content.pack_end(&end, false, false, 0);
load_modules(&left, &center, &right, app, config, monitor, monitor_name);
load_modules(&start, &center, &end, app, config, monitor, monitor_name)?;
win.add(&content);
win.connect_destroy_event(|_, _| {
info!("Shutting down");
gtk::main_quit();
Inhibit(false)
});
win.show_all();
debug!("Showing bar");
// show each box but do not use `show_all`.
// this ensures `show_if` option works as intended.
start.show();
center.show();
end.show();
content.show();
win.show();
Ok(())
}
/// Sets up GTK layer shell for a provided application window.
fn setup_layer_shell(
win: &ApplicationWindow,
monitor: &Monitor,
position: BarPosition,
anchor_to_edges: bool,
margin: MarginConfig,
) {
gtk_layer_shell::init_for_window(win);
gtk_layer_shell::set_monitor(win, monitor);
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
gtk_layer_shell::auto_exclusive_zone_enable(win);
gtk_layer_shell::set_namespace(win, env!("CARGO_PKG_NAME"));
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Top, margin.top);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Bottom, margin.bottom);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, margin.left);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, margin.right);
let bar_orientation = position.get_orientation();
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Top,
position == BarPosition::Top
|| (bar_orientation == Orientation::Vertical && anchor_to_edges),
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Bottom,
position == BarPosition::Bottom
|| (bar_orientation == Orientation::Vertical && anchor_to_edges),
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Left,
position == BarPosition::Left
|| (bar_orientation == Orientation::Horizontal && anchor_to_edges),
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Right,
position == BarPosition::Right
|| (bar_orientation == Orientation::Horizontal && anchor_to_edges),
);
}
/// Creates a `gtk::Box` container to place widgets inside.
fn create_container(name: &str, orientation: Orientation) -> gtk::Box {
let container = gtk::Box::builder()
.orientation(orientation)
.spacing(0)
.name(name)
.build();
container.style_context().add_class("container");
container
}
/// Loads the configured modules onto a bar.
fn load_modules(
left: &gtk::Box,
center: &gtk::Box,
@@ -50,88 +147,294 @@ fn load_modules(
config: Config,
monitor: &Monitor,
output_name: &str,
) {
if let Some(modules) = config.left {
let info = ModuleInfo {
app,
location: ModuleLocation::Left,
bar_position: &config.position,
monitor,
output_name,
};
) -> Result<()> {
let icon_theme = IconTheme::new();
if let Some(ref theme) = config.icon_theme {
icon_theme.set_custom_theme(Some(theme));
}
add_modules(left, modules, &info);
macro_rules! info {
($location:expr) => {
ModuleInfo {
app,
bar_position: config.position,
monitor,
output_name,
location: $location,
icon_theme: &icon_theme,
}
};
}
if let Some(modules) = config.start {
let info = info!(ModuleLocation::Left);
add_modules(left, modules, &info)?;
}
if let Some(modules) = config.center {
let info = ModuleInfo {
app,
location: ModuleLocation::Center,
bar_position: &config.position,
monitor,
output_name,
};
add_modules(center, modules, &info);
let info = info!(ModuleLocation::Center);
add_modules(center, modules, &info)?;
}
if let Some(modules) = config.right {
let info = ModuleInfo {
app,
location: ModuleLocation::Right,
bar_position: &config.position,
monitor,
output_name,
};
add_modules(right, modules, &info);
if let Some(modules) = config.end {
let info = info!(ModuleLocation::Right);
add_modules(right, modules, &info)?;
}
Ok(())
}
fn add_modules(content: &gtk::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) {
/// Adds modules into a provided GTK box,
/// which should be one of its left, center or right containers.
fn add_modules(content: &gtk::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) -> Result<()> {
let popup = Popup::new(info);
let popup = Arc::new(RwLock::new(popup));
macro_rules! add_module {
($module:expr, $name:literal) => {{
let widget = $module.into_widget(&info);
widget.set_widget_name($name);
content.add(&widget);
($module:expr, $id:expr) => {{
let common = $module.common.take().expect("Common config did not exist");
let widget = create_module(*$module, $id, &info, &Arc::clone(&popup))?;
let container = wrap_widget(&widget);
content.add(&container);
setup_module_common_options(container, common);
}};
}
for config in modules {
for (id, config) in modules.into_iter().enumerate() {
match config {
ModuleConfig::Clock(module) => add_module!(module, "clock"),
ModuleConfig::Mpd(module) => add_module!(module, "mpd"),
ModuleConfig::Tray(module) => add_module!(module, "tray"),
ModuleConfig::Workspaces(module) => add_module!(module, "workspaces"),
ModuleConfig::SysInfo(module) => add_module!(module, "sysinfo"),
ModuleConfig::Launcher(module) => add_module!(module, "launcher"),
ModuleConfig::Script(module) => add_module!(module, "script"),
ModuleConfig::Focused(module) => add_module!(module, "focused"),
#[cfg(feature = "clipboard")]
ModuleConfig::Clipboard(mut module) => add_module!(module, id),
#[cfg(feature = "clock")]
ModuleConfig::Clock(mut module) => add_module!(module, id),
ModuleConfig::Custom(mut module) => add_module!(module, id),
ModuleConfig::Focused(mut module) => add_module!(module, id),
ModuleConfig::Launcher(mut module) => add_module!(module, id),
#[cfg(feature = "music")]
ModuleConfig::Music(mut module) => add_module!(module, id),
ModuleConfig::Script(mut module) => add_module!(module, id),
#[cfg(feature = "sys_info")]
ModuleConfig::SysInfo(mut module) => add_module!(module, id),
#[cfg(feature = "tray")]
ModuleConfig::Tray(mut module) => add_module!(module, id),
#[cfg(feature = "workspaces")]
ModuleConfig::Workspaces(mut module) => add_module!(module, id),
}
}
Ok(())
}
fn setup_layer_shell(win: &ApplicationWindow, monitor: &Monitor, position: &BarPosition) {
gtk_layer_shell::init_for_window(win);
gtk_layer_shell::set_monitor(win, monitor);
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
gtk_layer_shell::auto_exclusive_zone_enable(win);
/// Creates a module and sets it up.
/// This setup includes widget/popup content and event channels.
fn create_module<TModule, TWidget, TSend, TRec>(
module: TModule,
id: usize,
info: &ModuleInfo,
popup: &Arc<RwLock<Popup>>,
) -> Result<TWidget>
where
TModule: Module<TWidget, SendMessage = TSend, ReceiveMessage = TRec>,
TWidget: IsA<Widget>,
TSend: Clone + Send + 'static,
{
let (w_tx, w_rx) = glib::MainContext::channel::<TSend>(glib::PRIORITY_DEFAULT);
let (p_tx, p_rx) = glib::MainContext::channel::<TSend>(glib::PRIORITY_DEFAULT);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Top, 0);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Bottom, 0);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, 0);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, 0);
let channel = BridgeChannel::<ModuleUpdateEvent<TSend>>::new();
let (ui_tx, ui_rx) = mpsc::channel::<TRec>(16);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Top,
position == &BarPosition::Top,
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Bottom,
position == &BarPosition::Bottom,
);
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Left, true);
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Right, true);
module.spawn_controller(info, channel.create_sender(), ui_rx)?;
let context = WidgetContext {
id,
widget_rx: w_rx,
popup_rx: p_rx,
tx: channel.create_sender(),
controller_tx: ui_tx,
};
let name = TModule::name();
let module_parts = module.into_widget(context, info)?;
module_parts.widget.set_widget_name(name);
let mut has_popup = false;
if let Some(popup_content) = module_parts.popup {
register_popup_content(popup, id, popup_content);
has_popup = true;
}
setup_receiver(channel, w_tx, p_tx, popup.clone(), name, id, has_popup);
Ok(module_parts.widget)
}
/// Registers the popup content with the popup.
fn register_popup_content(popup: &Arc<RwLock<Popup>>, id: usize, popup_content: gtk::Box) {
write_lock!(popup).register_content(id, popup_content);
}
/// Sets up the bridge channel receiver
/// to pick up events from the controller, widget or popup.
///
/// Handles opening/closing popups
/// and communicating update messages between controllers and widgets/popups.
fn setup_receiver<TSend>(
channel: BridgeChannel<ModuleUpdateEvent<TSend>>,
w_tx: glib::Sender<TSend>,
p_tx: glib::Sender<TSend>,
popup: Arc<RwLock<Popup>>,
name: &'static str,
id: usize,
has_popup: bool,
) where
TSend: Clone + Send + 'static,
{
// some rare cases can cause the popup to incorrectly calculate its size on first open.
// we can fix that by just force re-rendering it on its first open.
let mut has_popup_opened = false;
channel.recv(move |ev| {
match ev {
ModuleUpdateEvent::Update(update) => {
if has_popup {
send!(p_tx, update.clone());
}
send!(w_tx, update);
}
ModuleUpdateEvent::TogglePopup(geometry) => {
debug!("Toggling popup for {} [#{}]", name, id);
let popup = read_lock!(popup);
if popup.is_visible() {
popup.hide();
} else {
popup.show_content(id);
popup.show(geometry);
if !has_popup_opened {
popup.show_content(id);
popup.show(geometry);
has_popup_opened = true;
}
}
}
ModuleUpdateEvent::OpenPopup(geometry) => {
debug!("Opening popup for {} [#{}]", name, id);
let popup = read_lock!(popup);
popup.hide();
popup.show_content(id);
popup.show(geometry);
if !has_popup_opened {
popup.show_content(id);
popup.show(geometry);
has_popup_opened = true;
}
}
ModuleUpdateEvent::ClosePopup => {
debug!("Closing popup for {} [#{}]", name, id);
let popup = read_lock!(popup);
popup.hide();
}
}
Continue(true)
});
}
/// Takes a widget and adds it into a new `gtk::EventBox`.
/// The event box container is returned.
fn wrap_widget<W: IsA<Widget>>(widget: &W) -> EventBox {
let container = EventBox::new();
container.add_events(EventMask::SCROLL_MASK);
container.add(widget);
container
}
/// Configures the module's container according to the common config options.
fn setup_module_common_options(container: EventBox, common: CommonConfig) {
common.show_if.map_or_else(
|| {
container.show_all();
},
|show_if| {
let script = Script::new_polling(show_if);
let container = container.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(|(_, success)| {
send!(tx, success);
})
.await;
});
rx.attach(None, move |success| {
if success {
container.show_all();
} else {
container.hide();
};
Continue(true)
});
},
);
let left_click_script = common.on_click_left.map(Script::new_polling);
let middle_click_script = common.on_click_middle.map(Script::new_polling);
let right_click_script = common.on_click_right.map(Script::new_polling);
container.connect_button_press_event(move |_, event| {
let script = match event.button() {
1 => left_click_script.as_ref(),
2 => middle_click_script.as_ref(),
3 => right_click_script.as_ref(),
_ => None,
};
if let Some(script) = script {
trace!("Running on-click script: {}", event.button());
match await_sync(async { script.get_output().await }) {
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
Err(err) => error!("{err:?}"),
_ => {}
}
}
Inhibit(false)
});
let scroll_up_script = common.on_scroll_up.map(Script::new_polling);
let scroll_down_script = common.on_scroll_down.map(Script::new_polling);
container.connect_scroll_event(move |_, event| {
let script = match event.direction() {
ScrollDirection::Up => scroll_up_script.as_ref(),
ScrollDirection::Down => scroll_down_script.as_ref(),
_ => None,
};
if let Some(script) = script {
trace!("Running on-scroll script: {}", event.direction());
match await_sync(async { script.get_output().await }) {
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
Err(err) => error!("{err:?}"),
_ => {}
}
}
Inhibit(false)
});
if let Some(tooltip) = common.tooltip {
DynamicString::new(&tooltip, move |string| {
container.set_tooltip_text(Some(&string));
Continue(true)
});
}
}

44
src/bridge_channel.rs Normal file
View File

@@ -0,0 +1,44 @@
use crate::send;
use tokio::spawn;
use tokio::sync::mpsc;
/// MPSC async -> sync channel.
/// The sender uses `tokio::sync::mpsc`
/// while the receiver uses `glib::MainContext::channel`.
///
/// This makes it possible to send events asynchronously
/// and receive them on the main thread,
/// allowing UI updates to be handled on the receiving end.
pub struct BridgeChannel<T> {
async_tx: mpsc::Sender<T>,
sync_rx: glib::Receiver<T>,
}
impl<T: Send + 'static> BridgeChannel<T> {
/// Creates a new channel
pub fn new() -> Self {
let (async_tx, mut async_rx) = mpsc::channel(32);
let (sync_tx, sync_rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
while let Some(val) = async_rx.recv().await {
send!(sync_tx, val);
}
});
Self { async_tx, sync_rx }
}
/// Gets a clone of the sender.
pub fn create_sender(&self) -> mpsc::Sender<T> {
self.async_tx.clone()
}
/// Attaches a callback to the receiver.
pub fn recv<F>(self, f: F) -> glib::SourceId
where
F: FnMut(T) -> glib::Continue + 'static,
{
self.sync_rx.attach(None, f)
}
}

244
src/clients/clipboard.rs Normal file
View File

@@ -0,0 +1,244 @@
use super::wayland::{self, ClipboardItem};
use crate::{lock, try_send};
use indexmap::map::Iter;
use indexmap::IndexMap;
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::debug;
#[derive(Debug)]
pub enum ClipboardEvent {
Add(Arc<ClipboardItem>),
Remove(usize),
Activate(usize),
}
type EventSender = mpsc::Sender<ClipboardEvent>;
/// Clipboard client singleton,
/// to ensure bars don't duplicate requests to the compositor.
pub struct ClipboardClient {
senders: Arc<Mutex<Vec<(EventSender, usize)>>>,
cache: Arc<Mutex<ClipboardCache>>,
}
impl ClipboardClient {
fn new() -> Self {
let senders = Arc::new(Mutex::new(Vec::<(EventSender, usize)>::new()));
let cache = Arc::new(Mutex::new(ClipboardCache::new()));
{
let senders = senders.clone();
let cache = cache.clone();
spawn(async move {
let mut rx = {
let wl = wayland::get_client().await;
wl.subscribe_clipboard()
};
while let Ok(item) = rx.recv().await {
debug!("Received clipboard item (ID: {})", item.id);
let (existing_id, cache_size) = {
let cache = lock!(cache);
(cache.contains(&item), cache.len())
};
existing_id.map_or_else(
|| {
{
let mut cache = lock!(cache);
let senders = lock!(senders);
cache.insert(item.clone(), senders.len());
}
let senders = lock!(senders);
let iter = senders.iter();
for (tx, sender_cache_size) in iter {
if cache_size == *sender_cache_size {
let mut cache = lock!(cache);
let removed_id = cache
.remove_ref_first()
.expect("Clipboard cache unexpectedly empty");
try_send!(tx, ClipboardEvent::Remove(removed_id));
}
try_send!(tx, ClipboardEvent::Add(item.clone()));
}
},
|existing_id| {
let senders = lock!(senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Activate(existing_id));
}
},
);
}
});
}
Self { senders, cache }
}
pub async fn subscribe(&self, cache_size: usize) -> mpsc::Receiver<ClipboardEvent> {
let (tx, rx) = mpsc::channel(16);
let wl = wayland::get_client().await;
wl.roundtrip();
{
let mut cache = lock!(self.cache);
if let Some(item) = wl.get_clipboard() {
cache.insert_or_inc_ref(item);
}
let iter = cache.iter();
for (_, (item, _)) in iter {
try_send!(tx, ClipboardEvent::Add(item.clone()));
}
}
{
let mut senders = lock!(self.senders);
senders.push((tx, cache_size));
}
rx
}
pub async fn copy(&self, id: usize) {
debug!("Copying item with id {id}");
let item = {
let cache = lock!(self.cache);
cache.get(id)
};
if let Some(item) = item {
let wl = wayland::get_client().await;
wl.copy_to_clipboard(item);
}
let senders = lock!(self.senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Activate(id));
}
}
pub fn remove(&self, id: usize) {
let mut cache = lock!(self.cache);
cache.remove(id);
let senders = lock!(self.senders);
let iter = senders.iter();
for (tx, _) in iter {
try_send!(tx, ClipboardEvent::Remove(id));
}
}
}
/// Shared clipboard item cache.
///
/// Items are stored with a number of references,
/// allowing different consumers to 'remove' cached items
/// at different times.
#[derive(Debug)]
struct ClipboardCache {
cache: IndexMap<usize, (Arc<ClipboardItem>, usize)>,
}
impl ClipboardCache {
/// Creates a new empty cache.
fn new() -> Self {
Self {
cache: IndexMap::new(),
}
}
/// Gets the entry with key `id` from the cache.
fn get(&self, id: usize) -> Option<Arc<ClipboardItem>> {
self.cache.get(&id).map(|(item, _)| item).cloned()
}
/// Inserts an entry with `ref_count` initial references.
fn insert(&mut self, item: Arc<ClipboardItem>, ref_count: usize) -> Option<Arc<ClipboardItem>> {
self.cache
.insert(item.id, (item, ref_count))
.map(|(item, _)| item)
}
/// Inserts an entry with `ref_count` initial references,
/// or increments the `ref_count` by 1 if it already exists.
fn insert_or_inc_ref(&mut self, item: Arc<ClipboardItem>) {
let mut item = self.cache.entry(item.id).or_insert((item, 0));
item.1 += 1;
}
/// Removes the entry with key `id`.
/// This ignores references.
fn remove(&mut self, id: usize) -> Option<Arc<ClipboardItem>> {
self.cache.shift_remove(&id).map(|(item, _)| item)
}
/// Removes a reference to the entry with key `id`.
///
/// If the reference count reaches zero, the entry
/// is removed from the cache.
fn remove_ref(&mut self, id: usize) {
if let Some(entry) = self.cache.get_mut(&id) {
entry.1 -= 1;
if entry.1 == 0 {
self.cache.shift_remove(&id);
}
}
}
/// Removes a reference to the first entry.
///
/// If the reference count reaches zero, the entry
/// is removed from the cache.
fn remove_ref_first(&mut self) -> Option<usize> {
if let Some((id, _)) = self.cache.first() {
let id = *id;
self.remove_ref(id);
Some(id)
} else {
None
}
}
/// Checks if an item with matching mime type and value
/// already exists in the cache.
fn contains(&self, item: &ClipboardItem) -> Option<usize> {
self.cache.values().find_map(|(it, _)| {
if it.mime_type == item.mime_type && it.value == item.value {
Some(it.id)
} else {
None
}
})
}
/// Gets the current number of items in the cache.
fn len(&self) -> usize {
self.cache.len()
}
fn iter(&self) -> Iter<'_, usize, (Arc<ClipboardItem>, usize)> {
self.cache.iter()
}
}
lazy_static! {
static ref CLIENT: ClipboardClient = ClipboardClient::new();
}
pub fn get_client() -> &'static ClipboardClient {
&CLIENT
}

View File

@@ -0,0 +1,281 @@
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::{lock, send};
use color_eyre::Result;
use hyprland::data::{Workspace as HWorkspace, Workspaces};
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
use hyprland::event_listener::EventListenerMutable as EventListener;
use hyprland::prelude::*;
use hyprland::shared::WorkspaceType;
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::task::spawn_blocking;
use tracing::{debug, error, info};
pub struct EventClient {
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
}
impl EventClient {
fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16);
Self {
workspace_tx,
_workspace_rx: workspace_rx,
}
}
fn listen_workspace_events(&self) {
info!("Starting Hyprland event listener");
let tx = self.workspace_tx.clone();
spawn_blocking(move || {
let mut event_listener = EventListener::new();
// we need a lock to ensure events don't run at the same time
let lock = Arc::new(Mutex::new(()));
// cache the active workspace since Hyprland doesn't give us the prev active
let active = Self::get_active_workspace().expect("Failed to get active workspace");
let active = Arc::new(Mutex::new(Some(active)));
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_added_handler(move |workspace_type, _state| {
let _lock = lock!(lock);
debug!("Added workspace: {workspace_type:?}");
let workspace_name = get_workspace_name(workspace_type);
let prev_workspace = lock!(active);
let focused = prev_workspace
.as_ref()
.map_or(false, |w| w.name == workspace_name);
let workspace = Self::get_workspace(&workspace_name, focused);
if let Some(workspace) = workspace {
send!(tx, WorkspaceUpdate::Add(workspace));
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_change_handler(move |workspace_type, _state| {
let _lock = lock!(lock);
let mut prev_workspace = lock!(active);
debug!(
"Received workspace change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.id)
);
let workspace_name = get_workspace_name(workspace_type);
let focused = prev_workspace
.as_ref()
.map_or(false, |w| w.name == workspace_name);
let workspace = Self::get_workspace(&workspace_name, focused);
workspace.map_or_else(
|| {
error!("Unable to locate workspace");
},
|workspace| {
// there may be another type of update so dispatch that regardless of focus change
send!(tx, WorkspaceUpdate::Update(workspace.clone()));
if !focused {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
},
);
});
}
{
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_active_monitor_change_handler(move |event_data, _state| {
let _lock = lock!(lock);
let workspace_type = event_data.1;
let mut prev_workspace = lock!(active);
debug!(
"Received active monitor change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.name)
);
let workspace_name = get_workspace_name(workspace_type);
let focused = prev_workspace
.as_ref()
.map_or(false, |w| w.name == workspace_name);
let workspace = Self::get_workspace(&workspace_name, focused);
if let (Some(workspace), false) = (workspace, focused) {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
} else {
error!("Unable to locate workspace");
}
});
}
{
let tx = tx.clone();
let lock = lock.clone();
event_listener.add_workspace_moved_handler(move |event_data, _state| {
let _lock = lock!(lock);
let workspace_type = event_data.1;
debug!("Received workspace move: {workspace_type:?}");
let mut prev_workspace = lock!(active);
let workspace_name = get_workspace_name(workspace_type);
let focused = prev_workspace
.as_ref()
.map_or(false, |w| w.name == workspace_name);
let workspace = Self::get_workspace(&workspace_name, focused);
if let Some(workspace) = workspace {
send!(tx, WorkspaceUpdate::Move(workspace.clone()));
if !focused {
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
}
}
});
}
{
event_listener.add_workspace_destroy_handler(move |workspace_type, _state| {
let _lock = lock!(lock);
debug!("Received workspace destroy: {workspace_type:?}");
let name = get_workspace_name(workspace_type);
send!(tx, WorkspaceUpdate::Remove(name));
});
}
event_listener
.start_listener()
.expect("Failed to start listener");
});
}
/// Sends a `WorkspaceUpdate::Focus` event
/// and updates the active workspace cache.
fn send_focus_change(
prev_workspace: &mut Option<Workspace>,
workspace: Workspace,
tx: &Sender<WorkspaceUpdate>,
) {
let old = prev_workspace
.as_ref()
.map(|w| w.name.clone())
.unwrap_or_default();
send!(
tx,
WorkspaceUpdate::Focus {
old,
new: workspace.name.clone(),
}
);
prev_workspace.replace(workspace);
}
/// Gets a workspace by name from the server.
///
/// Use `focused` to manually mark the workspace as focused,
/// as this is not automatically checked.
fn get_workspace(name: &str, focused: bool) -> Option<Workspace> {
Workspaces::get()
.expect("Failed to get workspaces")
.find_map(|w| {
if w.name == name {
Some(Workspace::from((focused, w)))
} else {
None
}
})
}
/// Gets the active workspace from the server.
fn get_active_workspace() -> Result<Workspace> {
let w = HWorkspace::get_active().map(|w| Workspace::from((true, w)))?;
Ok(w)
}
}
impl WorkspaceClient for EventClient {
fn focus(&self, id: String) -> Result<()> {
Dispatch::call(DispatchType::Workspace(
WorkspaceIdentifierWithSpecial::Name(&id),
))?;
Ok(())
}
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe();
{
let tx = self.workspace_tx.clone();
let active_name = HWorkspace::get_active()
.map(|active| active.name)
.unwrap_or_default();
let workspaces = Workspaces::get()
.expect("Failed to get workspaces")
.map(|w| Workspace::from((w.name == active_name, w)))
.collect();
send!(tx, WorkspaceUpdate::Init(workspaces));
}
rx
}
}
lazy_static! {
static ref CLIENT: EventClient = {
let client = EventClient::new();
client.listen_workspace_events();
client
};
}
pub fn get_client() -> &'static EventClient {
&CLIENT
}
fn get_workspace_name(name: WorkspaceType) -> String {
match name {
WorkspaceType::Regular(name) => name,
WorkspaceType::Special(name) => name.unwrap_or_default(),
}
}
impl From<(bool, hyprland::data::Workspace)> for Workspace {
fn from((focused, workspace): (bool, hyprland::data::Workspace)) -> Self {
Self {
id: workspace.id.to_string(),
name: workspace.name,
monitor: workspace.monitor,
focused,
}
}
}

View File

@@ -0,0 +1,104 @@
use cfg_if::cfg_if;
use color_eyre::{Help, Report, Result};
use std::fmt::{Display, Formatter};
use tokio::sync::broadcast;
use tracing::debug;
#[cfg(feature = "workspaces+hyprland")]
pub mod hyprland;
#[cfg(feature = "workspaces+sway")]
pub mod sway;
pub enum Compositor {
#[cfg(feature = "workspaces+sway")]
Sway,
#[cfg(feature = "workspaces+hyprland")]
Hyprland,
Unsupported,
}
impl Display for Compositor {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
#[cfg(feature = "workspaces+sway")]
Self::Sway => "Sway",
#[cfg(feature = "workspaces+hyprland")]
Self::Hyprland => "Hyprland",
Self::Unsupported => "Unsupported",
}
)
}
}
impl Compositor {
/// Attempts to get the current compositor.
/// This is done by checking system env vars.
fn get_current() -> Self {
if std::env::var("SWAYSOCK").is_ok() {
cfg_if! {
if #[cfg(feature = "workspaces+sway")] { Self::Sway }
else { tracing::error!("Not compiled with Sway support"); Self::Unsupported }
}
} else if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() {
cfg_if! {
if #[cfg(feature = "workspaces+hyprland")] { Self::Hyprland}
else { tracing::error!("Not compiled with Hyprland support"); Self::Unsupported }
}
} else {
Self::Unsupported
}
}
/// Gets the workspace client for the current compositor
pub fn get_workspace_client() -> Result<&'static (dyn WorkspaceClient + Send)> {
let current = Self::get_current();
debug!("Getting workspace client for: {current}");
match current {
#[cfg(feature = "workspaces+sway")]
Self::Sway => Ok(sway::get_sub_client()),
#[cfg(feature = "workspaces+hyprland")]
Self::Hyprland => Ok(hyprland::get_client()),
Self::Unsupported => Err(Report::msg("Unsupported compositor")
.note("Currently workspaces are only supported by Sway and Hyprland")),
}
}
}
#[derive(Debug, Clone)]
pub struct Workspace {
/// Unique identifier
pub id: String,
/// Workspace friendly name
pub name: String,
/// Name of the monitor (output) the workspace is located on
pub monitor: String,
/// Whether the workspace is in focus
pub focused: bool,
}
#[derive(Debug, Clone)]
pub enum WorkspaceUpdate {
/// Provides an initial list of workspaces.
/// This is re-sent to all subscribers when a new subscription is created.
Init(Vec<Workspace>),
Add(Workspace),
Remove(String),
Update(Workspace),
Move(Workspace),
/// Declares focus moved from the old workspace to the new.
Focus {
old: String,
new: String,
},
}
pub trait WorkspaceClient {
/// Requests the workspace with this name is focused.
fn focus(&self, name: String) -> Result<()>;
/// Creates a new to workspace event receiver.
fn subscribe_workspace_change(&self) -> broadcast::Receiver<WorkspaceUpdate>;
}

View File

@@ -0,0 +1,159 @@
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::{await_sync, send};
use async_once::AsyncOnce;
use color_eyre::Report;
use futures_util::StreamExt;
use lazy_static::lazy_static;
use std::sync::Arc;
use swayipc_async::{Connection, Event, EventType, Node, WorkspaceChange, WorkspaceEvent};
use tokio::spawn;
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::sync::Mutex;
use tracing::{info, trace};
pub struct SwayEventClient {
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
}
impl SwayEventClient {
fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16);
{
let workspace_tx = workspace_tx.clone();
spawn(async move {
let client = Connection::new().await?;
info!("Sway IPC subscription client connected");
let event_types = [EventType::Workspace];
let mut events = client.subscribe(event_types).await?;
while let Some(event) = events.next().await {
trace!("event: {:?}", event);
if let Event::Workspace(ev) = event? {
workspace_tx.send(WorkspaceUpdate::from(*ev))?;
};
}
Ok::<(), Report>(())
});
}
Self {
workspace_tx,
_workspace_rx: workspace_rx,
}
}
}
impl WorkspaceClient for SwayEventClient {
fn focus(&self, id: String) -> color_eyre::Result<()> {
await_sync(async move {
let client = get_client().await;
let mut client = client.lock().await;
client.run_command(format!("workspace {id}")).await
})?;
Ok(())
}
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe();
{
let tx = self.workspace_tx.clone();
await_sync(async {
let client = get_client().await;
let mut client = client.lock().await;
let workspaces = client
.get_workspaces()
.await
.expect("Failed to get workspaces");
let event =
WorkspaceUpdate::Init(workspaces.into_iter().map(Workspace::from).collect());
send!(tx, event);
});
}
rx
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<Arc<Mutex<Connection>>> = AsyncOnce::new(async {
let client = Connection::new()
.await
.expect("Failed to connect to Sway socket");
Arc::new(Mutex::new(client))
});
static ref SUB_CLIENT: SwayEventClient = SwayEventClient::new();
}
/// Gets the sway IPC client
async fn get_client() -> Arc<Mutex<Connection>> {
let client = CLIENT.get().await;
Arc::clone(client)
}
/// Gets the sway IPC event subscription client
pub fn get_sub_client() -> &'static SwayEventClient {
&SUB_CLIENT
}
impl From<Node> for Workspace {
fn from(node: Node) -> Self {
Self {
id: node.id.to_string(),
name: node.name.unwrap_or_default(),
monitor: node.output.unwrap_or_default(),
focused: node.focused,
}
}
}
impl From<swayipc_async::Workspace> for Workspace {
fn from(workspace: swayipc_async::Workspace) -> Self {
Self {
id: workspace.id.to_string(),
name: workspace.name,
monitor: workspace.output,
focused: workspace.focused,
}
}
}
impl From<WorkspaceEvent> for WorkspaceUpdate {
fn from(event: WorkspaceEvent) -> Self {
match event.change {
WorkspaceChange::Init => {
Self::Add(event.current.expect("Missing current workspace").into())
}
WorkspaceChange::Empty => Self::Remove(
event
.current
.expect("Missing current workspace")
.name
.unwrap_or_default(),
),
WorkspaceChange::Focus => Self::Focus {
old: event
.old
.expect("Missing old workspace")
.name
.unwrap_or_default(),
new: event
.current
.expect("Missing current workspace")
.name
.unwrap_or_default(),
},
WorkspaceChange::Move => {
Self::Move(event.current.expect("Missing current workspace").into())
}
_ => Self::Update(event.current.expect("Missing current workspace").into()),
}
}
}

11
src/clients/mod.rs Normal file
View File

@@ -0,0 +1,11 @@
#[cfg(feature = "clipboard")]
pub mod clipboard;
#[cfg(feature = "workspaces")]
pub mod compositor;
#[cfg(feature = "music")]
pub mod music;
#[cfg(feature = "tray")]
pub mod system_tray;
pub mod wayland;
#[cfg(feature = "volume")]
pub mod volume;

72
src/clients/music/mod.rs Normal file
View File

@@ -0,0 +1,72 @@
use color_eyre::Result;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::broadcast;
#[cfg(feature = "music+mpd")]
pub mod mpd;
#[cfg(feature = "music+mpris")]
pub mod mpris;
#[derive(Clone, Debug)]
pub enum PlayerUpdate {
Update(Box<Option<Track>>, Status),
Disconnect,
}
#[derive(Clone, Debug)]
pub struct Track {
pub title: Option<String>,
pub album: Option<String>,
pub artist: Option<String>,
pub date: Option<String>,
pub disc: Option<u64>,
pub genre: Option<String>,
pub track: Option<u64>,
pub cover_path: Option<String>,
}
#[derive(Clone, Debug)]
pub enum PlayerState {
Playing,
Paused,
Stopped,
}
#[derive(Clone, Debug)]
pub struct Status {
pub state: PlayerState,
pub volume_percent: u8,
pub duration: Option<Duration>,
pub elapsed: Option<Duration>,
pub playlist_position: u32,
pub playlist_length: u32,
}
pub trait MusicClient {
fn play(&self) -> Result<()>;
fn pause(&self) -> Result<()>;
fn next(&self) -> Result<()>;
fn prev(&self) -> Result<()>;
fn set_volume_percent(&self, vol: u8) -> Result<()>;
fn subscribe_change(&self) -> broadcast::Receiver<PlayerUpdate>;
}
pub enum ClientType<'a> {
Mpd { host: &'a str, music_dir: PathBuf },
Mpris,
}
pub async fn get_client(client_type: ClientType<'_>) -> Box<Arc<dyn MusicClient>> {
match client_type {
ClientType::Mpd { host, music_dir } => Box::new(
mpd::get_client(host, music_dir)
.await
.expect("Failed to connect to MPD client"),
),
ClientType::Mpris => Box::new(mpris::get_client()),
}
}

311
src/clients/music/mpd.rs Normal file
View File

@@ -0,0 +1,311 @@
use super::{MusicClient, Status, Track};
use crate::await_sync;
use crate::clients::music::{PlayerState, PlayerUpdate};
use color_eyre::Result;
use lazy_static::lazy_static;
use mpd_client::client::{Connection, ConnectionEvent, Subsystem};
use mpd_client::protocol::MpdProtocolError;
use mpd_client::responses::{PlayState, Song};
use mpd_client::tag::Tag;
use mpd_client::{commands, Client};
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::os::unix::fs::FileTypeExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpStream, UnixStream};
use tokio::spawn;
use tokio::sync::broadcast::{channel, error::SendError, Receiver, Sender};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::{debug, error, info};
lazy_static! {
static ref CONNECTIONS: Arc<Mutex<HashMap<String, Arc<MpdClient>>>> =
Arc::new(Mutex::new(HashMap::new()));
}
pub struct MpdClient {
client: Client,
music_dir: PathBuf,
tx: Sender<PlayerUpdate>,
_rx: Receiver<PlayerUpdate>,
}
#[derive(Debug)]
pub enum MpdConnectionError {
MaxRetries,
ProtocolError(MpdProtocolError),
}
impl Display for MpdConnectionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MaxRetries => write!(f, "Reached max retries"),
Self::ProtocolError(e) => write!(f, "{e:?}"),
}
}
}
impl std::error::Error for MpdConnectionError {}
impl MpdClient {
async fn new(host: &str, music_dir: PathBuf) -> Result<Self, MpdConnectionError> {
debug!("Creating new MPD connection to {}", host);
let (client, mut state_changes) =
wait_for_connection(host, Duration::from_secs(5), None).await?;
let (tx, rx) = channel(16);
{
let music_dir = music_dir.clone();
let tx = tx.clone();
let client = client.clone();
spawn(async move {
while let Some(change) = state_changes.next().await {
debug!("Received state change: {:?}", change);
if let ConnectionEvent::SubsystemChange(
Subsystem::Player | Subsystem::Queue | Subsystem::Mixer,
) = change
{
Self::send_update(&client, &tx, &music_dir)
.await
.expect("Failed to send update");
}
}
Ok::<(), SendError<(Option<Track>, Status)>>(())
});
}
Ok(Self {
client,
music_dir,
tx,
_rx: rx,
})
}
async fn send_update(
client: &Client,
tx: &Sender<PlayerUpdate>,
music_dir: &Path,
) -> Result<(), SendError<PlayerUpdate>> {
let current_song = client.command(commands::CurrentSong).await;
let status = client.command(commands::Status).await;
if let (Ok(current_song), Ok(status)) = (current_song, status) {
let track = current_song.map(|s| Self::convert_song(&s.song, music_dir));
let status = Status::from(status);
tx.send(PlayerUpdate::Update(Box::new(track), status))?;
}
Ok(())
}
fn is_connected(&self) -> bool {
!self.client.is_connection_closed()
}
fn send_disconnect_update(&self) -> Result<(), SendError<PlayerUpdate>> {
info!("Connection to MPD server lost");
self.tx.send(PlayerUpdate::Disconnect)?;
Ok(())
}
fn convert_song(song: &Song, music_dir: &Path) -> Track {
let (track, disc) = song.number();
let cover_path = music_dir
.join(
song.file_path()
.parent()
.expect("Song path should not be root")
.join("cover.jpg"),
)
.into_os_string()
.into_string()
.ok();
Track {
title: song.title().map(std::string::ToString::to_string),
album: song.album().map(std::string::ToString::to_string),
artist: Some(song.artists().join(", ")),
date: try_get_first_tag(song, &Tag::Date).map(std::string::ToString::to_string),
genre: try_get_first_tag(song, &Tag::Genre).map(std::string::ToString::to_string),
disc: Some(disc),
track: Some(track),
cover_path,
}
}
}
macro_rules! async_command {
($client:expr, $command:expr) => {
await_sync(async {
$client
.command($command)
.await
.unwrap_or_else(|err| error!("Failed to send command: {err:?}"))
})
};
}
impl MusicClient for MpdClient {
fn play(&self) -> Result<()> {
async_command!(self.client, commands::SetPause(false));
Ok(())
}
fn pause(&self) -> Result<()> {
async_command!(self.client, commands::SetPause(true));
Ok(())
}
fn next(&self) -> Result<()> {
async_command!(self.client, commands::Next);
Ok(())
}
fn prev(&self) -> Result<()> {
async_command!(self.client, commands::Previous);
Ok(())
}
fn set_volume_percent(&self, vol: u8) -> Result<()> {
async_command!(self.client, commands::SetVolume(vol));
Ok(())
}
fn subscribe_change(&self) -> Receiver<PlayerUpdate> {
let rx = self.tx.subscribe();
await_sync(async {
Self::send_update(&self.client, &self.tx, &self.music_dir)
.await
.expect("Failed to send player update");
});
rx
}
}
pub async fn get_client(
host: &str,
music_dir: PathBuf,
) -> Result<Arc<MpdClient>, MpdConnectionError> {
let mut connections = CONNECTIONS.lock().await;
match connections.get(host) {
None => {
let client = MpdClient::new(host, music_dir).await?;
let client = Arc::new(client);
connections.insert(host.to_string(), Arc::clone(&client));
Ok(client)
}
Some(client) => {
if client.is_connected() {
Ok(Arc::clone(client))
} else {
client
.send_disconnect_update()
.expect("Failed to send disconnect update");
let client = MpdClient::new(host, music_dir).await?;
let client = Arc::new(client);
connections.insert(host.to_string(), Arc::clone(&client));
Ok(client)
}
}
}
}
async fn wait_for_connection(
host: &str,
interval: Duration,
max_retries: Option<usize>,
) -> Result<Connection, MpdConnectionError> {
let mut retries = 0;
let max_retries = max_retries.unwrap_or(usize::MAX);
loop {
if retries == max_retries {
break Err(MpdConnectionError::MaxRetries);
}
retries += 1;
match try_get_mpd_conn(host).await {
Ok(conn) => break Ok(conn),
Err(err) => {
if retries == max_retries {
break Err(MpdConnectionError::ProtocolError(err));
}
}
}
sleep(interval).await;
}
}
/// Cycles through each MPD host and
/// returns the first one which connects,
/// or none if there are none
async fn try_get_mpd_conn(host: &str) -> Result<Connection, MpdProtocolError> {
if is_unix_socket(host) {
connect_unix(host).await
} else {
connect_tcp(host).await
}
}
fn is_unix_socket(host: &str) -> bool {
let path = PathBuf::from(host);
path.exists()
&& path
.metadata()
.map_or(false, |metadata| metadata.file_type().is_socket())
}
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = UnixStream::connect(host).await?;
Client::connect(connection).await
}
async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = TcpStream::connect(host).await?;
Client::connect(connection).await
}
/// Attempts to read the first value for a tag
/// (since the MPD client returns a vector of tags, or None)
pub fn try_get_first_tag<'a>(song: &'a Song, tag: &'a Tag) -> Option<&'a str> {
song.tags
.get(tag)
.and_then(|vec| vec.first().map(String::as_str))
}
impl From<mpd_client::responses::Status> for Status {
fn from(status: mpd_client::responses::Status) -> Self {
Self {
state: PlayerState::from(status.state),
volume_percent: status.volume,
duration: status.duration,
elapsed: status.elapsed,
playlist_position: status.current_song.map_or(0, |(pos, _)| pos.0 as u32),
playlist_length: status.playlist_length as u32,
}
}
}
impl From<PlayState> for PlayerState {
fn from(value: PlayState) -> Self {
match value {
PlayState::Stopped => Self::Stopped,
PlayState::Playing => Self::Playing,
PlayState::Paused => Self::Paused,
}
}
}

275
src/clients/music/mpris.rs Normal file
View File

@@ -0,0 +1,275 @@
use super::{MusicClient, PlayerUpdate, Status, Track};
use crate::clients::music::PlayerState;
use crate::{lock, send};
use color_eyre::Result;
use lazy_static::lazy_static;
use mpris::{DBusError, Event, Metadata, PlaybackStatus, Player, PlayerFinder};
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::task::spawn_blocking;
use tracing::{debug, error, trace};
lazy_static! {
static ref CLIENT: Arc<Client> = Arc::new(Client::new());
}
pub struct Client {
current_player: Arc<Mutex<Option<String>>>,
tx: Sender<PlayerUpdate>,
_rx: Receiver<PlayerUpdate>,
}
impl Client {
fn new() -> Self {
let (tx, rx) = channel(32);
let current_player = Arc::new(Mutex::new(None));
{
let players_list = Arc::new(Mutex::new(HashSet::new()));
let current_player = current_player.clone();
let tx = tx.clone();
spawn_blocking(move || {
let player_finder = PlayerFinder::new().expect("Failed to connect to D-Bus");
// D-Bus gives no event for new players,
// so we have to keep polling the player list
loop {
let players = player_finder
.find_all()
.expect("Failed to connect to D-Bus");
let mut players_list_val = lock!(players_list);
for player in players {
let identity = player.identity();
if !players_list_val.contains(identity) {
debug!("Adding MPRIS player '{identity}'");
players_list_val.insert(identity.to_string());
let status = player
.get_playback_status()
.expect("Failed to connect to D-Bus");
{
let mut current_player = lock!(current_player);
if status == PlaybackStatus::Playing || current_player.is_none() {
debug!("Setting active player to '{identity}'");
current_player.replace(identity.to_string());
if let Err(err) = Self::send_update(&player, &tx) {
error!("{err:?}");
}
}
}
Self::listen_player_events(
identity.to_string(),
players_list.clone(),
current_player.clone(),
tx.clone(),
);
}
}
// wait 1 second before re-checking players
sleep(Duration::from_secs(1));
}
});
}
Self {
current_player,
tx,
_rx: rx,
}
}
fn listen_player_events(
player_id: String,
players: Arc<Mutex<HashSet<String>>>,
current_player: Arc<Mutex<Option<String>>>,
tx: Sender<PlayerUpdate>,
) {
spawn_blocking(move || {
let player_finder = PlayerFinder::new()?;
if let Ok(player) = player_finder.find_by_name(&player_id) {
let identity = player.identity();
for event in player.events()? {
trace!("Received player event from '{identity}': {event:?}");
match event {
Ok(Event::PlayerShutDown) => {
lock!(current_player).take();
lock!(players).remove(identity);
break;
}
Ok(Event::Playing) => {
lock!(current_player).replace(identity.to_string());
if let Err(err) = Self::send_update(&player, &tx) {
error!("{err:?}");
}
}
Ok(_) => {
let current_player = lock!(current_player);
let current_player = current_player.as_ref();
if let Some(current_player) = current_player {
if current_player == identity {
if let Err(err) = Self::send_update(&player, &tx) {
error!("{err:?}");
}
}
}
}
Err(err) => error!("{err:?}"),
}
}
}
Ok::<(), DBusError>(())
});
}
fn send_update(player: &Player, tx: &Sender<PlayerUpdate>) -> Result<()> {
debug!("Sending update using '{}'", player.identity());
let metadata = player.get_metadata()?;
let playback_status = player
.get_playback_status()
.unwrap_or(PlaybackStatus::Stopped);
let track_list = player.get_track_list();
let volume_percent = player
.get_volume()
.map(|vol| (vol * 100.0) as u8)
.unwrap_or(0);
let status = Status {
// MRPIS doesn't seem to provide playlist info reliably,
// so we can just assume next/prev will work by bodging the numbers
playlist_position: 1,
playlist_length: track_list.map(|list| list.len() as u32).unwrap_or(u32::MAX),
state: PlayerState::from(playback_status),
elapsed: player.get_position().ok(),
duration: metadata.length(),
volume_percent,
};
let track = Track::from(metadata);
let player_update = PlayerUpdate::Update(Box::new(Some(track)), status);
send!(tx, player_update);
Ok(())
}
fn get_player(&self) -> Option<Player> {
let player_name = lock!(self.current_player);
let player_name = player_name.as_ref();
player_name.and_then(|player_name| {
let player_finder = PlayerFinder::new().expect("Failed to connect to D-Bus");
player_finder.find_by_name(player_name).ok()
})
}
}
macro_rules! command {
($self:ident, $func:ident) => {
if let Some(player) = Self::get_player($self) {
player.$func()?;
} else {
error!("Could not find player");
}
};
}
impl MusicClient for Client {
fn play(&self) -> Result<()> {
command!(self, play);
Ok(())
}
fn pause(&self) -> Result<()> {
command!(self, pause);
Ok(())
}
fn next(&self) -> Result<()> {
command!(self, next);
Ok(())
}
fn prev(&self) -> Result<()> {
command!(self, previous);
Ok(())
}
fn set_volume_percent(&self, vol: u8) -> Result<()> {
if let Some(player) = Self::get_player(self) {
player.set_volume(vol as f64 / 100.0)?;
} else {
error!("Could not find player");
}
Ok(())
}
fn subscribe_change(&self) -> Receiver<PlayerUpdate> {
debug!("Creating new subscription");
let rx = self.tx.subscribe();
if let Some(player) = self.get_player() {
if let Err(err) = Self::send_update(&player, &self.tx) {
error!("{err:?}");
}
}
rx
}
}
pub fn get_client() -> Arc<Client> {
CLIENT.clone()
}
impl From<Metadata> for Track {
fn from(value: Metadata) -> Self {
const KEY_DATE: &str = "xesam:contentCreated";
const KEY_GENRE: &str = "xesam:genre";
Self {
title: value.title().map(std::string::ToString::to_string),
album: value.album_name().map(std::string::ToString::to_string),
artist: value.artists().map(|artists| artists.join(", ")),
date: value
.get(KEY_DATE)
.and_then(mpris::MetadataValue::as_string)
.map(std::string::ToString::to_string),
disc: value.disc_number().map(|disc| disc as u64),
genre: value
.get(KEY_GENRE)
.and_then(mpris::MetadataValue::as_str_array)
.and_then(|arr| arr.first().map(|val| (*val).to_string())),
track: value.track_number().map(|track| track as u64),
cover_path: value.art_url().map(|s| s.to_string()),
}
}
}
impl From<PlaybackStatus> for PlayerState {
fn from(value: PlaybackStatus) -> Self {
match value {
PlaybackStatus::Playing => Self::Playing,
PlaybackStatus::Paused => Self::Paused,
PlaybackStatus::Stopped => Self::Stopped,
}
}
}

120
src/clients/system_tray.rs Normal file
View File

@@ -0,0 +1,120 @@
use crate::{lock, send};
use async_once::AsyncOnce;
use color_eyre::Report;
use lazy_static::lazy_static;
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex};
use stray::message::menu::TrayMenu;
use stray::message::tray::StatusNotifierItem;
use stray::message::{NotifierItemCommand, NotifierItemMessage};
use stray::StatusNotifierWatcher;
use tokio::spawn;
use tokio::sync::{broadcast, mpsc};
use tracing::error;
type Tray = BTreeMap<String, (Box<StatusNotifierItem>, Option<TrayMenu>)>;
pub struct TrayEventReceiver {
tx: mpsc::Sender<NotifierItemCommand>,
b_tx: broadcast::Sender<NotifierItemMessage>,
_b_rx: broadcast::Receiver<NotifierItemMessage>,
tray: Arc<Mutex<Tray>>,
}
impl TrayEventReceiver {
async fn new() -> stray::error::Result<Self> {
let (tx, rx) = mpsc::channel(16);
let (b_tx, b_rx) = broadcast::channel(16);
let tray = StatusNotifierWatcher::new(rx).await?;
let mut host = tray.create_notifier_host("ironbar").await?;
let tray = Arc::new(Mutex::new(BTreeMap::new()));
{
let b_tx = b_tx.clone();
let tray = tray.clone();
spawn(async move {
while let Ok(message) = host.recv().await {
send!(b_tx, message.clone());
let mut tray = lock!(tray);
match message {
NotifierItemMessage::Update {
address,
item,
menu,
} => {
tray.insert(address, (item, menu));
}
NotifierItemMessage::Remove { address } => {
tray.remove(&address);
}
}
}
Ok::<(), broadcast::error::SendError<NotifierItemMessage>>(())
});
}
Ok(Self {
tx,
b_tx,
_b_rx: b_rx,
tray,
})
}
pub fn subscribe(
&self,
) -> (
mpsc::Sender<NotifierItemCommand>,
broadcast::Receiver<NotifierItemMessage>,
) {
let tx = self.tx.clone();
let b_rx = self.b_tx.subscribe();
let tray = lock!(self.tray).clone();
for (address, (item, menu)) in tray {
let update = NotifierItemMessage::Update {
address,
item,
menu,
};
send!(self.b_tx, update);
}
(tx, b_rx)
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<TrayEventReceiver> = AsyncOnce::new(async {
const MAX_RETRIES: i32 = 10;
// sometimes this can fail
let mut retries = 0;
let value = loop {
retries += 1;
let tray = TrayEventReceiver::new().await;
match tray {
Ok(tray) => break Some(tray),
Err(err) => error!("{:?}", Report::new(err).wrap_err(format!("Failed to create StatusNotifierWatcher (attempt {retries})")))
}
if retries == MAX_RETRIES {
break None;
}
};
value.expect("Failed to create StatusNotifierWatcher")
});
}
pub async fn get_tray_event_client() -> &'static TrayEventReceiver {
CLIENT.get().await
}

View File

@@ -0,0 +1,9 @@
#[cfg(feature = "volume+pulse")]
pub mod pulse_bak;
// #[cfg(feature = "volume+pulse")]
// pub mod pulse;
trait VolumeClient {
// TODO: Write
}

View File

@@ -0,0 +1,345 @@
use libpulse_binding::{
callbacks::ListResult,
context::{
introspect::{CardInfo, SinkInfo, SinkInputInfo, SourceInfo, SourceOutputInfo},
subscribe::{InterestMaskSet, Operation},
},
// def::{SinkState, SourceState},
};
use tracing::{debug, error, info};
use super::{common::*, /*pa_interface::ACTIONS_SX*/};
// use crate::{
// entry::{CardProfile, Entry},
// models::EntryUpdate,
// ui::Rect,
// };
use color_eyre::Result;
use crate::clients::volume::pulse::CardProfile;
pub fn subscribe(
context: &Rc<RefCell<PAContext>>,
info_sx: mpsc::UnboundedSender<EntryIdentifier>,
) -> Result<()> {
info!("[PAInterface] Registering pulseaudio callbacks");
context.borrow_mut().subscribe(
InterestMaskSet::SINK
| InterestMaskSet::SINK_INPUT
| InterestMaskSet::SOURCE
| InterestMaskSet::CARD
| InterestMaskSet::SOURCE_OUTPUT
| InterestMaskSet::CLIENT
| InterestMaskSet::SERVER,
|success: bool| {
assert!(success, "subscription failed");
},
);
context.borrow_mut().set_subscribe_callback(Some(Box::new(
move |facility, operation, index| {
if let Some(facility) = facility {
match facility {
Facility::Server | Facility::Client => {
error!("{:?} {:?}", facility, operation);
return;
}
_ => {}
};
let entry_type: EntryType = facility.into();
match operation {
Some(Operation::New) => {
info!("[PAInterface] New {:?}", entry_type);
info_sx
.send(EntryIdentifier::new(entry_type, index))
.unwrap();
}
Some(Operation::Changed) => {
info!("[PAInterface] {:?} changed", entry_type);
info_sx
.send(EntryIdentifier::new(entry_type, index))
.unwrap();
}
Some(Operation::Removed) => {
info!("[PAInterface] {:?} removed", entry_type);
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryRemoved(EntryIdentifier::new(
// entry_type, index,
// )))
// .unwrap();
}
_ => {}
};
};
},
)));
Ok(())
}
pub fn request_current_state(
context: Rc<RefCell<PAContext>>,
info_sxx: mpsc::UnboundedSender<EntryIdentifier>,
) -> Result<()> {
info!("[PAInterface] Requesting starting state");
let introspector = context.borrow_mut().introspect();
let info_sx = info_sxx.clone();
introspector.get_sink_info_list(move |x: ListResult<&SinkInfo>| {
if let ListResult::Item(e) = x {
let _ = info_sx
.clone()
.send(EntryIdentifier::new(EntryType::Sink, e.index));
}
});
let info_sx = info_sxx.clone();
introspector.get_sink_input_info_list(move |x: ListResult<&SinkInputInfo>| {
if let ListResult::Item(e) = x {
let _ = info_sx.send(EntryIdentifier::new(EntryType::SinkInput, e.index));
}
});
let info_sx = info_sxx.clone();
introspector.get_source_info_list(move |x: ListResult<&SourceInfo>| {
if let ListResult::Item(e) = x {
let _ = info_sx.send(EntryIdentifier::new(EntryType::Source, e.index));
}
});
let info_sx = info_sxx.clone();
introspector.get_source_output_info_list(move |x: ListResult<&SourceOutputInfo>| {
if let ListResult::Item(e) = x {
let _ = info_sx.send(EntryIdentifier::new(EntryType::SourceOutput, e.index));
}
});
introspector.get_card_info_list(move |x: ListResult<&CardInfo>| {
if let ListResult::Item(e) = x {
let _ = info_sxx.send(EntryIdentifier::new(EntryType::Card, e.index));
}
});
Ok(())
}
pub fn request_info(
ident: EntryIdentifier,
context: &Rc<RefCell<PAContext>>,
info_sx: mpsc::UnboundedSender<EntryIdentifier>,
) {
let introspector = context.borrow_mut().introspect();
debug!(
"[PAInterface] Requesting info for {:?} {}",
ident.entry_type, ident.index
);
match ident.entry_type {
EntryType::SinkInput => {
introspector.get_sink_input_info(ident.index, on_sink_input_info(&info_sx));
}
EntryType::Sink => {
introspector.get_sink_info_by_index(ident.index, on_sink_info(&info_sx));
}
EntryType::SourceOutput => {
introspector.get_source_output_info(ident.index, on_source_output_info(&info_sx));
}
EntryType::Source => {
introspector.get_source_info_by_index(ident.index, on_source_info(&info_sx));
}
EntryType::Card => {
introspector.get_card_info_by_index(ident.index, on_card_info);
}
};
}
pub fn on_card_info(res: ListResult<&CardInfo>) {
if let ListResult::Item(i) = res {
let n = match i
.proplist
.get_str(libpulse_binding::proplist::properties::DEVICE_DESCRIPTION)
{
Some(s) => s,
None => String::from(""),
};
let profiles: Vec<CardProfile> = i
.profiles
.iter()
.filter_map(|p| {
p.name.clone().map(|n| CardProfile {
// area: Rect::default(),
is_selected: false,
name: n.to_string(),
description: match &p.description {
Some(s) => s.to_string(),
None => n.to_string(),
},
#[cfg(any(feature = "pa_v13"))]
available: p.available,
})
})
.collect();
let selected_profile = match &i.active_profile {
Some(x) => {
if let Some(n) = &x.name {
profiles.iter().position(|p| p.name == *n)
} else {
None
}
}
None => None,
};
// let ident = EntryIdentifier::new(EntryType::Card, i.index);
// let entry = Entry::new_card_entry(i.index, n, profiles, selected_profile);
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryUpdate(ident, Box::new(entry)))
// .unwrap();
}
}
pub fn on_sink_info(
_sx: &mpsc::UnboundedSender<EntryIdentifier>,
) -> impl Fn(ListResult<&SinkInfo>) {
|res: ListResult<&SinkInfo>| {
if let ListResult::Item(i) = res {
debug!("[PADataInterface] Update {} sink info", i.index);
let name = match &i.description {
Some(name) => name.to_string(),
None => String::new(),
};
// let ident = EntryIdentifier::new(EntryType::Sink, i.index);
// let entry = Entry::new_play_entry(
// EntryType::Sink,
// i.index,
// name,
// None,
// i.mute,
// i.volume,
// Some(i.monitor_source),
// None,
// i.state == SinkState::Suspended,
// );
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryUpdate(ident, Box::new(entry)))
// .unwrap();
}
}
}
pub fn on_sink_input_info(
sx: &mpsc::UnboundedSender<EntryIdentifier>,
) -> impl Fn(ListResult<&SinkInputInfo>) {
let info_sx = sx.clone();
move |res: ListResult<&SinkInputInfo>| {
if let ListResult::Item(i) = res {
debug!("[PADataInterface] Update {} sink input info", i.index);
let n = match i
.proplist
.get_str(libpulse_binding::proplist::properties::APPLICATION_NAME)
{
Some(s) => s,
None => match &i.name {
Some(s) => s.to_string(),
None => String::from(""),
},
};
// let ident = EntryIdentifier::new(EntryType::SinkInput, i.index);
//
// let entry = Entry::new_play_entry(
// EntryType::SinkInput,
// i.index,
// n,
// Some(i.sink),
// i.mute,
// i.volume,
// None,
// Some(i.sink),
// false,
// );
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryUpdate(ident, Box::new(entry)))
// .unwrap();
let _ = info_sx.send(EntryIdentifier::new(EntryType::Sink, i.sink));
}
}
}
pub fn on_source_info(
_sx: &mpsc::UnboundedSender<EntryIdentifier>,
) -> impl Fn(ListResult<&SourceInfo>) {
move |res: ListResult<&SourceInfo>| {
if let ListResult::Item(i) = res {
debug!("[PADataInterface] Update {} source info", i.index);
let name = match &i.description {
Some(name) => name.to_string(),
None => String::new(),
};
// let ident = EntryIdentifier::new(EntryType::Source, i.index);
// let entry = Entry::new_play_entry(
// EntryType::Source,
// i.index,
// name,
// None,
// i.mute,
// i.volume,
// Some(i.index),
// None,
// i.state == SourceState::Suspended,
// );
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryUpdate(ident, Box::new(entry)))
// .unwrap();
}
}
}
pub fn on_source_output_info(
sx: &mpsc::UnboundedSender<EntryIdentifier>,
) -> impl Fn(ListResult<&SourceOutputInfo>) {
let info_sx = sx.clone();
move |res: ListResult<&SourceOutputInfo>| {
if let ListResult::Item(i) = res {
debug!("[PADataInterface] Update {} source output info", i.index);
let n = match i
.proplist
.get_str(libpulse_binding::proplist::properties::APPLICATION_NAME)
{
Some(s) => s,
None => String::from(""),
};
if n == "RsMixerContext" {
return;
}
// let ident = EntryIdentifier::new(EntryType::SourceOutput, i.index);
// let entry = Entry::new_play_entry(
// EntryType::SourceOutput,
// i.index,
// n,
// Some(i.source),
// i.mute,
// i.volume,
// Some(i.source),
// None,
// false,
// );
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryUpdate(ident, Box::new(entry)))
// .unwrap();
let _ = info_sx.send(EntryIdentifier::new(EntryType::Source, i.index));
}
}
}

View File

@@ -0,0 +1,30 @@
pub use std::{cell::RefCell, collections::HashMap, rc::Rc};
pub use libpulse_binding::{
context::{subscribe::Facility, Context as PAContext},
mainloop::{api::Mainloop as MainloopTrait, threaded::Mainloop},
stream::Stream,
};
pub use tokio::sync::mpsc;
pub use super::{monitor::Monitors, PAInternal, SPEC};
// pub use crate::{
// entry::{EntryIdentifier, EntryType},
// models::{EntryUpdate, PulseAudioAction},
// prelude::*,
// };
pub static LOGGING_MODULE: &str = "PAInterface";
impl From<Facility> for EntryType {
fn from(fac: Facility) -> Self {
match fac {
Facility::Sink => EntryType::Sink,
Facility::Source => EntryType::Source,
Facility::SinkInput => EntryType::SinkInput,
Facility::SourceOutput => EntryType::SourceOutput,
Facility::Card => EntryType::Card,
_ => EntryType::Sink,
}
}
}

View File

@@ -0,0 +1,17 @@
// use thiserror::Error;
//
// use super::PAInternal;
//
// #[derive(Debug, Error)]
// pub enum PAError {
// #[error("cannot create pulseaudio mainloop")]
// MainloopCreateError,
// #[error("cannot connect pulseaudio mainloop")]
// MainloopConnectError,
// #[error("cannot create pulseaudio stream")]
// StreamCreateError,
// #[error("internal channel send error")]
// ChannelError(#[from] cb_channel::SendError<PAInternal>),
// #[error("pulseaudio disconnected")]
// PulseAudioDisconnected,
// }

View File

@@ -0,0 +1,36 @@
mod callbacks;
pub mod common;
mod errors;
mod monitor;
mod pa_actions;
mod pa_interface;
use common::*;
use lazy_static::lazy_static;
pub use pa_interface::start;
#[derive(Debug)]
pub enum PAInternal {
Tick,
Command(Box<PulseAudioAction>),
AskInfo(EntryIdentifier),
}
lazy_static! {
pub static ref SPEC: libpulse_binding::sample::Spec = libpulse_binding::sample::Spec {
format: libpulse_binding::sample::Format::FLOAT32NE,
channels: 1,
rate: 1024,
};
}
#[derive(PartialEq, Clone, Debug)]
pub struct CardProfile {
pub name: String,
pub description: String,
#[cfg(any(feature = "pa_v13"))]
pub available: bool,
// pub area: Rect,
pub is_selected: bool,
}
impl Eq for CardProfile {}

View File

@@ -0,0 +1,278 @@
use std::convert::TryInto;
use libpulse_binding::stream::PeekResult;
use tracing::{debug, error, info, warn};
use super::{common::*, /*pa_interface::ACTIONS_SX*/};
// use crate::VARIABLES;
use color_eyre::{Report, Result};
pub struct Monitor {
stream: Rc<RefCell<Stream>>,
exit_sender: mpsc::UnboundedSender<u32>,
}
pub struct Monitors {
monitors: HashMap<EntryIdentifier, Monitor>,
errors: HashMap<EntryIdentifier, usize>,
}
impl Default for Monitors {
fn default() -> Self {
Self {
monitors: HashMap::new(),
errors: HashMap::new(),
}
}
}
impl Monitors {
pub fn filter(
&mut self,
mainloop: &Rc<RefCell<Mainloop>>,
context: &Rc<RefCell<PAContext>>,
targets: &HashMap<EntryIdentifier, Option<u32>>,
) {
// remove failed streams
// then send exit signal if stream is unwanted
self.monitors.retain(|ident, monitor| {
match monitor.stream.borrow_mut().get_state() {
libpulse_binding::stream::State::Terminated
| libpulse_binding::stream::State::Failed => {
info!(
"[PAInterface] Disconnecting {} sink input monitor (failed state)",
ident.index
);
return false;
}
_ => {}
};
if targets.get(ident) == None {
let _ = monitor.exit_sender.send(0);
}
true
});
targets.iter().for_each(|(ident, monitor_src)| {
if self.monitors.get(ident).is_none() {
self.create_monitor(mainloop, context, *ident, *monitor_src);
}
});
}
fn create_monitor(
&mut self,
mainloop: &Rc<RefCell<Mainloop>>,
context: &Rc<RefCell<PAContext>>,
ident: EntryIdentifier,
monitor_src: Option<u32>,
) {
if let Some(count) = self.errors.get(&ident) {
if *count >= 5 {
self.errors.remove(&ident);
// (*ACTIONS_SX)
// .get()
// .send(EntryUpdate::EntryRemoved(ident))
// .unwrap();
}
}
if self.monitors.contains_key(&ident) {
return;
}
let (sx, rx) = mpsc::unbounded_channel();
if let Ok(stream) = create(
&mainloop,
&context,
&libpulse_binding::sample::Spec {
format: libpulse_binding::sample::Format::FLOAT32NE,
channels: 1,
rate: /*(*VARIABLES).get().pa_rate*/ 20,
},
ident,
monitor_src,
rx,
) {
self.monitors.insert(
ident,
Monitor {
stream,
exit_sender: sx,
},
);
self.errors.remove(&ident);
} else {
self.error(&ident);
}
}
fn error(&mut self, ident: &EntryIdentifier) {
let count = match self.errors.get(&ident) {
Some(x) => *x,
None => 0,
};
self.errors.insert(*ident, count + 1);
}
}
fn slice_to_4_bytes(slice: &[u8]) -> [u8; 4] {
slice.try_into().expect("slice with incorrect length")
}
fn create(
p_mainloop: &Rc<RefCell<Mainloop>>,
p_context: &Rc<RefCell<PAContext>>,
p_spec: &libpulse_binding::sample::Spec,
ident: EntryIdentifier,
source_index: Option<u32>,
mut close_rx: mpsc::UnboundedReceiver<u32>,
) -> Result<Rc<RefCell<Stream>>> {
info!("[PADataInterface] Attempting to create new monitor stream");
let stream_index = if ident.entry_type == EntryType::SinkInput {
Some(ident.index)
} else {
None
};
let stream = Rc::new(RefCell::new(
match Stream::new(&mut p_context.borrow_mut(), "RsMixer monitor", p_spec, None) {
Some(stream) => stream,
None => return Err(Report::msg("Error creating stream for monitoring volume")),
},
));
// Stream state change callback
{
debug!("[PADataInterface] Registering stream state change callback");
let ml_ref = Rc::clone(&p_mainloop);
let stream_ref = Rc::downgrade(&stream);
stream
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*(*stream_ref.as_ptr()).as_ptr()).get_state() };
match state {
libpulse_binding::stream::State::Ready
| libpulse_binding::stream::State::Failed
| libpulse_binding::stream::State::Terminated => {
unsafe { (*ml_ref.as_ptr()).signal(false) };
}
_ => {}
}
})));
}
// for sink inputs we want to set monitor stream to sink
if let Some(index) = stream_index {
stream.borrow_mut().set_monitor_stream(index).unwrap();
}
let x;
let mut s = None;
if let Some(i) = source_index {
x = i.to_string();
s = Some(x.as_str());
}
debug!("[PADataInterface] Connecting stream");
match stream.borrow_mut().connect_record(
s,
Some(&libpulse_binding::def::BufferAttr {
maxlength: std::u32::MAX,
tlength: std::u32::MAX,
prebuf: std::u32::MAX,
minreq: 0,
fragsize: /*(*VARIABLES).get().pa_frag_size*/ 48,
}),
libpulse_binding::stream::FlagSet::PEAK_DETECT
| libpulse_binding::stream::FlagSet::ADJUST_LATENCY,
) {
Ok(_) => {}
Err(err) => {
return Err(Report::new(err).wrap_err("while connecting stream for monitoring volume"));
}
};
debug!("[PADataInterface] Waiting for stream to be ready");
loop {
match stream.borrow_mut().get_state() {
libpulse_binding::stream::State::Ready => {
break;
}
libpulse_binding::stream::State::Failed
| libpulse_binding::stream::State::Terminated => {
error!("[PADataInterface] Stream state failed/terminated");
return Err(Report::msg("Stream terminated"))
}
_ => {
p_mainloop.borrow_mut().wait();
}
}
}
stream.borrow_mut().set_state_callback(None);
{
info!("[PADataInterface] Registering stream read callback");
let ml_ref = Rc::clone(&p_mainloop);
let stream_ref = Rc::downgrade(&stream);
stream.borrow_mut().set_read_callback(Some(Box::new(move |_size: usize| {
let remove_failed = || {
error!("[PADataInterface] Monitor failed or terminated");
};
let disconnect_stream = || {
warn!("[PADataInterface] {:?} Monitor existed while the sink (input)/source (output) was already gone", ident);
unsafe {
(*(*stream_ref.as_ptr()).as_ptr()).disconnect().unwrap();
(*ml_ref.as_ptr()).signal(false);
};
};
if close_rx.try_recv().is_ok() {
disconnect_stream();
return;
}
match unsafe {(*(*stream_ref.as_ptr()).as_ptr()).get_state() }{
libpulse_binding::stream::State::Failed => {
remove_failed();
},
libpulse_binding::stream::State::Terminated => {
remove_failed();
},
libpulse_binding::stream::State::Ready => {
match unsafe{ (*(*stream_ref.as_ptr()).as_ptr()).peek() } {
Ok(res) => match res {
PeekResult::Data(data) => {
let count = data.len() / 4;
let mut peak = 0.0;
for c in 0..count {
let data_slice = slice_to_4_bytes(&data[c * 4 .. (c + 1) * 4]);
peak += f32::from_ne_bytes(data_slice).abs();
}
peak = peak / count as f32;
// if (*ACTIONS_SX).get().send(EntryUpdate::PeakVolumeUpdate(ident, peak)).is_err() {
// disconnect_stream();
// }
unsafe { (*(*stream_ref.as_ptr()).as_ptr()).discard().unwrap(); };
},
PeekResult::Hole(_) => {
unsafe { (*(*stream_ref.as_ptr()).as_ptr()).discard().unwrap(); };
},
_ => {},
},
Err(_) => {
remove_failed();
},
}
},
_ => {},
};
})));
}
Ok(stream)
}

View File

@@ -0,0 +1,148 @@
use super::{callbacks, common::*};
pub fn handle_command(
cmd: PulseAudioAction,
context: &Rc<RefCell<PAContext>>,
info_sx: &mpsc::UnboundedSender<EntryIdentifier>,
) -> Option<()> {
match cmd {
PulseAudioAction::RequestPulseAudioState => {
callbacks::request_current_state(Rc::clone(&context), info_sx.clone()).unwrap();
}
PulseAudioAction::MuteEntry(ident, mute) => {
set_mute(ident, mute, &context);
}
PulseAudioAction::MoveEntryToParent(ident, parent) => {
move_entry_to_parent(ident, parent, &context, info_sx.clone());
}
PulseAudioAction::ChangeCardProfile(ident, profile) => {
change_card_profile(ident, profile, &context);
}
PulseAudioAction::SetVolume(ident, vol) => {
set_volume(ident, vol, &context);
}
PulseAudioAction::SetSuspend(ident, suspend) => {
set_suspend(ident, suspend, &context);
}
PulseAudioAction::KillEntry(ident) => {
kill_entry(ident, &context);
}
PulseAudioAction::Shutdown => {
//@TODO disconnect monitors
return None;
}
_ => {}
};
Some(())
}
fn set_volume(
ident: EntryIdentifier,
vol: libpulse_binding::volume::ChannelVolumes,
context: &Rc<RefCell<PAContext>>,
) {
let mut introspector = context.borrow_mut().introspect();
match ident.entry_type {
EntryType::Sink => {
introspector.set_sink_volume_by_index(ident.index, &vol, None);
}
EntryType::SinkInput => {
introspector.set_sink_input_volume(ident.index, &vol, None);
}
EntryType::Source => {
introspector.set_source_volume_by_index(ident.index, &vol, None);
}
EntryType::SourceOutput => {
introspector.set_source_output_volume(ident.index, &vol, None);
}
_ => {}
};
}
fn change_card_profile(ident: EntryIdentifier, profile: String, context: &Rc<RefCell<PAContext>>) {
if ident.entry_type != EntryType::Card {
return;
}
context
.borrow_mut()
.introspect()
.set_card_profile_by_index(ident.index, &profile[..], None);
}
fn move_entry_to_parent(
ident: EntryIdentifier,
parent: EntryIdentifier,
context: &Rc<RefCell<PAContext>>,
info_sx: mpsc::UnboundedSender<EntryIdentifier>,
) {
let mut introspector = context.borrow_mut().introspect();
match ident.entry_type {
EntryType::SinkInput => {
introspector.move_sink_input_by_index(
ident.index,
parent.index,
Some(Box::new(move |_| {
info_sx.send(parent).unwrap();
info_sx.send(ident).unwrap();
})),
);
}
EntryType::SourceOutput => {
introspector.move_source_output_by_index(
ident.index,
parent.index,
Some(Box::new(move |_| {
info_sx.send(parent).unwrap();
info_sx.send(ident).unwrap();
})),
);
}
_ => {}
};
}
fn set_suspend(ident: EntryIdentifier, suspend: bool, context: &Rc<RefCell<PAContext>>) {
let mut introspector = context.borrow_mut().introspect();
match ident.entry_type {
EntryType::Sink => {
introspector.suspend_sink_by_index(ident.index, suspend, None);
}
EntryType::Source => {
introspector.suspend_source_by_index(ident.index, suspend, None);
}
_ => {}
};
}
fn kill_entry(ident: EntryIdentifier, context: &Rc<RefCell<PAContext>>) {
let mut introspector = context.borrow_mut().introspect();
match ident.entry_type {
EntryType::SinkInput => {
introspector.kill_sink_input(ident.index, |_| {});
}
EntryType::SourceOutput => {
introspector.kill_source_output(ident.index, |_| {});
}
_ => {}
};
}
fn set_mute(ident: EntryIdentifier, mute: bool, context: &Rc<RefCell<PAContext>>) {
let mut introspector = context.borrow_mut().introspect();
match ident.entry_type {
EntryType::Sink => {
introspector.set_sink_mute_by_index(ident.index, mute, None);
}
EntryType::SinkInput => {
introspector.set_sink_input_mute(ident.index, mute, None);
}
EntryType::Source => {
introspector.set_source_mute_by_index(ident.index, mute, None);
}
EntryType::SourceOutput => {
introspector.set_source_output_mute(ident.index, mute, None);
}
_ => {}
};
}

View File

@@ -0,0 +1,162 @@
use std::ops::Deref;
// use lazy_static::lazy_static;
use libpulse_binding::proplist::Proplist;
use tracing::{debug, error, info};
// use state::Storage;
use color_eyre::{Report, Result};
use super::{callbacks, common::*, pa_actions};
// lazy_static! {
// pub static ref ACTIONS_SX: Storage<mpsc::UnboundedSender<EntryUpdate>> = Storage::new();
// }
pub async fn start(
mut internal_rx: mpsc::Receiver<PAInternal>,
info_sx: mpsc::UnboundedSender<EntryIdentifier>,
actions_sx: mpsc::UnboundedSender<EntryUpdate>,
) -> Result<()> {
// (*ACTIONS_SX).set(actions_sx);
// Create new mainloop and context
let mut proplist = Proplist::new().unwrap();
proplist
.set_str(libpulse_binding::proplist::properties::APPLICATION_NAME, "RsMixer")
.unwrap();
debug!("[PAInterface] Creating new mainloop");
let mainloop = Rc::new(RefCell::new(match Mainloop::new() {
Some(ml) => ml,
None => {
error!("[PAInterface] Error while creating new mainloop");
return Err(Report::msg("Error while creating new mainloop"));
}
}));
debug!("[PAInterface] Creating new context");
let context = Rc::new(RefCell::new(
match PAContext::new_with_proplist(
mainloop.borrow_mut().deref().deref(),
"RsMixerContext",
&proplist,
) {
Some(ctx) => ctx,
None => {
error!("[PAInterface] Error while creating new context");
return Err(Report::msg("Error while creating new context"));
}
},
));
// PAContext state change callback
{
debug!("[PAInterface] Registering state change callback");
let ml_ref = Rc::clone(&mainloop);
let context_ref = Rc::clone(&context);
context
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*context_ref.as_ptr()).get_state() };
if matches!(
state,
libpulse_binding::context::State::Ready
| libpulse_binding::context::State::Failed
| libpulse_binding::context::State::Terminated
) {
unsafe { (*ml_ref.as_ptr()).signal(false) };
}
})));
}
// Try to connect to pulseaudio
debug!("[PAInterface] Connecting context");
if context
.borrow_mut()
.connect(None, libpulse_binding::context::FlagSet::NOFLAGS, None)
.is_err()
{
error!("[PAInterface] Error while connecting context");
return Err(Report::msg("Error while connecting context"));
}
info!("[PAInterface] Starting mainloop");
// start mainloop
mainloop.borrow_mut().lock();
if let Err(err) = mainloop.borrow_mut().start() {
return Err(Report::new(err));
}
debug!("[PAInterface] Waiting for context to be ready...");
// wait for context to be ready
loop {
match context.borrow_mut().get_state() {
libpulse_binding::context::State::Ready => {
break;
}
libpulse_binding::context::State::Failed | libpulse_binding::context::State::Terminated => {
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
error!("[PAInterface] Connection failed or context terminated");
return Err(Report::msg("Connection failed or context terminated"));
}
_ => {
mainloop.borrow_mut().wait();
}
}
}
debug!("[PAInterface] PAContext ready");
context.borrow_mut().set_state_callback(None);
callbacks::subscribe(&context, info_sx.clone())?;
callbacks::request_current_state(context.clone(), info_sx.clone())?;
mainloop.borrow_mut().unlock();
debug!("[PAInterface] Actually starting our mainloop");
let mut monitors = Monitors::default();
let mut last_targets = HashMap::new();
while let Some(msg) = internal_rx.recv().await {
mainloop.borrow_mut().lock();
match context.borrow_mut().get_state() {
libpulse_binding::context::State::Ready => {}
_ => {
mainloop.borrow_mut().unlock();
return Err(Report::msg("Disconnected while working"))
}
}
match msg {
PAInternal::AskInfo(ident) => {
callbacks::request_info(ident, &context, info_sx.clone());
}
PAInternal::Tick => {
// remove failed monitors
monitors.filter(&mainloop, &context, &last_targets);
}
PAInternal::Command(cmd) => {
let cmd = cmd.deref();
if pa_actions::handle_command(cmd.clone(), &context, &info_sx).is_none() {
monitors.filter(&mainloop, &context, &HashMap::new());
mainloop.borrow_mut().unlock();
break;
}
if let PulseAudioAction::CreateMonitors(mons) = cmd.clone() {
last_targets = mons;
monitors.filter(&mainloop, &context, &last_targets);
}
}
};
mainloop.borrow_mut().unlock();
}
Ok(())
}

View File

@@ -0,0 +1,90 @@
use crate::clients::volume::VolumeClient;
use libpulse_binding::context::State;
use libpulse_binding::{
context::{Context, FlagSet},
mainloop::threaded::Mainloop,
proplist::{properties, Proplist},
};
use std::cell::RefCell;
use std::ops::Deref;
use std::rc::Rc;
use tracing::{debug, error};
pub fn test() {
let mut prop_list = Proplist::new().unwrap();
prop_list
.set_str(properties::APPLICATION_NAME, "ironbar")
.unwrap();
let mainloop = Rc::new(RefCell::new(Mainloop::new().unwrap()));
let context = Rc::new(RefCell::new(
Context::new_with_proplist(mainloop.borrow().deref(), "ironbar_context", &prop_list)
.unwrap(),
));
// PAContext state change callback
{
debug!("[PAInterface] Registering state change callback");
let ml_ref = Rc::clone(&mainloop);
let context_ref = Rc::clone(&context);
context
.borrow_mut()
.set_state_callback(Some(Box::new(move || {
let state = unsafe { (*context_ref.as_ptr()).get_state() };
if matches!(state, State::Ready | State::Failed | State::Terminated) {
unsafe { (*ml_ref.as_ptr()).signal(false) };
}
})));
}
if let Err(err) = context.borrow_mut().connect(None, FlagSet::NOFLAGS, None) {
error!("{err:?}");
}
println!("{:?}", context.borrow().get_server());
mainloop.borrow_mut().lock();
if let Err(err) = mainloop.borrow_mut().start() {
error!("{err:?}");
}
debug!("[PAInterface] Waiting for context to be ready...");
println!("[PAInterface] Waiting for context to be ready...");
// wait for context to be ready
loop {
match context.borrow().get_state() {
State::Ready => {
break;
}
State::Failed | State::Terminated => {
mainloop.borrow_mut().unlock();
mainloop.borrow_mut().stop();
error!("[PAInterface] Connection failed or context terminated");
}
_ => {
mainloop.borrow_mut().wait();
}
}
}
debug!("[PAInterface] PAContext ready");
println!("[PAInterface] PAContext ready");
context.borrow_mut().set_state_callback(None);
println!("jfgjfgg");
let introspector = context.borrow().introspect();
println!("jfgjfgg2");
introspector.get_sink_info_list(|result| {
println!("boo: {result:?}");
});
println!("fjgjfgf??");
}
struct PulseVolumeClient {}
impl VolumeClient for PulseVolumeClient {}

View File

@@ -0,0 +1,287 @@
use super::wlr_foreign_toplevel::{
handle::{ToplevelEvent, ToplevelInfo},
manager::listen_for_toplevels,
};
use super::{DData, Env, ToplevelHandler};
use crate::{error as err, send};
use cfg_if::cfg_if;
use color_eyre::Report;
use indexmap::IndexMap;
use smithay_client_toolkit::environment::Environment;
use smithay_client_toolkit::output::{with_output_info, OutputInfo};
use smithay_client_toolkit::reexports::calloop::channel::{channel, Event, Sender};
use smithay_client_toolkit::reexports::calloop::EventLoop;
use smithay_client_toolkit::WaylandSource;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use tokio::sync::{broadcast, oneshot};
use tokio::task::spawn_blocking;
use tracing::{debug, error};
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{ConnectError, Display, EventQueue};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
};
cfg_if! {
if #[cfg(feature = "clipboard")] {
use super::{ClipboardItem};
use super::wlr_data_control::manager::{listen_to_devices, DataControlDeviceHandler};
use crate::{read_lock, write_lock};
use tokio::spawn;
}
}
#[derive(Debug)]
pub enum Request {
/// Copies the value to the clipboard
#[cfg(feature = "clipboard")]
CopyToClipboard(Arc<ClipboardItem>),
/// Forces a dispatch, flushing any currently queued events
Refresh,
}
pub struct WaylandClient {
pub outputs: Vec<OutputInfo>,
pub seats: Vec<WlSeat>,
pub toplevels: Arc<RwLock<IndexMap<usize, (ToplevelInfo, ZwlrForeignToplevelHandleV1)>>>,
toplevel_tx: broadcast::Sender<ToplevelEvent>,
_toplevel_rx: broadcast::Receiver<ToplevelEvent>,
#[cfg(feature = "clipboard")]
clipboard_tx: broadcast::Sender<Arc<ClipboardItem>>,
#[cfg(feature = "clipboard")]
clipboard: Arc<RwLock<Option<Arc<ClipboardItem>>>>,
request_tx: Sender<Request>,
}
impl WaylandClient {
pub(super) async fn new() -> Self {
let (output_tx, output_rx) = oneshot::channel();
let (seat_tx, seat_rx) = oneshot::channel();
let (toplevel_tx, toplevel_rx) = broadcast::channel(32);
let toplevels = Arc::new(RwLock::new(IndexMap::new()));
let toplevels2 = toplevels.clone();
let toplevel_tx2 = toplevel_tx.clone();
cfg_if! {
if #[cfg(feature = "clipboard")] {
let (clipboard_tx, mut clipboard_rx) = broadcast::channel(32);
let clipboard = Arc::new(RwLock::new(None));
let clipboard_tx2 = clipboard_tx.clone();
}
}
let (ev_tx, ev_rx) = channel::<Request>();
// `queue` is not `Send` so we need to handle everything inside the task
spawn_blocking(move || {
let toplevels = toplevels2;
let toplevel_tx = toplevel_tx2;
let (env, _display, queue) =
Self::new_environment().expect("Failed to connect to Wayland compositor");
let mut event_loop =
EventLoop::<DData>::try_new().expect("Failed to create new event loop");
WaylandSource::new(queue)
.quick_insert(event_loop.handle())
.expect("Failed to insert Wayland event queue into event loop");
let outputs = Self::get_outputs(&env);
send!(output_tx, outputs);
let seats = env.get_all_seats();
// TODO: Actually handle seats properly
#[cfg(feature = "clipboard")]
let default_seat = seats[0].detach();
send!(
seat_tx,
seats
.into_iter()
.map(|seat| seat.detach())
.collect::<Vec<WlSeat>>()
);
let handle = event_loop.handle();
handle
.insert_source(ev_rx, move |event, _metadata, ddata| {
// let env = &ddata.env;
match event {
Event::Msg(Request::Refresh) => debug!("Received refresh event"),
#[cfg(feature = "clipboard")]
Event::Msg(Request::CopyToClipboard(value)) => {
super::wlr_data_control::copy_to_clipboard(
&ddata.env,
&default_seat,
&value,
)
.expect("Failed to copy to clipboard");
}
Event::Closed => panic!("Channel unexpectedly closed"),
}
})
.expect("Failed to insert channel into event queue");
let _toplevel_manager = env.require_global::<ZwlrForeignToplevelManagerV1>();
let _toplevel_listener = listen_for_toplevels(&env, move |handle, event, _ddata| {
super::wlr_foreign_toplevel::update_toplevels(
&toplevels,
handle,
event,
&toplevel_tx,
);
});
cfg_if! {
if #[cfg(feature = "clipboard")] {
let clipboard_tx = clipboard_tx2;
let handle = event_loop.handle();
let _offer_listener = listen_to_devices(&env, move |_seat, event, ddata| {
debug!("Received clipboard event");
super::wlr_data_control::receive_offer(event, &handle, clipboard_tx.clone(), ddata);
});
}
}
let mut data = DData {
env,
offer_tokens: HashMap::new(),
};
loop {
if let Err(err) = event_loop.dispatch(None, &mut data) {
error!(
"{:?}",
Report::new(err).wrap_err("Failed to dispatch pending wayland events")
);
}
}
});
// keep track of current clipboard item
#[cfg(feature = "clipboard")]
{
let clipboard = clipboard.clone();
spawn(async move {
while let Ok(item) = clipboard_rx.recv().await {
let mut clipboard = write_lock!(clipboard);
clipboard.replace(item);
}
});
}
let outputs = output_rx.await.expect(err::ERR_CHANNEL_RECV);
let seats = seat_rx.await.expect(err::ERR_CHANNEL_RECV);
Self {
outputs,
seats,
#[cfg(feature = "clipboard")]
clipboard,
toplevels,
toplevel_tx,
_toplevel_rx: toplevel_rx,
#[cfg(feature = "clipboard")]
clipboard_tx,
request_tx: ev_tx,
}
}
pub fn subscribe_toplevels(&self) -> broadcast::Receiver<ToplevelEvent> {
self.toplevel_tx.subscribe()
}
#[cfg(feature = "clipboard")]
pub fn subscribe_clipboard(&self) -> broadcast::Receiver<Arc<ClipboardItem>> {
self.clipboard_tx.subscribe()
}
pub fn roundtrip(&self) {
send!(self.request_tx, Request::Refresh);
}
#[cfg(feature = "clipboard")]
pub fn get_clipboard(&self) -> Option<Arc<ClipboardItem>> {
let clipboard = read_lock!(self.clipboard);
clipboard.as_ref().cloned()
}
#[cfg(feature = "clipboard")]
pub fn copy_to_clipboard(&self, item: Arc<ClipboardItem>) {
send!(self.request_tx, Request::CopyToClipboard(item));
}
fn get_outputs(env: &Environment<Env>) -> Vec<OutputInfo> {
let outputs = env.get_all_outputs();
outputs
.iter()
.filter_map(|output| with_output_info(output, Clone::clone))
.collect()
}
fn new_environment() -> Result<(Environment<Env>, Display, EventQueue), ConnectError> {
Display::connect_to_env().and_then(|display| {
let mut queue = display.create_event_queue();
let ret = {
let mut sctk_seats = smithay_client_toolkit::seat::SeatHandler::new();
let sctk_data_device_manager =
smithay_client_toolkit::data_device::DataDeviceHandler::init(&mut sctk_seats);
#[cfg(feature = "clipboard")]
let data_control_device = DataControlDeviceHandler::init(&mut sctk_seats);
let sctk_primary_selection_manager =
smithay_client_toolkit::primary_selection::PrimarySelectionHandler::init(
&mut sctk_seats,
);
let display = ::smithay_client_toolkit::reexports::client::Proxy::clone(&display);
let env = Environment::new(
&display.attach(queue.token()),
&mut queue,
Env {
sctk_compositor: smithay_client_toolkit::environment::SimpleGlobal::new(),
sctk_subcompositor: smithay_client_toolkit::environment::SimpleGlobal::new(
),
sctk_shm: smithay_client_toolkit::shm::ShmHandler::new(),
sctk_outputs: smithay_client_toolkit::output::OutputHandler::new(),
sctk_seats,
sctk_data_device_manager,
sctk_primary_selection_manager,
toplevel: ToplevelHandler::init(),
#[cfg(feature = "clipboard")]
data_control_device,
},
);
if let Ok(env) = env.as_ref() {
let _psm = env.get_primary_selection_manager();
}
env
};
match ret {
Ok(env) => Ok((env, display, queue)),
Err(_e) => display.protocol_error().map_or_else(
|| Err(ConnectError::NoCompositorListening),
|perr| {
panic!("[SCTK] A protocol error occured during initial setup: {perr}");
},
),
}
})
}
}

View File

@@ -0,0 +1,75 @@
mod client;
mod wlr_foreign_toplevel;
use std::collections::HashMap;
use async_once::AsyncOnce;
use lazy_static::lazy_static;
use std::fmt::Debug;
use cfg_if::cfg_if;
use smithay_client_toolkit::default_environment;
use smithay_client_toolkit::environment::Environment;
use smithay_client_toolkit::reexports::calloop::RegistrationToken;
use wayland_client::{Attached, Interface};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1;
pub use wlr_foreign_toplevel::handle::{ToplevelChange, ToplevelEvent, ToplevelInfo};
use wlr_foreign_toplevel::manager::{ToplevelHandler};
pub use client::WaylandClient;
cfg_if! {
if #[cfg(feature = "clipboard")] {
mod wlr_data_control;
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1;
use wlr_data_control::manager::DataControlDeviceHandler;
pub use wlr_data_control::{ClipboardItem, ClipboardValue};
}
}
/// A utility for lazy-loading globals.
/// Taken from `smithay_client_toolkit` where it's not exposed
#[derive(Debug)]
enum LazyGlobal<I: Interface> {
Unknown,
Seen { id: u32, version: u32 },
Bound(Attached<I>),
}
pub struct DData {
env: Environment<Env>,
offer_tokens: HashMap<u128, RegistrationToken>,
}
cfg_if! {
if #[cfg(feature = "clipboard")] {
default_environment!(Env,
fields = [
toplevel: ToplevelHandler,
data_control_device: DataControlDeviceHandler
],
singles = [
ZwlrForeignToplevelManagerV1 => toplevel,
ZwlrDataControlManagerV1 => data_control_device
],
);
} else {
default_environment!(Env,
fields = [
toplevel: ToplevelHandler,
],
singles = [
ZwlrForeignToplevelManagerV1 => toplevel,
],
);
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<WaylandClient> =
AsyncOnce::new(async { WaylandClient::new().await });
}
pub async fn get_client() -> &'static WaylandClient {
CLIENT.get().await
}

View File

@@ -0,0 +1,88 @@
use super::offer::DataControlOffer;
use super::source::DataControlSource;
use crate::lock;
use std::sync::{Arc, Mutex};
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{Attached, DispatchData, Main};
use wayland_protocols::wlr::unstable::data_control::v1::client::{
zwlr_data_control_device_v1::{Event, ZwlrDataControlDeviceV1},
zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
zwlr_data_control_offer_v1::ZwlrDataControlOfferV1,
};
#[derive(Debug)]
struct Inner {
offer: Option<Arc<DataControlOffer>>,
}
impl Inner {
fn new_offer(&mut self, offer: &Main<ZwlrDataControlOfferV1>) {
self.offer.replace(Arc::new(DataControlOffer::new(offer)));
}
}
#[derive(Debug, Clone)]
pub struct DataControlDeviceEvent(pub Arc<DataControlOffer>);
fn data_control_device_implem<F>(
event: Event,
inner: &mut Inner,
implem: &mut F,
ddata: DispatchData,
) where
F: FnMut(DataControlDeviceEvent, DispatchData),
{
match event {
Event::DataOffer { id } => {
inner.new_offer(&id);
}
Event::Selection { id: Some(offer) } => {
let inner_offer = inner
.offer
.clone()
.expect("Offer should exist at this stage");
if offer == inner_offer.offer {
implem(DataControlDeviceEvent(inner_offer), ddata);
}
}
_ => {}
}
}
pub struct DataControlDevice {
device: ZwlrDataControlDeviceV1,
_inner: Arc<Mutex<Inner>>,
}
impl DataControlDevice {
pub fn init_for_seat<F>(
manager: &Attached<ZwlrDataControlManagerV1>,
seat: &WlSeat,
mut callback: F,
) -> Self
where
F: FnMut(DataControlDeviceEvent, DispatchData) + 'static,
{
let inner = Arc::new(Mutex::new(Inner { offer: None }));
let device = manager.get_data_device(seat);
{
let inner = inner.clone();
device.quick_assign(move |_handle, event, ddata| {
let mut inner = lock!(inner);
data_control_device_implem(event, &mut inner, &mut callback, ddata);
});
}
Self {
device: device.detach(),
_inner: inner,
}
}
pub fn set_selection(&self, source: &Option<DataControlSource>) {
self.device
.set_selection(source.as_ref().map(|s| &s.source));
}
}

View File

@@ -0,0 +1,253 @@
use super::device::{DataControlDevice, DataControlDeviceEvent};
use super::source::DataControlSource;
use smithay_client_toolkit::data_device::WritePipe;
use smithay_client_toolkit::environment::{Environment, GlobalHandler};
use smithay_client_toolkit::seat::{SeatHandling, SeatListener};
use smithay_client_toolkit::MissingGlobal;
use std::cell::RefCell;
use std::rc::{self, Rc};
use tracing::warn;
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::{Attached, DispatchData};
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1;
enum DataControlDeviceHandlerInner {
Ready {
manager: Attached<ZwlrDataControlManagerV1>,
devices: Vec<(WlSeat, DataControlDevice)>,
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
},
Pending {
seats: Vec<WlSeat>,
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
},
}
impl DataControlDeviceHandlerInner {
fn init_manager(&mut self, manager: Attached<ZwlrDataControlManagerV1>) {
let (seats, status_listeners) = if let Self::Pending {
seats,
status_listeners,
} = self
{
(std::mem::take(seats), status_listeners.clone())
} else {
warn!("Ignoring second zwlr_data_control_manager_v1");
return;
};
let mut devices = Vec::new();
for seat in seats {
let my_seat = seat.clone();
let status_listeners = status_listeners.clone();
let device =
DataControlDevice::init_for_seat(&manager, &seat, move |event, dispatch_data| {
notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners);
});
devices.push((seat.clone(), device));
}
*self = Self::Ready {
manager,
devices,
status_listeners,
};
}
fn get_manager(&self) -> Option<Attached<ZwlrDataControlManagerV1>> {
match self {
Self::Ready { manager, .. } => Some(manager.clone()),
Self::Pending { .. } => None,
}
}
fn new_seat(&mut self, seat: &WlSeat) {
match self {
Self::Ready {
manager,
devices,
status_listeners,
} => {
if devices.iter().any(|(s, _)| s == seat) {
// the seat already exists, nothing to do
return;
}
let my_seat = seat.clone();
let status_listeners = status_listeners.clone();
let device =
DataControlDevice::init_for_seat(manager, seat, move |event, dispatch_data| {
notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners);
});
devices.push((seat.clone(), device));
}
Self::Pending { seats, .. } => {
seats.push(seat.clone());
}
}
}
fn remove_seat(&mut self, seat: &WlSeat) {
match self {
Self::Ready { devices, .. } => devices.retain(|(s, _)| s != seat),
Self::Pending { seats, .. } => seats.retain(|s| s != seat),
}
}
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
where
F: FnMut(String, WritePipe, DispatchData) + 'static,
{
match self {
Self::Ready { manager, .. } => {
let source = DataControlSource::new(manager, mime_types, callback);
Some(source)
}
Self::Pending { .. } => None,
}
}
fn with_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
where
F: FnOnce(&DataControlDevice),
{
match self {
Self::Ready { devices, .. } => {
let device = devices
.iter()
.find_map(|(s, device)| if s == seat { Some(device) } else { None });
device.map_or(Err(MissingGlobal), |device| {
f(device);
Ok(())
})
}
Self::Pending { .. } => Err(MissingGlobal),
}
}
}
pub struct DataControlDeviceHandler {
inner: Rc<RefCell<DataControlDeviceHandlerInner>>,
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
_seat_listener: SeatListener,
}
impl DataControlDeviceHandler {
pub fn init<S>(seat_handler: &mut S) -> Self
where
S: SeatHandling,
{
let status_listeners = Rc::new(RefCell::new(Vec::new()));
let inner = Rc::new(RefCell::new(DataControlDeviceHandlerInner::Pending {
seats: Vec::new(),
status_listeners: status_listeners.clone(),
}));
let seat_inner = inner.clone();
let seat_listener = seat_handler.listen(move |seat, seat_data, _| {
if seat_data.defunct {
seat_inner.borrow_mut().remove_seat(&seat);
} else {
seat_inner.borrow_mut().new_seat(&seat);
}
});
Self {
inner,
_seat_listener: seat_listener,
status_listeners,
}
}
}
impl GlobalHandler<ZwlrDataControlManagerV1> for DataControlDeviceHandler {
fn created(
&mut self,
registry: Attached<WlRegistry>,
id: u32,
version: u32,
_ddata: DispatchData,
) {
// data control manager is supported until version 2
let version = std::cmp::min(version, 2);
let manager = registry.bind::<ZwlrDataControlManagerV1>(version, id);
self.inner.borrow_mut().init_manager((*manager).clone());
}
fn get(&self) -> Option<Attached<ZwlrDataControlManagerV1>> {
RefCell::borrow(&self.inner).get_manager()
}
}
type DataControlDeviceStatusCallback =
dyn FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static;
/// Notifies the callbacks of an event on the data device
fn notify_status_listeners(
seat: &WlSeat,
event: &DataControlDeviceEvent,
mut ddata: DispatchData,
listeners: &RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>,
) {
listeners.borrow_mut().retain(|lst| {
rc::Weak::upgrade(lst).map_or(false, |cb| {
(cb.borrow_mut())(seat.clone(), event.clone(), ddata.reborrow());
true
})
});
}
pub struct DataControlDeviceStatusListener {
_cb: Rc<RefCell<DataControlDeviceStatusCallback>>,
}
pub trait DataControlDeviceHandling {
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
where
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static;
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
where
F: FnOnce(&DataControlDevice);
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
where
F: FnMut(String, WritePipe, DispatchData) + 'static;
}
impl DataControlDeviceHandling for DataControlDeviceHandler {
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
where
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
{
let rc = Rc::new(RefCell::new(f)) as Rc<_>;
self.status_listeners.borrow_mut().push(Rc::downgrade(&rc));
DataControlDeviceStatusListener { _cb: rc }
}
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
where
F: FnOnce(&DataControlDevice),
{
RefCell::borrow(&self.inner).with_device(seat, f)
}
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
where
F: FnMut(String, WritePipe, DispatchData) + 'static,
{
RefCell::borrow(&self.inner).create_source(mime_types, callback)
}
}
pub fn listen_to_devices<E, F>(env: &Environment<E>, f: F) -> DataControlDeviceStatusListener
where
E: DataControlDeviceHandling,
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
{
env.with_inner(move |inner| DataControlDeviceHandling::listen(inner, f))
}

View File

@@ -0,0 +1,258 @@
pub mod device;
pub mod manager;
pub mod offer;
pub mod source;
use super::Env;
use crate::clients::wayland::DData;
use crate::send;
use color_eyre::Report;
use device::{DataControlDevice, DataControlDeviceEvent};
use glib::Bytes;
use manager::{DataControlDeviceHandling, DataControlDeviceStatusListener};
use smithay_client_toolkit::data_device::WritePipe;
use smithay_client_toolkit::environment::Environment;
use smithay_client_toolkit::reexports::calloop::LoopHandle;
use smithay_client_toolkit::MissingGlobal;
use source::DataControlSource;
use std::fs::File;
use std::io;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::UNIX_EPOCH;
use tokio::sync::broadcast;
use tracing::{debug, error, trace};
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_client::DispatchData;
static COUNTER: AtomicUsize = AtomicUsize::new(1);
const INTERNAL_MIME_TYPE: &str = "x-ironbar-internal";
fn get_id() -> usize {
COUNTER.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone, Eq)]
pub struct ClipboardItem {
pub id: usize,
pub value: ClipboardValue,
pub mime_type: String,
}
impl PartialEq<Self> for ClipboardItem {
fn eq(&self, other: &Self) -> bool {
self.id == other.id
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ClipboardValue {
Text(String),
Image(Bytes),
Other,
}
impl DataControlDeviceHandling for Env {
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
where
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
{
self.data_control_device.listen(f)
}
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
where
F: FnOnce(&DataControlDevice),
{
self.data_control_device.with_data_control_device(seat, f)
}
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
where
F: FnMut(String, WritePipe, DispatchData) + 'static,
{
self.data_control_device.create_source(mime_types, callback)
}
}
pub fn copy_to_clipboard<E>(
env: &Environment<E>,
seat: &WlSeat,
item: &ClipboardItem,
) -> Result<(), MissingGlobal>
where
E: DataControlDeviceHandling,
{
debug!("Copying item with id {} [{}]", item.id, item.mime_type);
trace!("Copying: {item:?}");
let item = item.clone();
env.with_inner(|env| {
let mime_types = vec![INTERNAL_MIME_TYPE.to_string(), item.mime_type];
let source = env.create_source(mime_types, move |mime_type, mut pipe, _ddata| {
debug!(
"Triggering source callback for item with id {} [{}]",
item.id, mime_type
);
// FIXME: Not working for large (buffered) values in xwayland
let bytes = match &item.value {
ClipboardValue::Text(text) => text.as_bytes(),
ClipboardValue::Image(bytes) => bytes.as_ref(),
ClipboardValue::Other => panic!(
"{:?}",
io::Error::new(
io::ErrorKind::Other,
"Attempted to copy unsupported mime type",
)
),
};
if let Err(err) = pipe.write_all(bytes) {
error!("{err:?}");
}
});
env.with_data_control_device(seat, |device| device.set_selection(&source))
})
}
#[derive(Debug)]
struct MimeType {
value: String,
category: MimeTypeCategory,
}
#[derive(Debug)]
enum MimeTypeCategory {
Text,
Image,
}
impl MimeType {
fn parse(mime_types: &[String]) -> Option<Self> {
mime_types
.iter()
.map(|s| s.to_lowercase())
.find_map(|mime_type| match mime_type.as_str() {
"text"
| "string"
| "utf8_string"
| "text/plain"
| "text/plain;charset=utf-8"
| "text/plain;charset=iso-8859-1"
| "text/plain;charset=us-ascii"
| "text/plain;charset=unicode" => Some(Self {
value: mime_type,
category: MimeTypeCategory::Text,
}),
"image/png" | "image/jpg" | "image/jpeg" | "image/tiff" | "image/bmp"
| "image/x-bmp" | "image/icon" => Some(Self {
value: mime_type,
category: MimeTypeCategory::Image,
}),
_ => None,
})
}
}
pub fn receive_offer(
event: DataControlDeviceEvent,
handle: &LoopHandle<DData>,
tx: broadcast::Sender<Arc<ClipboardItem>>,
mut ddata: DispatchData,
) {
let timestamp = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("Could not get epoch, system time is probably very wrong")
.as_nanos();
let offer = event.0;
let ddata = ddata
.get::<DData>()
.expect("Expected dispatch data to exist");
let handle2 = handle.clone();
let res = offer.with_mime_types(|mime_types| {
debug!("Offer mime types: {mime_types:?}");
if mime_types.contains(&INTERNAL_MIME_TYPE.to_string()) {
debug!("Skipping value provided by bar");
return Ok(());
}
let mime_type = MimeType::parse(mime_types);
debug!("Detected mime type: {mime_type:?}");
match mime_type {
Some(mime_type) => {
debug!("[{timestamp}] Sending clipboard read request ({mime_type:?})");
let read_pipe = offer.receive(mime_type.value.clone())?;
let source = handle.insert_source(read_pipe, move |(), file, ddata| {
debug!(
"[{timestamp}] Reading clipboard contents ({:?})",
&mime_type.category
);
match read_file(&mime_type, file) {
Ok(item) => {
send!(tx, Arc::new(item));
}
Err(err) => error!("{err:?}"),
}
if let Some(src) = ddata.offer_tokens.remove(&timestamp) {
handle2.remove(src);
}
})?;
ddata.offer_tokens.insert(timestamp, source);
}
None => {
// send an event so the clipboard module is aware it's changed
send!(
tx,
Arc::new(ClipboardItem {
id: usize::MAX,
mime_type: String::new(),
value: ClipboardValue::Other
})
);
}
}
Ok::<(), Report>(())
});
if let Err(err) = res {
error!("{err:?}");
}
}
fn read_file(mime_type: &MimeType, file: &mut File) -> io::Result<ClipboardItem> {
let value = match mime_type.category {
MimeTypeCategory::Text => {
let mut txt = String::new();
file.read_to_string(&mut txt)?;
ClipboardValue::Text(txt)
}
MimeTypeCategory::Image => {
let mut bytes = vec![];
file.read_to_end(&mut bytes)?;
let bytes = Bytes::from(&bytes);
ClipboardValue::Image(bytes)
}
};
Ok(ClipboardItem {
id: get_id(),
value,
mime_type: mime_type.value.clone(),
})
}

View File

@@ -0,0 +1,74 @@
use crate::lock;
use nix::fcntl::OFlag;
use nix::unistd::{close, pipe2};
use smithay_client_toolkit::data_device::ReadPipe;
use std::io;
use std::os::fd::FromRawFd;
use std::sync::{Arc, Mutex};
use tracing::warn;
use wayland_client::Main;
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_offer_v1::{
Event, ZwlrDataControlOfferV1,
};
#[derive(Debug, Clone)]
struct Inner {
mime_types: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct DataControlOffer {
inner: Arc<Mutex<Inner>>,
pub(crate) offer: ZwlrDataControlOfferV1,
}
impl DataControlOffer {
pub(crate) fn new(offer: &Main<ZwlrDataControlOfferV1>) -> Self {
let inner = Arc::new(Mutex::new(Inner {
mime_types: Vec::new(),
}));
{
let inner = inner.clone();
offer.quick_assign(move |_, event, _| {
let mut inner = lock!(inner);
if let Event::Offer { mime_type } = event {
inner.mime_types.push(mime_type);
}
});
}
Self {
offer: offer.detach(),
inner,
}
}
pub fn with_mime_types<F, T>(&self, f: F) -> T
where
F: FnOnce(&[String]) -> T,
{
let inner = lock!(self.inner);
f(&inner.mime_types)
}
pub fn receive(&self, mime_type: String) -> io::Result<ReadPipe> {
// create a pipe
let (readfd, writefd) = pipe2(OFlag::O_CLOEXEC)?;
self.offer.receive(mime_type, writefd);
if let Err(err) = close(writefd) {
warn!("Failed to close write pipe: {}", err);
}
Ok(unsafe { FromRawFd::from_raw_fd(readfd) })
}
}
impl Drop for DataControlOffer {
fn drop(&mut self) {
self.offer.destroy();
}
}

View File

@@ -0,0 +1,54 @@
use smithay_client_toolkit::data_device::WritePipe;
use std::os::fd::FromRawFd;
use wayland_client::{Attached, DispatchData};
use wayland_protocols::wlr::unstable::data_control::v1::client::{
zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
zwlr_data_control_source_v1::{Event, ZwlrDataControlSourceV1},
};
fn data_control_source_impl<F>(
source: &ZwlrDataControlSourceV1,
event: Event,
implem: &mut F,
ddata: DispatchData,
) where
F: FnMut(String, WritePipe, DispatchData),
{
match event {
Event::Send { mime_type, fd } => {
let pipe = unsafe { FromRawFd::from_raw_fd(fd) };
implem(mime_type, pipe, ddata);
}
Event::Cancelled => source.destroy(),
_ => unreachable!(),
}
}
pub struct DataControlSource {
pub(crate) source: ZwlrDataControlSourceV1,
}
impl DataControlSource {
pub fn new<F>(
manager: &Attached<ZwlrDataControlManagerV1>,
mime_types: Vec<String>,
mut callback: F,
) -> Self
where
F: FnMut(String, WritePipe, DispatchData) + 'static,
{
let source = manager.create_data_source();
source.quick_assign(move |source, evt, ddata| {
data_control_source_impl(&source, evt, &mut callback, ddata);
});
for mime_type in mime_types {
source.offer(mime_type);
}
Self {
source: source.detach(),
}
}
}

View File

@@ -0,0 +1,152 @@
use std::collections::HashSet;
use std::sync::{Arc, RwLock};
use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::trace;
use wayland_client::{DispatchData, Main};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{Event, ZwlrForeignToplevelHandleV1};
use crate::write_lock;
const STATE_ACTIVE: u32 = 2;
const STATE_FULLSCREEN: u32 = 3;
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn get_id() -> usize {
COUNTER.fetch_add(1, Ordering::Relaxed)
}
#[derive(Debug, Clone, Default)]
pub struct ToplevelInfo {
pub id: usize,
pub app_id: String,
pub title: String,
pub active: bool,
pub fullscreen: bool,
ready: bool,
}
impl ToplevelInfo {
fn new() -> Self {
let id = get_id();
Self {
id,
..Default::default()
}
}
}
pub struct Toplevel;
#[derive(Debug, Clone)]
pub struct ToplevelEvent {
pub toplevel: ToplevelInfo,
pub change: ToplevelChange,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ToplevelChange {
New,
Close,
Title(String),
Focus(bool),
Fullscreen(bool),
}
fn toplevel_implem<F>(event: Event, info: &mut ToplevelInfo, implem: &mut F, ddata: DispatchData)
where
F: FnMut(ToplevelEvent, DispatchData),
{
trace!("event: {event:?} (info: {info:?})");
let change = match event {
Event::AppId { app_id } => {
info.app_id = app_id;
None
}
Event::Title { title } => {
info.title = title.clone();
if info.ready {
Some(ToplevelChange::Title(title))
} else {
None
}
}
Event::State { state } => {
// state is received as a `Vec<u8>` where every 4 bytes make up a `u32`
// the u32 then represents a value in the `State` enum.
assert_eq!(state.len() % 4, 0);
let state = (0..state.len() / 4)
.map(|i| {
let slice: [u8; 4] = state[i * 4..i * 4 + 4]
.try_into()
.expect("Received invalid state length");
u32::from_le_bytes(slice)
})
.collect::<HashSet<_>>();
let new_active = state.contains(&STATE_ACTIVE);
let new_fullscreen = state.contains(&STATE_FULLSCREEN);
let change = if info.ready && new_active != info.active {
Some(ToplevelChange::Focus(new_active))
} else if info.ready && new_fullscreen != info.fullscreen {
Some(ToplevelChange::Fullscreen(new_fullscreen))
} else {
None
};
info.active = new_active;
info.fullscreen = new_fullscreen;
change
}
Event::Closed => {
if info.ready {
Some(ToplevelChange::Close)
} else {
None
}
}
Event::OutputEnter { output: _ }
| Event::OutputLeave { output: _ }
| Event::Parent { parent: _ } => None,
Event::Done => {
if info.ready || info.app_id.is_empty() {
None
} else {
info.ready = true;
Some(ToplevelChange::New)
}
}
_ => unreachable!(),
};
if let Some(change) = change {
let event = ToplevelEvent {
change,
toplevel: info.clone(),
};
implem(event, ddata);
}
}
impl Toplevel {
pub fn init<F>(handle: &Main<ZwlrForeignToplevelHandleV1>, mut callback: F) -> Self
where
F: FnMut(ToplevelEvent, DispatchData) + 'static,
{
let inner = Arc::new(RwLock::new(ToplevelInfo::new()));
handle.quick_assign(move |_handle, event, ddata| {
let mut inner = write_lock!(inner);
toplevel_implem(event, &mut inner, &mut callback, ddata);
});
Self
}
}

View File

@@ -0,0 +1,163 @@
use super::handle::{Toplevel, ToplevelEvent};
use crate::wayland::LazyGlobal;
use smithay_client_toolkit::environment::{Environment, GlobalHandler};
use std::cell::RefCell;
use std::rc::{self, Rc};
use tracing::warn;
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::{Attached, DispatchData};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1},
};
struct ToplevelHandlerInner {
manager: LazyGlobal<ZwlrForeignToplevelManagerV1>,
registry: Option<Attached<WlRegistry>>,
toplevels: Vec<Toplevel>,
}
impl ToplevelHandlerInner {
const fn new() -> Self {
let toplevels = vec![];
Self {
registry: None,
manager: LazyGlobal::Unknown,
toplevels,
}
}
}
pub struct ToplevelHandler {
inner: Rc<RefCell<ToplevelHandlerInner>>,
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<ToplevelStatusCallback>>>>>,
}
impl ToplevelHandler {
pub fn init() -> Self {
let inner = Rc::new(RefCell::new(ToplevelHandlerInner::new()));
Self {
inner,
status_listeners: Rc::new(RefCell::new(Vec::new())),
}
}
}
impl GlobalHandler<ZwlrForeignToplevelManagerV1> for ToplevelHandler {
fn created(
&mut self,
registry: Attached<WlRegistry>,
id: u32,
version: u32,
_ddata: DispatchData,
) {
let mut inner = RefCell::borrow_mut(&self.inner);
if inner.registry.is_none() {
inner.registry = Some(registry);
}
if matches!(inner.manager, LazyGlobal::Unknown) {
inner.manager = LazyGlobal::Seen { id, version }
} else {
warn!(
"Compositor advertised zwlr_foreign_toplevel_manager_v1 multiple times, ignoring."
);
}
}
fn get(&self) -> Option<Attached<ZwlrForeignToplevelManagerV1>> {
let mut inner = RefCell::borrow_mut(&self.inner);
match inner.manager {
LazyGlobal::Bound(ref mgr) => Some(mgr.clone()),
LazyGlobal::Unknown => None,
LazyGlobal::Seen { id, version } => {
let registry = inner.registry.as_ref().expect("Failed to get registry");
// current max protocol version = 3
let version = std::cmp::min(version, 3);
let manager = registry.bind::<ZwlrForeignToplevelManagerV1>(version, id);
{
let inner = self.inner.clone();
let status_listeners = self.status_listeners.clone();
manager.quick_assign(move |_, event, _ddata| {
let mut inner = RefCell::borrow_mut(&inner);
let status_listeners = status_listeners.clone();
match event {
zwlr_foreign_toplevel_manager_v1::Event::Toplevel {
toplevel: handle,
} => {
let toplevel =
Toplevel::init(&handle.clone(), move |event, ddata| {
notify_status_listeners(
&handle,
&event,
ddata,
&status_listeners,
);
});
inner.toplevels.push(toplevel);
}
zwlr_foreign_toplevel_manager_v1::Event::Finished => {}
_ => unreachable!(),
}
});
}
inner.manager = LazyGlobal::Bound((*manager).clone());
Some((*manager).clone())
}
}
}
}
type ToplevelStatusCallback =
dyn FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static;
/// Notifies the callbacks of an event on the toplevel
fn notify_status_listeners(
toplevel: &ZwlrForeignToplevelHandleV1,
event: &ToplevelEvent,
mut ddata: DispatchData,
listeners: &RefCell<Vec<rc::Weak<RefCell<ToplevelStatusCallback>>>>,
) {
listeners.borrow_mut().retain(|lst| {
rc::Weak::upgrade(lst).map_or(false, |cb| {
(cb.borrow_mut())(toplevel.clone(), event.clone(), ddata.reborrow());
true
})
});
}
pub struct ToplevelStatusListener {
_cb: Rc<RefCell<ToplevelStatusCallback>>,
}
pub trait ToplevelHandling {
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
where
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static;
}
impl ToplevelHandling for ToplevelHandler {
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
where
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
{
let rc = Rc::new(RefCell::new(f)) as Rc<_>;
self.status_listeners.borrow_mut().push(Rc::downgrade(&rc));
ToplevelStatusListener { _cb: rc }
}
}
pub fn listen_for_toplevels<E, F>(env: &Environment<E>, f: F) -> ToplevelStatusListener
where
E: ToplevelHandling,
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
{
env.with_inner(move |inner| ToplevelHandling::listen(inner, f))
}

View File

@@ -0,0 +1,39 @@
use std::sync::RwLock;
use indexmap::IndexMap;
use tokio::sync::broadcast::Sender;
use tracing::trace;
use super::Env;
use handle::{ToplevelEvent, ToplevelChange, ToplevelInfo};
use manager::{ToplevelHandling, ToplevelStatusListener};
use wayland_client::DispatchData;
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1;
use crate::{send, write_lock};
pub mod handle;
pub mod manager;
impl ToplevelHandling for Env {
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
where
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
{
self.toplevel.listen(f)
}
}
pub fn update_toplevels(
toplevels: &RwLock<IndexMap<usize, (ToplevelInfo, ZwlrForeignToplevelHandleV1)>>,
handle: ZwlrForeignToplevelHandleV1,
event: ToplevelEvent,
tx: &Sender<ToplevelEvent>,
) {
trace!("Received toplevel event: {:?}", event);
if event.change == ToplevelChange::Close {
write_lock!(toplevels).remove(&event.toplevel.id);
} else {
write_lock!(toplevels).insert(event.toplevel.id, (event.toplevel.clone(), handle));
}
send!(tx, event);
}

View File

@@ -1,131 +0,0 @@
use serde::Serialize;
use std::slice::{Iter, IterMut};
/// An ordered map.
/// Internally this is just two vectors -
/// one for keys and one for values.
#[derive(Debug, Clone, Serialize)]
pub struct Collection<TKey, TData> {
keys: Vec<TKey>,
values: Vec<TData>,
}
impl<TKey: PartialEq, TData> Collection<TKey, TData> {
pub const fn new() -> Self {
Self {
keys: vec![],
values: vec![],
}
}
pub fn insert(&mut self, key: TKey, value: TData) {
self.keys.push(key);
self.values.push(value);
assert_eq!(self.keys.len(), self.values.len());
}
pub fn get(&self, key: &TKey) -> Option<&TData> {
let index = self.keys.iter().position(|k| k == key);
match index {
Some(index) => self.values.get(index),
None => None,
}
}
pub fn get_mut(&mut self, key: &TKey) -> Option<&mut TData> {
let index = self.keys.iter().position(|k| k == key);
match index {
Some(index) => self.values.get_mut(index),
None => None,
}
}
pub fn remove(&mut self, key: &TKey) -> Option<TData> {
assert_eq!(self.keys.len(), self.values.len());
let index = self.keys.iter().position(|k| k == key);
if let Some(index) = index {
self.keys.remove(index);
Some(self.values.remove(index))
} else {
None
}
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn first(&self) -> Option<&TData> {
self.values.first()
}
pub fn as_slice(&self) -> &[TData] {
self.values.as_slice()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn iter(&self) -> Iter<'_, TData> {
self.values.iter()
}
pub fn iter_mut(&mut self) -> IterMut<'_, TData> {
self.values.iter_mut()
}
}
impl<TKey: PartialEq, TData> From<(TKey, TData)> for Collection<TKey, TData> {
fn from((key, value): (TKey, TData)) -> Self {
let mut collection = Self::new();
collection.insert(key, value);
collection
}
}
impl<TKey: PartialEq, TData> FromIterator<(TKey, TData)> for Collection<TKey, TData> {
fn from_iter<T: IntoIterator<Item = (TKey, TData)>>(iter: T) -> Self {
let mut collection = Self::new();
for (key, value) in iter {
collection.insert(key, value);
}
collection
}
}
impl<'a, TKey: PartialEq, TData> IntoIterator for &'a Collection<TKey, TData> {
type Item = &'a TData;
type IntoIter = CollectionIntoIterator<'a, TKey, TData>;
fn into_iter(self) -> Self::IntoIter {
CollectionIntoIterator {
collection: self,
index: 0,
}
}
}
pub struct CollectionIntoIterator<'a, TKey, TData> {
collection: &'a Collection<TKey, TData>,
index: usize,
}
impl<'a, TKey: PartialEq, TData> Iterator for CollectionIntoIterator<'a, TKey, TData> {
type Item = &'a TData;
fn next(&mut self) -> Option<Self::Item> {
let res = self.collection.values.get(self.index);
self.index += 1;
res
}
}
impl<TKey: PartialEq, TData> Default for Collection<TKey, TData> {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,123 +0,0 @@
use crate::modules::clock::ClockModule;
use crate::modules::focused::FocusedModule;
use crate::modules::launcher::LauncherModule;
use crate::modules::mpd::MpdModule;
use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule;
use crate::modules::workspaces::WorkspacesModule;
use dirs::config_dir;
use serde::Deserialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::{env, fs};
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "kebab-case")]
pub enum ModuleConfig {
Clock(ClockModule),
Mpd(MpdModule),
Tray(TrayModule),
Workspaces(WorkspacesModule),
SysInfo(SysInfoModule),
Launcher(LauncherModule),
Script(ScriptModule),
Focused(FocusedModule),
}
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum MonitorConfig {
Single(Config),
Multiple(Vec<Config>),
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
pub enum BarPosition {
Top,
Bottom,
}
impl Default for BarPosition {
fn default() -> Self {
Self::Bottom
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct Config {
#[serde(default = "default_bar_position")]
pub position: BarPosition,
#[serde(default = "default_bar_height")]
pub height: i32,
pub left: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>,
pub right: Option<Vec<ModuleConfig>>,
pub monitors: Option<HashMap<String, MonitorConfig>>,
}
const fn default_bar_position() -> BarPosition {
BarPosition::Bottom
}
const fn default_bar_height() -> i32 {
42
}
impl Config {
pub fn load() -> Option<Self> {
if let Ok(config_path) = env::var("IRONBAR_CONFIG") {
let path = PathBuf::from(config_path);
Self::load_file(
&path,
path.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default(),
)
} else {
let config_dir = config_dir().expect("Failed to locate user config dir");
let extensions = vec!["json", "toml", "yaml", "yml", "corn"];
extensions.into_iter().find_map(|extension| {
let full_path = config_dir
.join("ironbar")
.join(format!("config.{extension}"));
Self::load_file(&full_path, extension)
})
}
}
fn load_file(path: &Path, extension: &str) -> Option<Self> {
if path.exists() {
let file = fs::read(path).expect("Failed to read config file");
Some(match extension {
"json" => serde_json::from_slice(&file).expect("Invalid JSON config"),
"toml" => toml::from_slice(&file).expect("Invalid TOML config"),
"yaml" | "yml" => serde_yaml::from_slice(&file).expect("Invalid YAML config"),
"corn" => {
// corn doesn't support deserialization yet
// so serialize the interpreted result then deserialize that
let file = String::from_utf8(file).expect("Config file contains invalid UTF-8");
let config = cornfig::parse(&file).expect("Invalid corn config").value;
serde_json::from_str(&serde_json::to_string(&config).unwrap()).unwrap()
}
_ => unreachable!(),
})
} else {
None
}
}
}
pub const fn default_false() -> bool {
false
}
pub const fn default_true() -> bool {
true
}

58
src/config/impl.rs Normal file
View File

@@ -0,0 +1,58 @@
use super::{BarPosition, Config, MonitorConfig};
use color_eyre::{Help, Report};
use gtk::Orientation;
use serde::{Deserialize, Deserializer};
// Manually implement for better untagged enum error handling:
// currently open pr: https://github.com/serde-rs/serde/pull/1544
impl<'de> Deserialize<'de> for MonitorConfig {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let content =
<serde::__private::de::Content as serde::Deserialize>::deserialize(deserializer)?;
match <Config as serde::Deserialize>::deserialize(
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
) {
Ok(config) => Ok(Self::Single(config)),
Err(outer) => match <Vec<Config> as serde::Deserialize>::deserialize(
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
) {
Ok(config) => Ok(Self::Multiple(config)),
Err(inner) => {
let report = Report::msg(format!(" multi-bar (c): {inner}").replace("An error occurred when deserializing: ", ""))
.wrap_err(format!("single-bar (b): {outer}").replace("An error occurred when deserializing: ", ""))
.wrap_err("An invalid config was found. The following errors were encountered:")
.note("Both the single-bar (type b / error 1) and multi-bar (type c / error 2) config variants were tried. You can likely ignore whichever of these is not relevant to you.")
.suggestion("Please see https://github.com/JakeStanger/ironbar/wiki/configuration-guide#2-pick-your-use-case for more info on the above");
Err(serde::de::Error::custom(format!("{report:?}")))
}
},
}
}
}
impl BarPosition {
/// Gets the orientation the bar and widgets should use
/// based on this position.
pub fn get_orientation(self) -> Orientation {
if self == Self::Top || self == Self::Bottom {
Orientation::Horizontal
} else {
Orientation::Vertical
}
}
/// Gets the angle that label text should be displayed at
/// based on this position.
pub const fn get_angle(self) -> f64 {
match self {
Self::Top | Self::Bottom => 0.0,
Self::Left => 90.0,
Self::Right => 270.0,
}
}
}

124
src/config/mod.rs Normal file
View File

@@ -0,0 +1,124 @@
mod r#impl;
mod truncate;
#[cfg(feature = "clipboard")]
use crate::modules::clipboard::ClipboardModule;
#[cfg(feature = "clock")]
use crate::modules::clock::ClockModule;
use crate::modules::custom::CustomModule;
use crate::modules::focused::FocusedModule;
use crate::modules::launcher::LauncherModule;
#[cfg(feature = "music")]
use crate::modules::music::MusicModule;
use crate::modules::script::ScriptModule;
#[cfg(feature = "sys_info")]
use crate::modules::sysinfo::SysInfoModule;
#[cfg(feature = "tray")]
use crate::modules::tray::TrayModule;
#[cfg(feature = "workspaces")]
use crate::modules::workspaces::WorkspacesModule;
use crate::script::ScriptInput;
use serde::Deserialize;
use std::collections::HashMap;
pub use self::truncate::{EllipsizeMode, TruncateMode};
#[derive(Debug, Deserialize, Clone)]
pub struct CommonConfig {
pub show_if: Option<ScriptInput>,
pub on_click_left: Option<ScriptInput>,
pub on_click_right: Option<ScriptInput>,
pub on_click_middle: Option<ScriptInput>,
pub on_scroll_up: Option<ScriptInput>,
pub on_scroll_down: Option<ScriptInput>,
pub tooltip: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ModuleConfig {
#[cfg(feature = "clock")]
Clipboard(Box<ClipboardModule>),
#[cfg(feature = "clock")]
Clock(Box<ClockModule>),
Custom(Box<CustomModule>),
Focused(Box<FocusedModule>),
Launcher(Box<LauncherModule>),
#[cfg(feature = "music")]
Music(Box<MusicModule>),
Script(Box<ScriptModule>),
#[cfg(feature = "sys_info")]
SysInfo(Box<SysInfoModule>),
#[cfg(feature = "tray")]
Tray(Box<TrayModule>),
#[cfg(feature = "workspaces")]
Workspaces(Box<WorkspacesModule>),
}
#[derive(Debug, Clone)]
pub enum MonitorConfig {
Single(Config),
Multiple(Vec<Config>),
}
#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BarPosition {
Top,
Bottom,
Left,
Right,
}
impl Default for BarPosition {
fn default() -> Self {
Self::Bottom
}
}
#[derive(Debug, Default, Deserialize, Copy, Clone, PartialEq, Eq)]
pub struct MarginConfig {
#[serde(default)]
pub bottom: i32,
#[serde(default)]
pub left: i32,
#[serde(default)]
pub right: i32,
#[serde(default)]
pub top: i32,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default)]
pub position: BarPosition,
#[serde(default = "default_true")]
pub anchor_to_edges: bool,
#[serde(default = "default_bar_height")]
pub height: i32,
#[serde(default)]
pub margin: MarginConfig,
/// GTK icon theme to use.
pub icon_theme: Option<String>,
pub start: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>,
pub end: Option<Vec<ModuleConfig>>,
pub monitors: Option<HashMap<String, MonitorConfig>>,
}
const fn default_bar_height() -> i32 {
42
}
pub const fn default_false() -> bool {
false
}
pub const fn default_true() -> bool {
true
}

66
src/config/truncate.rs Normal file
View File

@@ -0,0 +1,66 @@
use gtk::pango::EllipsizeMode as GtkEllipsizeMode;
use gtk::prelude::*;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum EllipsizeMode {
Start,
Middle,
End,
}
impl From<EllipsizeMode> for GtkEllipsizeMode {
fn from(value: EllipsizeMode) -> Self {
match value {
EllipsizeMode::Start => Self::Start,
EllipsizeMode::Middle => Self::Middle,
EllipsizeMode::End => Self::End,
}
}
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(untagged)]
pub enum TruncateMode {
Auto(EllipsizeMode),
Length {
mode: EllipsizeMode,
length: Option<i32>,
max_length: Option<i32>,
},
}
impl TruncateMode {
const fn mode(&self) -> EllipsizeMode {
match self {
Self::Length { mode, .. } | Self::Auto(mode) => *mode,
}
}
const fn length(&self) -> Option<i32> {
match self {
Self::Auto(_) => None,
Self::Length { length, .. } => *length,
}
}
const fn max_length(&self) -> Option<i32> {
match self {
Self::Auto(_) => None,
Self::Length { max_length, .. } => *max_length,
}
}
pub fn truncate_label(&self, label: &gtk::Label) {
label.set_ellipsize(self.mode().into());
if let Some(length) = self.length() {
label.set_width_chars(length);
}
if let Some(length) = self.max_length() {
label.set_max_width_chars(length);
}
}
}

74
src/desktop_file.rs Normal file
View File

@@ -0,0 +1,74 @@
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::BufRead;
use std::path::PathBuf;
use walkdir::WalkDir;
/// Gets directories that should contain `.desktop` files
/// and exist on the filesystem.
fn find_application_dirs() -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
let user_dir = dirs::data_local_dir();
if let Some(mut user_dir) = user_dir {
user_dir.push("applications");
dirs.push(user_dir);
}
dirs.into_iter().filter(|dir| dir.exists()).collect()
}
/// Attempts to locate a `.desktop` file for an app id
/// (or app class).
///
/// A simple case-insensitive check is performed on filename == `app_id`.
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
let dirs = find_application_dirs();
for dir in dirs {
let mut walker = WalkDir::new(dir).max_depth(5).into_iter();
let entry = walker.find(|entry| match entry {
Ok(entry) => {
let file_name = entry.file_name().to_string_lossy().to_lowercase();
let test_name = format!("{}.desktop", app_id.to_lowercase());
file_name == test_name
}
_ => false,
});
if let Some(Ok(entry)) = entry {
let path = entry.path().to_owned();
return Some(path);
}
}
None
}
/// Parses a desktop file into a flat hashmap of keys/values.
fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
let file = File::open(path)?;
let lines = io::BufReader::new(file).lines();
let mut map = HashMap::new();
for line in lines.flatten() {
if let Some((key, value)) = line.split_once('=') {
map.insert(key.to_string(), value.to_string());
}
}
Ok(map)
}
/// Attempts to get the icon name from the app's `.desktop` file.
pub fn get_desktop_icon_name(app_id: &str) -> Option<String> {
find_desktop_file(app_id).and_then(|file| {
let map = parse_desktop_file(file);
map.map_or(None, |map| {
map.get("Icon").map(std::string::ToString::to_string)
})
})
}

125
src/dynamic_string.rs Normal file
View File

@@ -0,0 +1,125 @@
use crate::script::{OutputStream, Script};
use crate::{lock, send};
use gtk::prelude::*;
use std::sync::{Arc, Mutex};
use tokio::spawn;
#[derive(Debug)]
enum DynamicStringSegment {
Static(String),
Dynamic(Script),
}
pub struct DynamicString;
impl DynamicString {
pub fn new<F>(input: &str, f: F) -> Self
where
F: FnMut(String) -> Continue + 'static,
{
let mut segments = vec![];
let mut chars = input.chars().collect::<Vec<_>>();
while !chars.is_empty() {
let char = &chars[..=1];
let (token, skip) = if let ['{', '{'] = char {
const SKIP_BRACKETS: usize = 4;
let str = chars
.iter()
.skip(2)
.enumerate()
.take_while(|(i, &c)| c != '}' && chars[i + 1] != '}')
.map(|(_, c)| c)
.collect::<String>();
let len = str.len();
(
DynamicStringSegment::Dynamic(Script::from(str.as_str())),
len + SKIP_BRACKETS,
)
} else {
let str = chars
.iter()
.enumerate()
.take_while(|(i, &c)| !(c == '{' && chars[i + 1] == '{'))
.map(|(_, c)| c)
.collect::<String>();
let len = str.len();
(DynamicStringSegment::Static(str), len)
};
assert_ne!(skip, 0);
segments.push(token);
chars.drain(..skip);
}
let label_parts = Arc::new(Mutex::new(Vec::new()));
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
for (i, segment) in segments.into_iter().enumerate() {
match segment {
DynamicStringSegment::Static(str) => {
lock!(label_parts).push(str);
}
DynamicStringSegment::Dynamic(script) => {
let tx = tx.clone();
let label_parts = label_parts.clone();
// insert blank value to preserve segment order
lock!(label_parts).push(String::new());
spawn(async move {
script
.run(|(out, _)| {
if let OutputStream::Stdout(out) = out {
let mut label_parts = lock!(label_parts);
let _ = std::mem::replace(&mut label_parts[i], out);
let string = label_parts.join("");
send!(tx, string);
}
})
.await;
});
}
}
}
// initialize
{
let label_parts = lock!(label_parts).join("");
send!(tx, label_parts);
}
rx.attach(None, f);
Self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn test() {
// TODO: see if we can run gtk tests in ci
if gtk::init().is_ok() {
let label = gtk::Label::new(None);
DynamicString::new(
"Uptime: {{1000:uptime -p | cut -d ' ' -f2-}}",
move |string| {
label.set_label(&string);
Continue(true)
},
);
}
}
}

13
src/error.rs Normal file
View File

@@ -0,0 +1,13 @@
#[repr(i32)]
pub enum ExitCode {
GtkDisplay = 1,
CreateBars = 2,
Config = 3,
}
pub const ERR_OUTPUTS: &str = "GTK and Sway are reporting a different set of outputs - this is a severe bug and should never happen";
pub const ERR_MUTEX_LOCK: &str = "Failed to get lock on Mutex";
pub const ERR_READ_LOCK: &str = "Failed to get read lock";
pub const ERR_WRITE_LOCK: &str = "Failed to get write lock";
pub const ERR_CHANNEL_SEND: &str = "Failed to send message to channel";
pub const ERR_CHANNEL_RECV: &str = "Failed to receive message from channel";

View File

@@ -1,142 +0,0 @@
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme};
use std::collections::HashMap;
use std::fs::File;
use std::io;
use std::io::BufRead;
use std::path::PathBuf;
use walkdir::WalkDir;
/// Gets directories that should contain `.desktop` files
/// and exist on the filesystem.
fn find_application_dirs() -> Vec<PathBuf> {
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
let user_dir = dirs::data_local_dir();
if let Some(mut user_dir) = user_dir {
user_dir.push("applications");
dirs.push(user_dir);
}
dirs.into_iter().filter(|dir| dir.exists()).collect()
}
/// Attempts to locate a `.desktop` file for an app id
/// (or app class).
///
/// A simple case-insensitive check is performed on filename == `app_id`.
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
let dirs = find_application_dirs();
for dir in dirs {
let mut walker = WalkDir::new(dir).max_depth(5).into_iter();
let entry = walker.find(|entry| match entry {
Ok(entry) => {
let file_name = entry.file_name().to_string_lossy().to_lowercase();
let test_name = format!("{}.desktop", app_id.to_lowercase());
file_name == test_name
}
_ => false,
});
if let Some(Ok(entry)) = entry {
let path = entry.path().to_owned();
return Some(path);
}
}
None
}
/// Parses a desktop file into a flat hashmap of keys/values.
fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
let file = File::open(path)?;
let lines = io::BufReader::new(file).lines();
let mut map = HashMap::new();
for line in lines.flatten() {
let is_pair = line.contains('=');
if is_pair {
let (key, value) = line.split_once('=').unwrap();
map.insert(key.to_string(), value.to_string());
}
}
Ok(map)
}
/// Attempts to get the icon name from the app's `.desktop` file.
fn get_desktop_icon_name(app_id: &str) -> Option<String> {
match find_desktop_file(app_id) {
Some(file) => {
let map = parse_desktop_file(file);
match map {
Ok(map) => map.get("Icon").map(std::string::ToString::to_string),
Err(_) => None,
}
}
None => None,
}
}
enum IconLocation {
Theme(String),
File(PathBuf),
}
fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconLocation> {
let has_icon = theme
.lookup_icon(app_id, size, IconLookupFlags::empty())
.is_some();
if has_icon {
return Some(IconLocation::Theme(app_id.to_string()));
}
let is_steam_game = app_id.starts_with("steam_app_");
if is_steam_game {
let steam_id: String = app_id.chars().skip("steam_app_".len()).collect();
let home_dir = dirs::data_dir().unwrap();
let path = home_dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{}.png",
steam_id
));
return Some(IconLocation::File(path));
}
let icon_name = get_desktop_icon_name(app_id);
if let Some(icon_name) = icon_name {
let is_path = PathBuf::from(&icon_name).exists();
return if is_path {
Some(IconLocation::File(PathBuf::from(icon_name)))
} else {
return Some(IconLocation::Theme(icon_name));
};
}
None
}
/// Gets the icon associated with an app.
pub fn get_icon(theme: &IconTheme, app_id: &str, size: i32) -> Option<Pixbuf> {
let icon_location = get_icon_location(theme, app_id, size);
match icon_location {
Some(IconLocation::Theme(icon_name)) => {
let icon = theme.load_icon(&icon_name, size, IconLookupFlags::FORCE_SIZE);
match icon {
Ok(icon) => icon,
Err(_) => None,
}
}
Some(IconLocation::File(path)) => Pixbuf::from_file_at_scale(path, size, size, true).ok(),
None => None,
}
}

56
src/image/gtk.rs Normal file
View File

@@ -0,0 +1,56 @@
use super::ImageProvider;
use gtk::prelude::*;
use gtk::{Button, IconTheme, Image, Label, Orientation};
use tracing::error;
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button {
let button = Button::new();
if ImageProvider::is_definitely_image_input(input) {
let image = Image::new();
image.set_widget_name("image");
match ImageProvider::parse(input, icon_theme, size)
.and_then(|provider| provider.load_into_image(image.clone()))
{
Ok(_) => {
button.set_image(Some(&image));
button.set_always_show_image(true);
}
Err(err) => {
error!("{err:?}");
button.set_label(input);
}
}
} else {
button.set_label(input);
}
button
}
#[cfg(feature = "music")]
pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Box {
let container = gtk::Box::new(Orientation::Horizontal, 0);
if ImageProvider::is_definitely_image_input(input) {
let image = Image::new();
image.set_widget_name("image");
container.add(&image);
if let Err(err) = ImageProvider::parse(input, icon_theme, size)
.and_then(|provider| provider.load_into_image(image))
{
error!("{err:?}");
}
} else {
let label = Label::new(Some(input));
label.set_widget_name("label");
container.add(&label);
}
container
}

7
src/image/mod.rs Normal file
View File

@@ -0,0 +1,7 @@
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
mod gtk;
mod provider;
#[cfg(any(feature = "music", feature = "workspaces"))]
pub use self::gtk::*;
pub use provider::ImageProvider;

199
src/image/provider.rs Normal file
View File

@@ -0,0 +1,199 @@
use crate::desktop_file::get_desktop_icon_name;
use cfg_if::cfg_if;
use color_eyre::{Help, Report, Result};
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme};
use std::path::{Path, PathBuf};
cfg_if!(
if #[cfg(feature = "http")] {
use crate::send;
use gtk::gio::{Cancellable, MemoryInputStream};
use tokio::spawn;
use tracing::error;
}
);
#[derive(Debug)]
enum ImageLocation<'a> {
Icon {
name: String,
theme: &'a IconTheme,
},
Local(PathBuf),
Steam(String),
#[cfg(feature = "http")]
Remote(reqwest::Url),
}
pub struct ImageProvider<'a> {
location: ImageLocation<'a>,
size: i32,
}
impl<'a> ImageProvider<'a> {
/// Attempts to parse the image input to find its location.
/// Errors if no valid location type can be found.
///
/// Note this checks that icons exist in theme, or files exist on disk
/// but no other check is performed.
pub fn parse(input: &str, theme: &'a IconTheme, size: i32) -> Result<Self> {
let location = Self::get_location(input, theme, size)?;
Ok(Self { location, size })
}
/// Returns true if the input starts with a prefix
/// that is supported by the parser
/// (ie the parser would not fallback to checking the input).
#[cfg(any(feature = "music", feature = "workspaces"))]
pub fn is_definitely_image_input(input: &str) -> bool {
input.starts_with("icon:")
|| input.starts_with("file://")
|| input.starts_with("http://")
|| input.starts_with("https://")
}
fn get_location(input: &str, theme: &'a IconTheme, size: i32) -> Result<ImageLocation<'a>> {
let (input_type, input_name) = input
.split_once(':')
.map_or((None, input), |(t, n)| (Some(t), n));
match input_type {
Some(input_type) if input_type == "icon" => Ok(ImageLocation::Icon {
name: input_name.to_string(),
theme,
}),
Some(input_type) if input_type == "file" => Ok(ImageLocation::Local(PathBuf::from(
input_name[2..].to_string(),
))),
#[cfg(feature = "http")]
Some(input_type) if input_type == "http" || input_type == "https" => {
Ok(ImageLocation::Remote(input.parse()?))
}
None if input.starts_with("steam_app_") => Ok(ImageLocation::Steam(
input_name.chars().skip("steam_app_".len()).collect(),
)),
None if theme
.lookup_icon(input, size, IconLookupFlags::empty())
.is_some() =>
{
Ok(ImageLocation::Icon {
name: input_name.to_string(),
theme,
})
}
Some(input_type) => Err(Report::msg(format!("Unsupported image type: {input_type}"))
.note("You may need to recompile with support if available")),
None if PathBuf::from(input_name).is_file() => {
Ok(ImageLocation::Local(PathBuf::from(input_name)))
}
None => get_desktop_icon_name(input_name).map_or_else(
|| Err(Report::msg(format!("Unknown image type: '{input}'"))),
|input| Self::get_location(&input, theme, size),
),
}
}
/// Attempts to fetch the image from the location
/// and load it into the provided `GTK::Image` widget.
pub fn load_into_image(&self, image: gtk::Image) -> Result<()> {
// handle remote locations async to avoid blocking UI thread while downloading
#[cfg(feature = "http")]
if let ImageLocation::Remote(url) = &self.location {
let url = url.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
let bytes = Self::get_bytes_from_http(url).await;
if let Ok(bytes) = bytes {
send!(tx, bytes);
}
});
{
let size = self.size;
rx.attach(None, move |bytes| {
let stream = MemoryInputStream::from_bytes(&bytes);
let pixbuf = Pixbuf::from_stream_at_scale(
&stream,
size,
size,
true,
Some(&Cancellable::new()),
);
match pixbuf {
Ok(pixbuf) => image.set_pixbuf(Some(&pixbuf)),
Err(err) => error!("{err:?}"),
}
Continue(false)
});
}
} else {
self.load_into_image_sync(&image)?;
};
#[cfg(not(feature = "http"))]
self.load_into_image_sync(&image)?;
Ok(())
}
fn load_into_image_sync(&self, image: &gtk::Image) -> Result<()> {
let pixbuf = match &self.location {
ImageLocation::Icon { name, theme } => self.get_from_icon(name, theme),
ImageLocation::Local(path) => self.get_from_file(path),
ImageLocation::Steam(steam_id) => self.get_from_steam_id(steam_id),
#[cfg(feature = "http")]
_ => unreachable!(), // handled above
}?;
image.set_pixbuf(Some(&pixbuf));
Ok(())
}
/// Attempts to get a `Pixbuf` from the GTK icon theme.
fn get_from_icon(&self, name: &str, theme: &IconTheme) -> Result<Pixbuf> {
let pixbuf = match theme.lookup_icon(name, self.size, IconLookupFlags::empty()) {
Some(_) => theme.load_icon(name, self.size, IconLookupFlags::FORCE_SIZE),
None => Ok(None),
}?;
pixbuf.map_or_else(
|| Err(Report::msg("Icon theme does not contain icon '{name}'")),
Ok,
)
}
/// Attempts to get a `Pixbuf` from a local file.
fn get_from_file(&self, path: &Path) -> Result<Pixbuf> {
let pixbuf = Pixbuf::from_file_at_scale(path, self.size, self.size, true)?;
Ok(pixbuf)
}
/// Attempts to get a `Pixbuf` from a local file,
/// using the Steam game ID to look it up.
fn get_from_steam_id(&self, steam_id: &str) -> Result<Pixbuf> {
// TODO: Can we load this from icon theme with app id `steam_icon_{}`?
let path = dirs::data_dir().map_or_else(
|| Err(Report::msg("Missing XDG data dir")),
|dir| {
Ok(dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
)))
},
)?;
self.get_from_file(&path)
}
/// Attempts to get `Bytes` from an HTTP resource asynchronously.
#[cfg(feature = "http")]
async fn get_bytes_from_http(url: reqwest::Url) -> Result<glib::Bytes> {
let bytes = reqwest::get(url).await?.bytes().await?;
Ok(glib::Bytes::from_owned(bytes))
}
}

86
src/logging.rs Normal file
View File

@@ -0,0 +1,86 @@
use color_eyre::Result;
use dirs::data_dir;
use std::{env, panic};
use strip_ansi_escapes::Writer;
use tracing::error;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::{Layer, MakeWriter};
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
struct MakeFileWriter {
file_writer: NonBlocking,
}
impl MakeFileWriter {
const fn new(file_writer: NonBlocking) -> Self {
Self { file_writer }
}
}
impl<'a> MakeWriter<'a> for MakeFileWriter {
type Writer = Writer<NonBlocking>;
fn make_writer(&'a self) -> Self::Writer {
Writer::new(self.file_writer.clone())
}
}
pub fn install_logging() -> Result<WorkerGuard> {
// Disable backtraces by default
if env::var("RUST_LIB_BACKTRACE").is_err() {
env::set_var("RUST_LIB_BACKTRACE", "0");
}
// keep guard in scope
// otherwise file logging drops
let guard = install_tracing()?;
let hook_builder = color_eyre::config::HookBuilder::default();
let (panic_hook, eyre_hook) = hook_builder.into_hooks();
eyre_hook.install()?;
// custom hook allows tracing_appender to capture panics
panic::set_hook(Box::new(move |panic_info| {
error!("{}", panic_hook.panic_report(panic_info));
}));
Ok(guard)
}
/// Installs tracing into the current application.
///
/// The returned `WorkerGuard` must remain in scope
/// for the lifetime of the application for logging to file to work.
fn install_tracing() -> Result<WorkerGuard> {
const DEFAULT_LOG: &str = "info";
const DEFAULT_FILE_LOG: &str = "warn";
let fmt_layer = fmt::layer().with_target(true);
let filter_layer =
EnvFilter::try_from_env("IRONBAR_LOG").or_else(|_| EnvFilter::try_new(DEFAULT_LOG))?;
let file_filter_layer = EnvFilter::try_from_env("IRONBAR_FILE_LOG")
.or_else(|_| EnvFilter::try_new(DEFAULT_FILE_LOG))?;
let log_path = data_dir().unwrap_or(env::current_dir()?).join("ironbar");
let appender = tracing_appender::rolling::never(log_path, "error.log");
let (file_writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.with(ErrorLayer::default())
.with(
Layer::default()
.with_writer(MakeFileWriter::new(file_writer))
.with_ansi(false)
.with_filter(file_filter_layer),
)
.init();
Ok(guard)
}

89
src/macros.rs Normal file
View File

@@ -0,0 +1,89 @@
/// Sends a message on an asynchronous `Sender` using `send()`
/// Panics if the message cannot be sent.
///
/// Usage:
///
/// ```rs
/// send_async!(tx, "my message");
/// ```
#[macro_export]
macro_rules! send_async {
($tx:expr, $msg:expr) => {
$tx.send($msg).await.expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Sends a message on an synchronous `Sender` using `send()`
/// Panics if the message cannot be sent.
///
/// Usage:
///
/// ```rs
/// send!(tx, "my message");
/// ```
#[macro_export]
macro_rules! send {
($tx:expr, $msg:expr) => {
$tx.send($msg).expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Sends a message on an synchronous `Sender` using `try_send()`
/// Panics if the message cannot be sent.
///
/// Usage:
///
/// ```rs
/// try_send!(tx, "my message");
/// ```
#[macro_export]
macro_rules! try_send {
($tx:expr, $msg:expr) => {
$tx.try_send($msg).expect($crate::error::ERR_CHANNEL_SEND)
};
}
/// Locks a `Mutex`.
/// Panics if the `Mutex` cannot be locked.
///
/// Usage:
///
/// ```rs
/// let mut val = lock!(my_mutex);
/// ```
#[macro_export]
macro_rules! lock {
($mutex:expr) => {
$mutex.lock().expect($crate::error::ERR_MUTEX_LOCK)
};
}
/// Gets a read lock on a `RwLock`.
/// Panics if the `RwLock` cannot be locked.
///
/// Usage:
///
/// ```rs
/// let val = read_lock!(my_rwlock);
/// ```
#[macro_export]
macro_rules! read_lock {
($rwlock:expr) => {
$rwlock.read().expect($crate::error::ERR_READ_LOCK)
};
}
/// Gets a write lock on a `RwLock`.
/// Panics if the `RwLock` cannot be locked.
///
/// Usage:
///
/// ```rs
/// let mut val = write_lock!(my_rwlock);
/// ```
#[macro_export]
macro_rules! write_lock {
($rwlock:expr) => {
$rwlock.write().expect($crate::error::ERR_WRITE_LOCK)
};
}

View File

@@ -1,83 +1,175 @@
mod bar;
mod collection;
mod bridge_channel;
mod clients;
mod config;
mod icon;
mod desktop_file;
mod dynamic_string;
mod error;
mod image;
mod logging;
mod macros;
mod modules;
mod popup;
mod script;
mod style;
mod sway;
use crate::bar::create_bar;
use crate::config::{Config, MonitorConfig};
use crate::style::load_css;
use crate::sway::SwayOutput;
use color_eyre::eyre::Result;
use color_eyre::Report;
use dirs::config_dir;
use gtk::gdk::Display;
use gtk::prelude::*;
use gtk::{gdk, Application};
use ksway::client::Client;
use ksway::IpcCommand;
use gtk::Application;
use std::env;
use std::future::Future;
use std::path::PathBuf;
use std::process::exit;
use tokio::runtime::Handle;
use tokio::task::block_in_place;
use crate::error::ExitCode;
use clients::wayland::{self, WaylandClient};
use tracing::{debug, error, info};
use universal_config::ConfigLoader;
const GTK_APP_ID: &str = "dev.jstanger.ironbar";
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[tokio::main]
async fn main() {
let app = Application::builder()
.application_id("dev.jstanger.waylandbar")
.build();
async fn main() -> Result<()> {
let _guard = logging::install_logging();
let mut sway_client = Client::connect().expect("Failed to connect to Sway IPC");
let outputs = sway_client
.ipc(IpcCommand::GetOutputs)
.expect("Failed to get Sway outputs");
let outputs = serde_json::from_slice::<Vec<SwayOutput>>(&outputs)
.expect("Failed to deserialize outputs message from Sway IPC");
info!("Ironbar version {}", VERSION);
info!("Starting application");
clients::volume::pulse_bak::test();
let wayland_client = wayland::get_client().await;
let app = Application::builder().application_id(GTK_APP_ID).build();
app.connect_activate(move |app| {
let config = Config::load().unwrap_or_default();
let display = Display::default().map_or_else(
|| {
let report = Report::msg("Failed to get default GTK display");
error!("{:?}", report);
exit(ExitCode::GtkDisplay as i32)
},
|display| display,
);
// TODO: Better logging (https://crates.io/crates/tracing)
// TODO: error handling (https://crates.io/crates/color-eyre)
let config_res = match env::var("IRONBAR_CONFIG") {
Ok(path) => ConfigLoader::load(path),
Err(_) => ConfigLoader::new("ironbar").find_and_load(),
};
// TODO: Embedded Deno/lua - build custom modules via script???
let config = match config_res {
Ok(config) => config,
Err(err) => {
error!("{:?}", err);
exit(ExitCode::Config as i32)
}
};
let display = gdk::Display::default().expect("Failed to get default GDK display");
let num_monitors = display.n_monitors();
debug!("Loaded config file");
for i in 0..num_monitors {
let monitor = display.monitor(i).unwrap();
let monitor_name = &outputs
.get(i as usize)
.expect("GTK monitor output differs from Sway's")
.name;
config.monitors.as_ref().map_or_else(
|| {
create_bar(app, &monitor, monitor_name, config.clone());
},
|config| {
let config = config.get(monitor_name);
match &config {
Some(MonitorConfig::Single(config)) => {
create_bar(app, &monitor, monitor_name, config.clone());
}
Some(MonitorConfig::Multiple(configs)) => {
for config in configs {
create_bar(app, &monitor, monitor_name, config.clone());
}
}
_ => {}
}
},
)
if let Err(err) = create_bars(app, &display, wayland_client, &config) {
error!("{:?}", err);
exit(ExitCode::CreateBars as i32);
}
let style_path = config_dir()
.expect("Failed to locate user config dir")
.join("ironbar")
.join("style.css");
debug!("Created bars");
let style_path = env::var("IRONBAR_CSS").ok().map_or_else(
|| {
config_dir().map_or_else(
|| {
let report = Report::msg("Failed to locate user config dir");
error!("{:?}", report);
exit(ExitCode::CreateBars as i32);
},
|dir| dir.join("ironbar").join("style.css"),
)
},
PathBuf::from,
);
if style_path.exists() {
load_css(style_path);
}
});
app.run();
// Ignore CLI args
// Some are provided by swaybar_config but not currently supported
app.run_with_args(&Vec::<&str>::new());
Ok(())
}
/// Creates each of the bars across each of the (configured) outputs.
fn create_bars(
app: &Application,
display: &Display,
wl: &WaylandClient,
config: &Config,
) -> Result<()> {
let outputs = wl.outputs.as_slice();
debug!("Received {} outputs from Wayland", outputs.len());
debug!("Outputs: {:?}", outputs);
let num_monitors = display.n_monitors();
for i in 0..num_monitors {
let monitor = display
.monitor(i)
.ok_or_else(|| Report::msg(error::ERR_OUTPUTS))?;
let output = outputs
.get(i as usize)
.ok_or_else(|| Report::msg(error::ERR_OUTPUTS))?;
let monitor_name = &output.name;
config.monitors.as_ref().map_or_else(
|| {
info!("Creating bar on '{}'", monitor_name);
create_bar(app, &monitor, monitor_name, config.clone())
},
|config| {
let config = config.get(monitor_name);
match &config {
Some(MonitorConfig::Single(config)) => {
info!("Creating bar on '{}'", monitor_name);
create_bar(app, &monitor, monitor_name, config.clone())
}
Some(MonitorConfig::Multiple(configs)) => {
for config in configs {
info!("Creating bar on '{}'", monitor_name);
create_bar(app, &monitor, monitor_name, config.clone())?;
}
Ok(())
}
_ => Ok(()),
}
},
)?;
}
Ok(())
}
/// Blocks on a `Future` until it resolves.
///
/// This is not an `async` operation
/// so can be used outside of an async function.
///
/// Do note it must be called from within a Tokio runtime still.
///
/// Use sparingly! Prefer async functions wherever possible.
///
/// TODO: remove all instances of this once async trait funcs are stable
pub fn await_sync<F: Future>(f: F) -> F::Output {
block_in_place(|| Handle::current().block_on(f))
}

315
src/modules/clipboard.rs Normal file
View File

@@ -0,0 +1,315 @@
use crate::clients::clipboard::{self, ClipboardEvent};
use crate::clients::wayland::{ClipboardItem, ClipboardValue};
use crate::config::{CommonConfig, TruncateMode};
use crate::image::new_icon_button;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::try_send;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::gio::{Cancellable, MemoryInputStream};
use gtk::prelude::*;
use gtk::{Button, EventBox, Image, Label, Orientation, RadioButton, Widget};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{debug, error};
#[derive(Debug, Deserialize, Clone)]
pub struct ClipboardModule {
#[serde(default = "default_icon")]
icon: String,
#[serde(default = "default_max_items")]
max_items: usize,
// -- Common --
truncate: Option<TruncateMode>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
fn default_icon() -> String {
String::from("󰨸")
}
const fn default_max_items() -> usize {
10
}
#[derive(Debug, Clone)]
pub enum ControllerEvent {
Add(usize, Arc<ClipboardItem>),
Remove(usize),
Activate(usize),
Deactivate,
}
#[derive(Debug, Clone)]
pub enum UIEvent {
Copy(usize),
Remove(usize),
}
impl Module<Button> for ClipboardModule {
type SendMessage = ControllerEvent;
type ReceiveMessage = UIEvent;
fn name() -> &'static str {
"clipboard"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> color_eyre::Result<()> {
let max_items = self.max_items;
// listen to clipboard events
spawn(async move {
let mut rx = {
let client = clipboard::get_client();
client.subscribe(max_items).await
};
while let Some(event) = rx.recv().await {
match event {
ClipboardEvent::Add(item) => {
let msg = match &item.value {
ClipboardValue::Other => {
ModuleUpdateEvent::Update(ControllerEvent::Deactivate)
}
_ => ModuleUpdateEvent::Update(ControllerEvent::Add(item.id, item)),
};
try_send!(tx, msg);
}
ClipboardEvent::Remove(id) => {
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Remove(id)));
}
ClipboardEvent::Activate(id) => {
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Activate(id)));
}
}
}
error!("Clipboard client unexpectedly closed");
});
// listen to ui events
spawn(async move {
while let Some(event) = rx.recv().await {
let client = clipboard::get_client();
match event {
UIEvent::Copy(id) => client.copy(id).await,
UIEvent::Remove(id) => client.remove(id),
}
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> color_eyre::Result<ModuleWidget<Button>> {
let position = info.bar_position;
let button = new_icon_button(&self.icon, info.icon_theme, 32);
button.style_context().add_class("btn");
button.connect_clicked(move |button| {
let pos = Popup::button_pos(button, position.get_orientation());
try_send!(context.tx, ModuleUpdateEvent::TogglePopup(pos));
});
// we need to bind to the receiver as the channel does not open
// until the popup is first opened.
context.widget_rx.attach(None, |_| Continue(true));
Ok(ModuleWidget {
widget: button,
popup: self.into_popup(context.controller_tx, context.popup_rx, info),
})
}
fn into_popup(
self,
tx: Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box>
where
Self: Sized,
{
let container = gtk::Box::builder()
.orientation(Orientation::Vertical)
.spacing(10)
.name("popup-clipboard")
.build();
let entries = gtk::Box::new(Orientation::Vertical, 5);
container.add(&entries);
let hidden_option = RadioButton::new();
entries.add(&hidden_option);
let mut items = HashMap::new();
{
let hidden_option = hidden_option.clone();
rx.attach(None, move |event| {
match event {
ControllerEvent::Add(id, item) => {
debug!("Adding new value with ID {}", id);
let row = gtk::Box::new(Orientation::Horizontal, 0);
row.style_context().add_class("item");
let button = match &item.value {
ClipboardValue::Text(value) => {
let button = RadioButton::from_widget(&hidden_option);
let label = Label::new(Some(value));
button.add(&label);
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
}
button.style_context().add_class("text");
button
}
ClipboardValue::Image(bytes) => {
let stream = MemoryInputStream::from_bytes(bytes);
let pixbuf = Pixbuf::from_stream_at_scale(
&stream,
128,
64,
true,
Some(&Cancellable::new()),
)
.expect("Failed to read Pixbuf from stream");
let image = Image::from_pixbuf(Some(&pixbuf));
let button = RadioButton::from_widget(&hidden_option);
button.set_image(Some(&image));
button.set_always_show_image(true);
button.style_context().add_class("image");
button
}
ClipboardValue::Other => unreachable!(),
};
button.style_context().add_class("btn");
button.set_active(true); // if just added, should be on clipboard
let button_wrapper = EventBox::new();
button_wrapper.add(&button);
button_wrapper.set_widget_name(&format!("copy-{id}"));
button_wrapper.set_above_child(true);
{
let tx = tx.clone();
button_wrapper.connect_button_press_event(
move |button_wrapper, event| {
// left click
if event.button() == 1 {
let id = get_button_id(button_wrapper)
.expect("Failed to get id from button name");
debug!("Copying item with id: {id}");
try_send!(tx, UIEvent::Copy(id));
}
Inhibit(true)
},
);
}
let remove_button = Button::with_label("x");
remove_button.set_widget_name(&format!("remove-{id}"));
remove_button.style_context().add_class("btn-remove");
{
let tx = tx.clone();
let entries = entries.clone();
let row = row.clone();
remove_button.connect_clicked(move |button| {
let id = get_button_id(button)
.expect("Failed to get id from button name");
debug!("Removing item with id: {id}");
try_send!(tx, UIEvent::Remove(id));
entries.remove(&row);
});
}
row.add(&button_wrapper);
row.pack_end(&remove_button, false, false, 0);
entries.add(&row);
entries.reorder_child(&row, 0);
row.show_all();
items.insert(id, (row, button));
}
ControllerEvent::Remove(id) => {
debug!("Removing option with ID {id}");
let row = items.remove(&id);
if let Some((row, button)) = row {
if button.is_active() {
hidden_option.set_active(true);
}
entries.remove(&row);
}
}
ControllerEvent::Activate(id) => {
debug!("Activating option with ID {id}");
hidden_option.set_active(false);
let row = items.get(&id);
if let Some((_, button)) = row {
button.set_active(true);
}
}
ControllerEvent::Deactivate => {
debug!("Deactivating current option");
hidden_option.set_active(true);
}
}
Continue(true)
});
}
container.show_all();
hidden_option.hide();
Some(container)
}
}
/// Gets the ID from a widget's name.
///
/// This expects the button name to be
/// in the format `<purpose>-<id>`.
fn get_button_id<W>(button_wrapper: &W) -> Option<usize>
where
W: IsA<Widget>,
{
button_wrapper
.widget_name()
.split_once('-')
.and_then(|(_, id)| id.parse().ok())
}

127
src/modules/clock.rs Normal file
View File

@@ -0,0 +1,127 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::{send_async, try_send};
use chrono::{DateTime, Local};
use color_eyre::Result;
use glib::Continue;
use gtk::prelude::*;
use gtk::{Align, Button, Calendar, Label, Orientation};
use serde::Deserialize;
use tokio::spawn;
use tokio::sync::mpsc;
use tokio::time::sleep;
#[derive(Debug, Deserialize, Clone)]
pub struct ClockModule {
/// Date/time format string.
/// Default: `%d/%m/%Y %H:%M`
///
/// Detail on available tokens can be found here:
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
#[serde(default = "default_format")]
format: String,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
fn default_format() -> String {
String::from("%d/%m/%Y %H:%M")
}
impl Module<Button> for ClockModule {
type SendMessage = DateTime<Local>;
type ReceiveMessage = ();
fn name() -> &'static str {
"clock"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: mpsc::Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
spawn(async move {
loop {
let date = Local::now();
send_async!(tx, ModuleUpdateEvent::Update(date));
sleep(tokio::time::Duration::from_millis(500)).await;
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<Button>> {
let button = Button::new();
let label = Label::new(None);
label.set_angle(info.bar_position.get_angle());
button.add(&label);
let orientation = info.bar_position.get_orientation();
button.connect_clicked(move |button| {
try_send!(
context.tx,
ModuleUpdateEvent::TogglePopup(Popup::button_pos(button, orientation))
);
});
let format = self.format.clone();
{
context.widget_rx.attach(None, move |date| {
let date_string = format!("{}", date.format(&format));
label.set_label(&date_string);
Continue(true)
});
}
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: button,
popup,
})
}
fn into_popup(
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
let container = gtk::Box::builder()
.orientation(Orientation::Vertical)
.name("popup-clock")
.build();
let clock = Label::builder()
.name("calendar-clock")
.halign(Align::Center)
.build();
let format = "%H:%M:%S";
container.add(&clock);
let calendar = Calendar::builder().name("calendar").build();
container.add(&calendar);
{
rx.attach(None, move |date| {
let date_string = format!("{}", date.format(format));
clock.set_label(&date_string);
Continue(true)
});
}
container.show_all();
Some(container)
}
}

View File

@@ -1,69 +0,0 @@
mod popup;
use self::popup::Popup;
use crate::modules::{Module, ModuleInfo};
use chrono::Local;
use glib::Continue;
use gtk::prelude::*;
use gtk::{Button, Orientation};
use serde::Deserialize;
use tokio::spawn;
use tokio::time::sleep;
#[derive(Debug, Deserialize, Clone)]
pub struct ClockModule {
/// Date/time format string.
/// Default: `%d/%m/%Y %H:%M`
///
/// Detail on available tokens can be found here:
/// <https://docs.rs/chrono/latest/chrono/format/strftime/index.html>
#[serde(default = "default_format")]
pub(crate) format: String,
}
fn default_format() -> String {
String::from("%d/%m/%Y %H:%M")
}
impl Module<Button> for ClockModule {
fn into_widget(self, info: &ModuleInfo) -> Button {
let button = Button::new();
let popup = Popup::new(
"popup-clock",
info.app,
info.monitor,
Orientation::Vertical,
info.bar_position,
);
popup.add_clock_widgets();
button.show_all();
button.connect_clicked(move |button| {
popup.show(button);
});
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
let format = self.format.as_str();
loop {
let date = Local::now();
let date_string = format!("{}", date.format(format));
tx.send(date_string).unwrap();
sleep(tokio::time::Duration::from_millis(500)).await;
}
});
{
let button = button.clone();
rx.attach(None, move |s| {
button.set_label(s.as_str());
Continue(true)
});
}
button
}
}

View File

@@ -1,39 +0,0 @@
pub use crate::popup::Popup;
use chrono::Local;
use gtk::prelude::*;
use gtk::{Align, Calendar, Label};
use tokio::spawn;
use tokio::time::sleep;
impl Popup {
pub fn add_clock_widgets(&self) {
let clock = Label::builder()
.name("calendar-clock")
.halign(Align::Center)
.build();
let format = "%H:%M:%S";
self.container.add(&clock);
let calendar = Calendar::builder().name("calendar").build();
self.container.add(&calendar);
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
loop {
let date = Local::now();
let date_string = format!("{}", date.format(format));
tx.send(date_string).unwrap();
sleep(tokio::time::Duration::from_millis(500)).await;
}
});
{
rx.attach(None, move |s| {
clock.set_label(s.as_str());
Continue(true)
});
}
}
}

309
src/modules/custom.rs Normal file
View File

@@ -0,0 +1,309 @@
use crate::config::CommonConfig;
use crate::dynamic_string::DynamicString;
use crate::image::ImageProvider;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::{ButtonGeometry, Popup};
use crate::script::Script;
use crate::{send_async, try_send};
use color_eyre::{Report, Result};
use gtk::prelude::*;
use gtk::{Button, IconTheme, Label, Orientation};
use serde::Deserialize;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{debug, error};
#[derive(Debug, Deserialize, Clone)]
pub struct CustomModule {
/// Container class name
class: Option<String>,
/// Widgets to add to the bar container
bar: Vec<Widget>,
/// Widgets to add to the popup container
popup: Option<Vec<Widget>>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
/// Attempts to parse an `Orientation` from `String`
fn try_get_orientation(orientation: &str) -> Result<Orientation> {
match orientation.to_lowercase().as_str() {
"horizontal" | "h" => Ok(Orientation::Horizontal),
"vertical" | "v" => Ok(Orientation::Vertical),
_ => Err(Report::msg("Invalid orientation string in config")),
}
}
/// Widget attributes
#[derive(Debug, Deserialize, Clone)]
pub struct Widget {
/// Type of GTK widget to add
#[serde(rename = "type")]
widget_type: WidgetType,
widgets: Option<Vec<Widget>>,
label: Option<String>,
name: Option<String>,
class: Option<String>,
on_click: Option<String>,
orientation: Option<String>,
src: Option<String>,
size: Option<i32>,
}
/// Supported GTK widget types
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum WidgetType {
Box,
Label,
Button,
Image,
}
impl Widget {
/// Creates this widget and adds it to the parent container
fn add_to(
self,
parent: &gtk::Box,
tx: Sender<ExecEvent>,
bar_orientation: Orientation,
icon_theme: &IconTheme,
) {
match self.widget_type {
WidgetType::Box => parent.add(&self.into_box(&tx, bar_orientation, icon_theme)),
WidgetType::Label => parent.add(&self.into_label()),
WidgetType::Button => parent.add(&self.into_button(tx, bar_orientation)),
WidgetType::Image => parent.add(&self.into_image(icon_theme)),
}
}
/// Creates a `gtk::Box` from this widget
fn into_box(
self,
tx: &Sender<ExecEvent>,
bar_orientation: Orientation,
icon_theme: &IconTheme,
) -> gtk::Box {
let mut builder = gtk::Box::builder();
if let Some(name) = self.name {
builder = builder.name(&name);
}
if let Some(orientation) = self.orientation {
builder = builder
.orientation(try_get_orientation(&orientation).unwrap_or(Orientation::Horizontal));
}
let container = builder.build();
if let Some(class) = self.class {
container.style_context().add_class(&class);
}
if let Some(widgets) = self.widgets {
for widget in widgets {
widget.add_to(&container, tx.clone(), bar_orientation, icon_theme);
}
}
container
}
/// Creates a `gtk::Label` from this widget
fn into_label(self) -> Label {
let mut builder = Label::builder().use_markup(true);
if let Some(name) = self.name {
builder = builder.name(name);
}
let label = builder.build();
if let Some(class) = self.class {
label.style_context().add_class(&class);
}
let text = self.label.map_or_else(String::new, |text| text);
{
let label = label.clone();
DynamicString::new(&text, move |string| {
label.set_label(&string);
Continue(true)
});
}
label
}
/// Creates a `gtk::Button` from this widget
fn into_button(self, tx: Sender<ExecEvent>, bar_orientation: Orientation) -> Button {
let mut builder = Button::builder();
if let Some(name) = self.name {
builder = builder.name(name);
}
let button = builder.build();
if let Some(text) = self.label {
let label = Label::new(None);
label.set_use_markup(true);
label.set_markup(&text);
button.add(&label);
}
if let Some(class) = self.class {
button.style_context().add_class(&class);
}
if let Some(exec) = self.on_click {
button.connect_clicked(move |button| {
try_send!(
tx,
ExecEvent {
cmd: exec.clone(),
geometry: Popup::button_pos(button, bar_orientation),
}
);
});
}
button
}
fn into_image(self, icon_theme: &IconTheme) -> gtk::Image {
let mut builder = gtk::Image::builder();
if let Some(name) = self.name {
builder = builder.name(&name);
}
let gtk_image = builder.build();
if let Some(src) = self.src {
let size = self.size.unwrap_or(32);
if let Err(err) = ImageProvider::parse(&src, icon_theme, size)
.and_then(|image| image.load_into_image(gtk_image.clone()))
{
error!("{err:?}");
}
}
if let Some(class) = self.class {
gtk_image.style_context().add_class(&class);
}
gtk_image
}
}
#[derive(Debug)]
pub struct ExecEvent {
cmd: String,
geometry: ButtonGeometry,
}
impl Module<gtk::Box> for CustomModule {
type SendMessage = ();
type ReceiveMessage = ExecEvent;
fn name() -> &'static str {
"custom"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
spawn(async move {
while let Some(event) = rx.recv().await {
if event.cmd.starts_with('!') {
let script = Script::from(&event.cmd[1..]);
debug!("executing command: '{}'", script.cmd);
// TODO: Migrate to use script.run
if let Err(err) = script.get_output().await {
error!("{err:?}");
}
} else if event.cmd == "popup:toggle" {
send_async!(tx, ModuleUpdateEvent::TogglePopup(event.geometry));
} else if event.cmd == "popup:open" {
send_async!(tx, ModuleUpdateEvent::OpenPopup(event.geometry));
} else if event.cmd == "popup:close" {
send_async!(tx, ModuleUpdateEvent::ClosePopup);
} else {
error!("Received invalid command: '{}'", event.cmd);
}
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let orientation = info.bar_position.get_orientation();
let container = gtk::Box::builder().orientation(orientation).build();
if let Some(ref class) = self.class {
container.style_context().add_class(class);
}
self.bar.clone().into_iter().for_each(|widget| {
widget.add_to(
&container,
context.controller_tx.clone(),
orientation,
info.icon_theme,
);
});
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: container,
popup,
})
}
fn into_popup(
self,
tx: Sender<Self::ReceiveMessage>,
_rx: glib::Receiver<Self::SendMessage>,
info: &ModuleInfo,
) -> Option<gtk::Box>
where
Self: Sized,
{
let container = gtk::Box::builder().name("popup-custom").build();
if let Some(class) = self.class {
container
.style_context()
.add_class(format!("popup-{class}").as_str());
}
if let Some(popup) = self.popup {
for widget in popup {
widget.add_to(
&container,
tx.clone(),
Orientation::Horizontal,
info.icon_theme,
);
}
}
container.show_all();
Some(container)
}
}

View File

@@ -1,24 +1,34 @@
use crate::icon;
use crate::modules::{Module, ModuleInfo};
use crate::sway::node::get_open_windows;
use crate::sway::WindowEvent;
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::{CommonConfig, TruncateMode};
use crate::image::ImageProvider;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, read_lock, send_async};
use color_eyre::Result;
use glib::Continue;
use gtk::prelude::*;
use gtk::{IconTheme, Image, Label, Orientation};
use ksway::{Client, IpcEvent};
use gtk::Label;
use serde::Deserialize;
use tokio::task::spawn_blocking;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::error;
#[derive(Debug, Deserialize, Clone)]
pub struct FocusedModule {
/// Whether to show icon on the bar.
#[serde(default = "crate::config::default_true")]
show_icon: bool,
/// Whether to show app name on the bar.
#[serde(default = "crate::config::default_true")]
show_title: bool,
/// Icon size in pixels.
#[serde(default = "default_icon_size")]
icon_size: i32,
icon_theme: Option<String>,
truncate: Option<TruncateMode>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
@@ -26,69 +36,99 @@ const fn default_icon_size() -> i32 {
}
impl Module<gtk::Box> for FocusedModule {
fn into_widget(self, _info: &ModuleInfo) -> gtk::Box {
let icon_theme = IconTheme::new();
type SendMessage = (String, String);
type ReceiveMessage = ();
if let Some(theme) = self.icon_theme {
icon_theme.set_custom_theme(Some(&theme));
fn name() -> &'static str {
"focused"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let focused = await_sync(async {
let wl = wayland::get_client().await;
let toplevels = read_lock!(wl.toplevels);
toplevels
.iter()
.find(|(_, (top, _))| top.active)
.map(|(_, (top, _))| top.clone())
});
if let Some(top) = focused {
tx.try_send(ModuleUpdateEvent::Update((top.title.clone(), top.app_id)))?;
}
let container = gtk::Box::new(Orientation::Horizontal, 5);
spawn(async move {
let mut wlrx = {
let wl = wayland::get_client().await;
wl.subscribe_toplevels()
};
let icon = Image::builder().name("icon").build();
let label = Label::builder().name("label").build();
container.add(&icon);
container.add(&label);
let mut sway = Client::connect().unwrap();
let srx = sway.subscribe(vec![IpcEvent::Window]).unwrap();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
let focused = get_open_windows(&mut sway)
.into_iter()
.find(|node| node.focused);
if let Some(focused) = focused {
tx.send(focused).unwrap();
}
spawn_blocking(move || loop {
while let Ok((_, payload)) = srx.try_recv() {
let payload: WindowEvent = serde_json::from_slice(&payload).unwrap();
let update = match payload.change.as_str() {
"focus" => true,
"title" => payload.container.focused,
while let Ok(event) = wlrx.recv().await {
let update = match event.change {
ToplevelChange::Focus(focus) => focus,
ToplevelChange::Title(_) => event.toplevel.active,
_ => false,
};
if update {
tx.send(payload.container).unwrap();
send_async!(
tx,
ModuleUpdateEvent::Update((event.toplevel.title, event.toplevel.app_id))
);
}
}
sway.poll().unwrap();
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let icon_theme = info.icon_theme;
let container = gtk::Box::new(info.bar_position.get_orientation(), 5);
let icon = gtk::Image::builder().name("icon").build();
let label = Label::builder().name("label").build();
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
}
container.add(&icon);
container.add(&label);
{
rx.attach(None, move |node| {
let value = node.name.as_deref().unwrap_or_else(|| node.get_id());
let pixbuf = icon::get_icon(&icon_theme, node.get_id(), self.icon_size);
let icon_theme = icon_theme.clone();
context.widget_rx.attach(None, move |(name, id)| {
if self.show_icon {
icon.set_pixbuf(pixbuf.as_ref());
if let Err(err) = ImageProvider::parse(&id, &icon_theme, self.icon_size)
.and_then(|image| image.load_into_image(icon.clone()))
{
error!("{err:?}");
}
}
if self.show_title {
label.set_label(value);
label.set_label(&name);
}
Continue(true)
});
}
container
Ok(ModuleWidget {
widget: container,
popup: None,
})
}
}

View File

@@ -1,244 +1,262 @@
use crate::collection::Collection;
use crate::icon::{find_desktop_file, get_icon};
use crate::modules::launcher::popup::Popup;
use crate::modules::launcher::FocusEvent;
use crate::sway::SwayNode;
use super::open_state::OpenState;
use crate::clients::wayland::ToplevelInfo;
use crate::image::ImageProvider;
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
use crate::modules::ModuleUpdateEvent;
use crate::popup::Popup;
use crate::{read_lock, try_send};
use gtk::prelude::*;
use gtk::{Button, IconTheme, Image};
use std::process::{Command, Stdio};
use gtk::{Button, IconTheme, Orientation};
use indexmap::IndexMap;
use std::rc::Rc;
use std::sync::{Arc, Mutex, RwLock};
use tokio::spawn;
use tokio::sync::mpsc;
use std::sync::RwLock;
use tokio::sync::mpsc::Sender;
use tracing::error;
#[derive(Debug, Clone)]
pub struct LauncherItem {
pub struct Item {
pub app_id: String,
pub favorite: bool,
pub windows: Rc<Mutex<Collection<i32, LauncherWindow>>>,
pub state: Arc<RwLock<State>>,
pub button: Button,
pub open_state: OpenState,
pub windows: IndexMap<usize, Window>,
pub name: String,
}
#[derive(Debug, Clone)]
pub struct LauncherWindow {
pub con_id: i32,
pub name: Option<String>,
}
#[derive(Debug, Clone)]
pub struct State {
pub is_xwayland: bool,
pub open: bool,
pub focused: bool,
pub urgent: bool,
}
#[derive(Debug, Clone)]
pub struct ButtonConfig {
pub icon_theme: IconTheme,
pub show_names: bool,
pub show_icons: bool,
pub popup: Popup,
pub tx: mpsc::Sender<FocusEvent>,
}
impl LauncherItem {
pub fn new(app_id: String, favorite: bool, config: &ButtonConfig) -> Self {
let button = Button::new();
button.style_context().add_class("item");
let state = State {
open: false,
focused: false,
urgent: false,
is_xwayland: false,
};
let item = Self {
impl Item {
pub fn new(app_id: String, open_state: OpenState, favorite: bool) -> Self {
Self {
app_id,
favorite,
windows: Rc::new(Mutex::new(Collection::new())),
state: Arc::new(RwLock::new(state)),
button,
};
item.configure_button(config);
item
open_state,
windows: IndexMap::new(),
name: String::new(),
}
}
pub fn from_node(node: &SwayNode, config: &ButtonConfig) -> Self {
let button = Button::new();
button.style_context().add_class("item");
/// Merges the provided node into this launcher item
pub fn merge_toplevel(&mut self, node: ToplevelInfo) -> Window {
let id = node.id;
let windows = Collection::from((
node.id,
LauncherWindow {
con_id: node.id,
name: node.name.clone(),
},
));
if self.windows.is_empty() {
self.name = node.title.clone();
}
let state = State {
open: true,
focused: node.focused,
urgent: node.urgent,
is_xwayland: node.is_xwayland(),
};
let window: Window = node.into();
self.windows.insert(id, window.clone());
let item = Self {
app_id: node.get_id().to_string(),
self.recalculate_open_state();
window
}
pub fn unmerge_toplevel(&mut self, node: &ToplevelInfo) {
self.windows.remove(&node.id);
self.recalculate_open_state();
}
pub fn set_window_name(&mut self, window_id: usize, name: String) {
if let Some(window) = self.windows.get_mut(&window_id) {
if let OpenState::Open { focused: true, .. } = window.open_state {
self.name = name.clone();
}
window.name = name;
}
}
pub fn set_window_focused(&mut self, window_id: usize, focused: bool) {
if let Some(window) = self.windows.get_mut(&window_id) {
window.open_state =
OpenState::merge_states(&[&window.open_state, &OpenState::focused(focused)]);
self.recalculate_open_state();
}
}
/// Sets this item's open state
/// to the merged result of its windows' open states
fn recalculate_open_state(&mut self) {
let new_state = OpenState::merge_states(
&self
.windows
.iter()
.map(|(_, win)| &win.open_state)
.collect::<Vec<_>>(),
);
self.open_state = new_state;
}
}
impl From<ToplevelInfo> for Item {
fn from(toplevel: ToplevelInfo) -> Self {
let open_state = OpenState::from_toplevel(&toplevel);
let name = toplevel.title.clone();
let app_id = toplevel.app_id.clone();
let mut windows = IndexMap::new();
windows.insert(toplevel.id, toplevel.into());
Self {
app_id,
favorite: false,
windows: Rc::new(Mutex::new(windows)),
state: Arc::new(RwLock::new(state)),
button,
};
item.configure_button(config);
item
}
fn configure_button(&self, config: &ButtonConfig) {
let button = &self.button;
let windows = self.windows.lock().unwrap();
let name = if windows.len() == 1 {
windows.first().unwrap().name.as_ref()
} else {
Some(&self.app_id)
};
if let Some(name) = name {
self.set_title(name, config);
}
if config.show_icons {
let icon = get_icon(&config.icon_theme, &self.app_id, 32);
if icon.is_some() {
let image = Image::from_pixbuf(icon.as_ref());
button.set_image(Some(&image));
button.set_always_show_image(true);
}
}
let app_id = self.app_id.clone();
let state = Arc::clone(&self.state);
let tx_click = config.tx.clone();
let (focus_tx, mut focus_rx) = mpsc::channel(32);
button.connect_clicked(move |_| {
let state = state.read().unwrap();
if state.open {
focus_tx.try_send(()).unwrap();
} else {
// attempt to find desktop file and launch
match find_desktop_file(&app_id) {
Some(file) => {
Command::new("gtk-launch")
.arg(file.file_name().unwrap())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
}
None => (),
}
}
});
let app_id = self.app_id.clone();
let state = Arc::clone(&self.state);
spawn(async move {
while focus_rx.recv().await == Some(()) {
let state = state.read().unwrap();
if state.is_xwayland {
tx_click
.try_send(FocusEvent::Class(app_id.clone()))
.unwrap();
} else {
tx_click
.try_send(FocusEvent::AppId(app_id.clone()))
.unwrap();
}
}
});
let popup = config.popup.clone();
let popup2 = config.popup.clone();
let windows = Rc::clone(&self.windows);
let tx_hover = config.tx.clone();
button.connect_enter_notify_event(move |button, _| {
let windows = windows.lock().unwrap();
if windows.len() > 1 {
popup.set_windows(windows.as_slice(), &tx_hover);
popup.show(button);
}
Inhibit(false)
});
{}
button.connect_leave_notify_event(move |_, e| {
let (_, y) = e.position();
// hover boundary
if y > 2.0 {
popup2.hide();
}
Inhibit(false)
});
let style = button.style_context();
style.add_class("launcher-item");
self.update_button_classes(&self.state.read().unwrap());
button.show_all();
}
pub fn set_title(&self, title: &str, config: &ButtonConfig) {
if config.show_names {
self.button.set_label(title);
} else {
self.button.set_tooltip_text(Some(title));
};
}
/// Updates the classnames on the GTK button
/// based on its current state.
///
/// State must be passed as an arg here rather than
/// using `self.state` to avoid a weird `RwLock` issue.
pub fn update_button_classes(&self, state: &State) {
let style = self.button.style_context();
if self.favorite {
style.add_class("favorite");
} else {
style.remove_class("favorite");
}
if state.open {
style.add_class("open");
} else {
style.remove_class("open");
}
if state.focused {
style.add_class("focused");
} else {
style.remove_class("focused");
}
if state.urgent {
style.add_class("urgent");
} else {
style.remove_class("urgent");
open_state,
windows,
name,
}
}
}
#[derive(Clone, Debug)]
pub struct Window {
pub id: usize,
pub name: String,
pub open_state: OpenState,
}
impl From<ToplevelInfo> for Window {
fn from(node: ToplevelInfo) -> Self {
let open_state = OpenState::from_toplevel(&node);
Self {
id: node.id,
name: node.title,
open_state,
}
}
}
pub struct MenuState {
pub num_windows: usize,
}
pub struct ItemButton {
pub button: Button,
pub persistent: bool,
pub show_names: bool,
pub menu_state: Rc<RwLock<MenuState>>,
}
impl ItemButton {
pub fn new(
item: &Item,
show_names: bool,
show_icons: bool,
orientation: Orientation,
icon_theme: &IconTheme,
tx: &Sender<ModuleUpdateEvent<LauncherUpdate>>,
controller_tx: &Sender<ItemEvent>,
) -> Self {
let mut button = Button::builder();
if show_names {
button = button.label(&item.name);
}
let button = button.build();
if show_icons {
let gtk_image = gtk::Image::new();
let image = ImageProvider::parse(&item.app_id.clone(), icon_theme, 32);
match image {
Ok(image) => {
button.set_image(Some(&gtk_image));
button.set_always_show_image(true);
if let Err(err) = image.load_into_image(gtk_image) {
error!("{err:?}");
}
}
Err(err) => error!("{err:?}"),
};
}
let style_context = button.style_context();
style_context.add_class("item");
if item.favorite {
style_context.add_class("favorite");
}
if item.open_state.is_open() {
style_context.add_class("open");
}
if item.open_state.is_focused() {
style_context.add_class("focused");
}
{
let app_id = item.app_id.clone();
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
// lazy check :| TODO: Improve this
let style_context = button.style_context();
if style_context.has_class("open") {
try_send!(tx, ItemEvent::FocusItem(app_id.clone()));
} else {
try_send!(tx, ItemEvent::OpenItem(app_id.clone()));
}
});
}
let menu_state = Rc::new(RwLock::new(MenuState {
num_windows: item.windows.len(),
}));
{
let app_id = item.app_id.clone();
let tx = tx.clone();
let menu_state = menu_state.clone();
button.connect_enter_notify_event(move |button, _| {
let menu_state = read_lock!(menu_state);
if menu_state.num_windows > 1 {
try_send!(
tx,
ModuleUpdateEvent::Update(LauncherUpdate::Hover(app_id.clone(),))
);
try_send!(
tx,
ModuleUpdateEvent::OpenPopup(Popup::button_pos(button, orientation,))
);
} else {
try_send!(tx, ModuleUpdateEvent::ClosePopup);
}
Inhibit(false)
});
}
button.show_all();
Self {
button,
persistent: item.favorite,
show_names,
menu_state,
}
}
pub fn set_open(&self, open: bool) {
self.update_class("open", open);
if !open {
self.set_focused(false);
}
}
pub fn set_focused(&self, focused: bool) {
self.update_class("focused", focused);
}
/// Adds or removes a class to the button based on `toggle`.
fn update_class(&self, class: &str, toggle: bool) {
let style_context = self.button.style_context();
if toggle {
style_context.add_class(class);
} else {
style_context.remove_class(class);
}
}
}

View File

@@ -1,247 +1,525 @@
mod item;
mod popup;
mod open_state;
use crate::collection::Collection;
use crate::modules::launcher::item::{ButtonConfig, LauncherItem, LauncherWindow};
use crate::modules::launcher::popup::Popup;
use crate::modules::{Module, ModuleInfo};
use crate::sway::node::get_open_windows;
use crate::sway::{SwayNode, WindowEvent};
use self::item::{Item, ItemButton, Window};
use self::open_state::OpenState;
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::desktop_file::find_desktop_file;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{lock, read_lock, try_send, write_lock};
use color_eyre::{Help, Report};
use glib::Continue;
use gtk::prelude::*;
use gtk::{IconTheme, Orientation};
use ksway::{Client, IpcEvent};
use gtk::{Button, Orientation};
use indexmap::IndexMap;
use serde::Deserialize;
use std::rc::Rc;
use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex};
use tokio::spawn;
use tokio::sync::mpsc;
use tokio::task::spawn_blocking;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::{debug, error, trace};
#[derive(Debug, Deserialize, Clone)]
pub struct LauncherModule {
/// List of app IDs (or classes) to always show regardless of open state,
/// in the order specified.
favorites: Option<Vec<String>>,
/// Whether to show application names on the bar.
#[serde(default = "crate::config::default_false")]
show_names: bool,
/// Whether to show application icons on the bar.
#[serde(default = "crate::config::default_true")]
show_icons: bool,
icon_theme: Option<String>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Clone)]
pub enum LauncherUpdate {
/// Adds item
AddItem(Item),
/// Adds window to item with `app_id`
AddWindow(String, Window),
/// Removes item with `app_id`
RemoveItem(String),
/// Removes window from item with `app_id`.
RemoveWindow(String, usize),
/// Sets title for `app_id`
Title(String, usize, String),
/// Marks the item with `app_id` as focused or not focused
Focus(String, bool),
/// Declares the item with `app_id` has been hovered over
Hover(String),
}
#[derive(Debug)]
pub enum FocusEvent {
AppId(String),
Class(String),
ConId(i32),
pub enum ItemEvent {
FocusItem(String),
FocusWindow(usize),
OpenItem(String),
}
type AppId = String;
struct Launcher {
items: Collection<AppId, LauncherItem>,
container: gtk::Box,
button_config: ButtonConfig,
enum ItemOrWindow {
Item(Item),
Window(Window),
}
impl Launcher {
fn new(favorites: Vec<String>, container: gtk::Box, button_config: ButtonConfig) -> Self {
let items = favorites
.into_iter()
.map(|app_id| {
(
app_id.clone(),
LauncherItem::new(app_id, true, &button_config),
)
})
.collect::<Collection<_, _>>();
for item in &items {
container.add(&item.button);
}
Self {
items,
container,
button_config,
}
}
/// Adds a new window to the launcher.
/// This gets added to an existing group
/// if an instance of the program is already open.
fn add_window(&mut self, window: SwayNode) {
let id = window.get_id().to_string();
if let Some(item) = self.items.get_mut(&id) {
let mut state = item.state.write().unwrap();
state.open = true;
state.focused = window.focused || state.focused;
state.urgent = window.urgent || state.urgent;
state.is_xwayland = window.is_xwayland();
item.update_button_classes(&state);
let mut windows = item.windows.lock().unwrap();
windows.insert(
window.id,
LauncherWindow {
con_id: window.id,
name: window.name,
},
);
} else {
let item = LauncherItem::from_node(&window, &self.button_config);
self.container.add(&item.button);
self.items.insert(id, item);
}
}
/// Removes a window from the launcher.
/// This removes it from the group if multiple instances were open.
/// The button will remain on the launcher if it is favourited.
fn remove_window(&mut self, window: &SwayNode) {
let id = window.get_id().to_string();
let item = self.items.get_mut(&id);
let remove = if let Some(item) = item {
let windows = Rc::clone(&item.windows);
let mut windows = windows.lock().unwrap();
windows.remove(&window.id);
if windows.is_empty() {
let mut state = item.state.write().unwrap();
state.open = false;
item.update_button_classes(&state);
if item.favorite {
false
} else {
self.container.remove(&item.button);
true
}
} else {
false
}
} else {
false
};
if remove {
self.items.remove(&id);
}
}
fn set_window_focused(&mut self, window: &SwayNode) {
let id = window.get_id().to_string();
let currently_focused = self
.items
.iter_mut()
.find(|item| item.state.read().unwrap().focused);
if let Some(currently_focused) = currently_focused {
let mut state = currently_focused.state.write().unwrap();
state.focused = false;
currently_focused.update_button_classes(&state);
}
let item = self.items.get_mut(&id);
if let Some(item) = item {
let mut state = item.state.write().unwrap();
state.focused = true;
item.update_button_classes(&state);
}
}
fn set_window_title(&mut self, window: SwayNode) {
let id = window.get_id().to_string();
let item = self.items.get_mut(&id);
if let (Some(item), Some(name)) = (item, window.name) {
let mut windows = item.windows.lock().unwrap();
if windows.len() == 1 {
item.set_title(&name, &self.button_config);
} else {
windows.get_mut(&window.id).unwrap().name = Some(name);
}
}
}
fn set_window_urgent(&mut self, window: &SwayNode) {
let id = window.get_id().to_string();
let item = self.items.get_mut(&id);
if let Some(item) = item {
let mut state = item.state.write().unwrap();
state.urgent = window.urgent;
item.update_button_classes(&state);
}
}
enum ItemOrWindowId {
Item,
Window,
}
impl Module<gtk::Box> for LauncherModule {
fn into_widget(self, info: &ModuleInfo) -> gtk::Box {
let icon_theme = IconTheme::new();
type SendMessage = LauncherUpdate;
type ReceiveMessage = ItemEvent;
if let Some(theme) = self.icon_theme {
icon_theme.set_custom_theme(Some(&theme));
}
fn name() -> &'static str {
"launcher"
}
let mut sway = Client::connect().unwrap();
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> crate::Result<()> {
let items = self
.favorites
.as_ref()
.map_or_else(IndexMap::new, |favorites| {
favorites
.iter()
.map(|app_id| {
(
app_id.to_string(),
Item::new(app_id.to_string(), OpenState::Closed, true),
)
})
.collect::<IndexMap<_, _>>()
});
let popup = Popup::new(
"popup-launcher",
info.app,
info.monitor,
Orientation::Vertical,
info.bar_position,
);
let container = gtk::Box::new(Orientation::Horizontal, 0);
let (ui_tx, mut ui_rx) = mpsc::channel(32);
let button_config = ButtonConfig {
icon_theme,
show_names: self.show_names,
show_icons: self.show_icons,
popup,
tx: ui_tx,
};
let mut launcher = Launcher::new(
self.favorites.unwrap_or_default(),
container.clone(),
button_config,
);
let open_windows = get_open_windows(&mut sway);
for window in open_windows {
launcher.add_window(window);
}
let srx = sway.subscribe(vec![IpcEvent::Window]).unwrap();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn_blocking(move || loop {
while let Ok((_, payload)) = srx.try_recv() {
let payload: WindowEvent = serde_json::from_slice(&payload).unwrap();
tx.send(payload).unwrap();
}
sway.poll().unwrap();
});
let items = Arc::new(Mutex::new(items));
{
let items = Arc::clone(&items);
let tx = tx.clone();
spawn(async move {
let wl = wayland::get_client().await;
let open_windows = read_lock!(wl.toplevels);
let open_windows = open_windows.clone();
for (_, (window, _)) in open_windows {
let mut items = lock!(items);
let item = items.get_mut(&window.app_id);
match item {
Some(item) => {
item.merge_toplevel(window);
}
None => {
items.insert(window.app_id.clone(), window.into());
}
}
}
let items = lock!(items);
let items = items.iter();
for (_, item) in items {
tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::AddItem(
item.clone(),
)))?;
}
Ok::<(), Report>(())
});
}
let items2 = Arc::clone(&items);
spawn(async move {
let items = items2;
let mut wlrx = {
let wl = wayland::get_client().await;
wl.subscribe_toplevels()
};
let send_update = |update: LauncherUpdate| tx.send(ModuleUpdateEvent::Update(update));
while let Ok(event) = wlrx.recv().await {
trace!("event: {:?}", event);
let window = event.toplevel;
let app_id = window.app_id.clone();
match event.change {
ToplevelChange::New => {
let new_item = {
let mut items = lock!(items);
let item = items.get_mut(&app_id);
match item {
None => {
let item: Item = window.into();
items.insert(app_id.clone(), item.clone());
ItemOrWindow::Item(item)
}
Some(item) => {
let window = item.merge_toplevel(window);
ItemOrWindow::Window(window)
}
}
};
match new_item {
ItemOrWindow::Item(item) => {
send_update(LauncherUpdate::AddItem(item)).await
}
ItemOrWindow::Window(window) => {
send_update(LauncherUpdate::AddWindow(app_id, window)).await
}
}?;
}
ToplevelChange::Close => {
let remove_item = {
let mut items = lock!(items);
let item = items.get_mut(&app_id);
match item {
Some(item) => {
item.unmerge_toplevel(&window);
if item.windows.is_empty() {
items.remove(&app_id);
Some(ItemOrWindowId::Item)
} else {
Some(ItemOrWindowId::Window)
}
}
None => None,
}
};
match remove_item {
Some(ItemOrWindowId::Item) => {
send_update(LauncherUpdate::RemoveItem(app_id)).await?;
}
Some(ItemOrWindowId::Window) => {
send_update(LauncherUpdate::RemoveWindow(app_id, window.id))
.await?;
}
None => {}
};
}
ToplevelChange::Focus(focused) => {
let mut update_title = false;
if focused {
if let Some(item) = lock!(items).get_mut(&app_id) {
item.set_window_focused(window.id, true);
// might be switching focus between windows of same app
if item.windows.len() > 1 {
item.set_window_name(window.id, window.title.clone());
update_title = true;
}
}
}
send_update(LauncherUpdate::Focus(app_id.clone(), focused)).await?;
if update_title {
send_update(LauncherUpdate::Title(app_id, window.id, window.title))
.await?;
}
}
ToplevelChange::Title(title) => {
if let Some(item) = lock!(items).get_mut(&app_id) {
item.set_window_name(window.id, title.clone());
}
send_update(LauncherUpdate::Title(app_id, window.id, title)).await?;
}
ToplevelChange::Fullscreen(_) => {}
}
}
Ok::<(), mpsc::error::SendError<ModuleUpdateEvent<LauncherUpdate>>>(())
});
// listen to ui events
spawn(async move {
while let Some(event) = rx.recv().await {
trace!("{:?}", event);
if let ItemEvent::OpenItem(app_id) = event {
find_desktop_file(&app_id).map_or_else(
|| error!("Could not find desktop file for {}", app_id),
|file| {
if let Err(err) = Command::new("gtk-launch")
.arg(
file.file_name()
.expect("File segment missing from path to desktop file"),
)
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
error!(
"{:?}",
Report::new(err)
.wrap_err("Failed to run gtk-launch command.")
.suggestion("Perhaps the desktop file is invalid?")
);
}
},
);
} else {
let wl = wayland::get_client().await;
let items = lock!(items);
let id = match event {
ItemEvent::FocusItem(app_id) => items
.get(&app_id)
.and_then(|item| item.windows.first().map(|(_, win)| win.id)),
ItemEvent::FocusWindow(id) => Some(id), // FIXME: Broken on wlroots-git
ItemEvent::OpenItem(_) => unreachable!(),
};
if let Some(id) = id {
let toplevels = read_lock!(wl.toplevels);
let seat = wl.seats.first().expect("Failed to get Wayland seat");
if let Some((_top, handle)) = toplevels.get(&id) {
handle.activate(seat);
};
}
// roundtrip to immediately send activate event
wl.roundtrip();
}
}
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> crate::Result<ModuleWidget<gtk::Box>> {
let icon_theme = info.icon_theme;
let container = gtk::Box::new(info.bar_position.get_orientation(), 0);
{
let container = container.clone();
let icon_theme = icon_theme.clone();
let controller_tx = context.controller_tx.clone();
let show_names = self.show_names;
let show_icons = self.show_icons;
let orientation = info.bar_position.get_orientation();
let mut buttons = IndexMap::<String, ItemButton>::new();
context.widget_rx.attach(None, move |event| {
match event {
LauncherUpdate::AddItem(item) => {
debug!("Adding item with id {}", item.app_id);
if let Some(button) = buttons.get(&item.app_id) {
button.set_open(true);
} else {
let button = ItemButton::new(
&item,
show_names,
show_icons,
orientation,
&icon_theme,
&context.tx,
&controller_tx,
);
container.add(&button.button);
buttons.insert(item.app_id, button);
}
}
LauncherUpdate::AddWindow(app_id, _) => {
if let Some(button) = buttons.get(&app_id) {
button.set_open(true);
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows += 1;
}
}
LauncherUpdate::RemoveItem(app_id) => {
debug!("Removing item with id {}", app_id);
if let Some(button) = buttons.get(&app_id) {
if button.persistent {
button.set_open(false);
if button.show_names {
button.button.set_label(&app_id);
}
} else {
container.remove(&button.button);
buttons.remove(&app_id);
}
}
}
LauncherUpdate::RemoveWindow(app_id, _) => {
if let Some(button) = buttons.get(&app_id) {
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows -= 1;
}
}
LauncherUpdate::Focus(app_id, focus) => {
debug!("Changing focus to {} on item with id {}", focus, app_id);
if let Some(button) = buttons.get(&app_id) {
button.set_focused(focus);
}
}
LauncherUpdate::Title(app_id, _, name) => {
debug!("Updating title for item with id {}: {:?}", app_id, name);
if show_names {
if let Some(button) = buttons.get(&app_id) {
button.button.set_label(&name);
}
}
}
LauncherUpdate::Hover(_) => {}
};
Continue(true)
});
}
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: container,
popup,
})
}
fn into_popup(
self,
controller_tx: Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
const MAX_WIDTH: i32 = 250;
let container = gtk::Box::builder()
.orientation(Orientation::Vertical)
.name("popup-launcher")
.build();
// we need some content to force the container to have a size
let placeholder = Button::with_label("PLACEHOLDER");
placeholder.set_width_request(MAX_WIDTH);
container.add(&placeholder);
let mut buttons = IndexMap::<String, IndexMap<usize, Button>>::new();
{
let container = container.clone();
rx.attach(None, move |event| {
match event.change.as_str() {
"new" => launcher.add_window(event.container),
"close" => launcher.remove_window(&event.container),
"focus" => launcher.set_window_focused(&event.container),
"title" => launcher.set_window_title(event.container),
"urgent" => launcher.set_window_urgent(&event.container),
match event {
LauncherUpdate::AddItem(item) => {
let app_id = item.app_id.clone();
trace!("Adding item with id '{app_id}' to the popup: {item:?}");
let window_buttons = item
.windows
.into_iter()
.map(|(_, win)| {
let button = Button::builder()
.label(clamp(&win.name))
.height_request(40)
.build();
{
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ItemEvent::FocusWindow(win.id));
if let Some(win) = button.window() {
win.hide();
}
});
}
(win.id, button)
})
.collect();
buttons.insert(app_id, window_buttons);
}
LauncherUpdate::AddWindow(app_id, win) => {
debug!(
"Adding new window to popup for '{app_id}': '{}' ({})",
win.name, win.id
);
if let Some(buttons) = buttons.get_mut(&app_id) {
let button = Button::builder()
.height_request(40)
.label(clamp(&win.name))
.build();
{
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
try_send!(tx, ItemEvent::FocusWindow(win.id));
if let Some(win) = button.window() {
win.hide();
}
});
}
buttons.insert(win.id, button);
}
}
LauncherUpdate::RemoveWindow(app_id, win_id) => {
debug!("Removing window from popup for '{app_id}': {win_id}");
if let Some(buttons) = buttons.get_mut(&app_id) {
buttons.remove(&win_id);
}
}
LauncherUpdate::Title(app_id, win_id, title) => {
debug!(
"Updating window title on popup for '{app_id}'/{win_id} to '{title}'"
);
if let Some(buttons) = buttons.get_mut(&app_id) {
if let Some(button) = buttons.get(&win_id) {
button.set_label(&title);
}
}
}
LauncherUpdate::Hover(app_id) => {
// empty current buttons
for child in container.children() {
container.remove(&child);
}
// add app's buttons
if let Some(buttons) = buttons.get(&app_id) {
for (_, button) in buttons {
button.style_context().add_class("popup-item");
container.add(button);
}
container.show_all();
container.set_width_request(MAX_WIDTH);
}
}
_ => {}
}
@@ -249,19 +527,21 @@ impl Module<gtk::Box> for LauncherModule {
});
}
spawn(async move {
let mut sway = Client::connect().unwrap();
while let Some(event) = ui_rx.recv().await {
let selector = match event {
FocusEvent::AppId(app_id) => format!("[app_id={}]", app_id),
FocusEvent::Class(class) => format!("[class={}]", class),
FocusEvent::ConId(id) => format!("[con_id={}]", id),
};
sway.run(format!("{} focus", selector)).unwrap();
}
});
container
Some(container)
}
}
/// Clamps a string at 24 characters.
///
/// This is a hacky number derived from
/// "what fits inside the 250px popup"
/// and probably won't hold up with wide fonts.
fn clamp(str: &str) -> String {
const MAX_CHARS: usize = 24;
if str.len() > MAX_CHARS {
str.chars().take(MAX_CHARS - 3).collect::<String>() + "..."
} else {
str.to_string()
}
}

View File

@@ -0,0 +1,48 @@
use crate::clients::wayland::ToplevelInfo;
/// Open state for a launcher item, or item window.
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
pub enum OpenState {
Closed,
Open { focused: bool },
}
impl OpenState {
/// Creates from `SwayNode`
pub const fn from_toplevel(toplevel: &ToplevelInfo) -> Self {
Self::Open {
focused: toplevel.active,
}
}
/// Creates open with focused
pub const fn focused(focused: bool) -> Self {
Self::Open { focused }
}
/// Checks if open
pub fn is_open(self) -> bool {
self != Self::Closed
}
/// Checks if open with focus
pub const fn is_focused(self) -> bool {
matches!(self, Self::Open { focused: true })
}
/// Merges states together to produce a single state.
/// This is effectively an OR operation,
/// so sets state to open and flags to true if any state is open
/// or any instance of the flag is true.
pub fn merge_states(states: &[&Self]) -> Self {
states.iter().fold(Self::Closed, |merged, current| {
if merged.is_open() || current.is_open() {
Self::Open {
focused: merged.is_focused() || current.is_focused(),
}
} else {
Self::Closed
}
})
}
}

View File

@@ -1,35 +0,0 @@
use crate::modules::launcher::item::LauncherWindow;
use crate::modules::launcher::FocusEvent;
pub use crate::popup::Popup;
use gtk::prelude::*;
use gtk::Button;
use tokio::sync::mpsc;
impl Popup {
pub fn set_windows(&self, windows: &[LauncherWindow], tx: &mpsc::Sender<FocusEvent>) {
// clear
for child in self.container.children() {
self.container.remove(&child);
}
for window in windows {
let mut button_builder = Button::builder().height_request(40);
if let Some(name) = &window.name {
button_builder = button_builder.label(name);
}
let button = button_builder.build();
let con_id = window.con_id;
let window = self.window.clone();
let tx = tx.clone();
button.connect_clicked(move |_| {
tx.try_send(FocusEvent::ConId(con_id)).unwrap();
window.hide();
});
self.container.add(&button);
}
}
}

Some files were not shown because too many files have changed in this diff Show More