130 Commits

Author SHA1 Message Date
Jake Stanger
c73585324c start porting to gtk4 2023-04-30 15:35:40 +01:00
Jake Stanger
0e3102de8c Merge pull request #83 from p00f/upower-string
implement upower module
2023-04-30 00:26:34 +01:00
Chinmay Dalal
ad3c171eca feat: implement upower module 2023-04-30 00:15:04 +01:00
Jake Stanger
e5bc44168f Merge pull request #125 from JakeStanger/feat/custom-slider-label
feat(custom): option to toggle slider label
2023-04-23 17:35:40 +01:00
Jake Stanger
cc62927f15 Merge pull request #124 from JakeStanger/feat/music-status-icon
feat(music): option to hide status icon on widget
2023-04-23 17:35:19 +01:00
Jake Stanger
76e2b7ba3e feat(music): option to hide status icon on widget
Adds new `show_status_icon` option.

Resolves #97.
2023-04-23 13:00:37 +01:00
Jake Stanger
033d0f7e6e feat(custom): option to toggle slider label
Adds new `show_label` option.

Resolves #115 (for real this time).
2023-04-23 12:59:55 +01:00
Jake Stanger
dc16b1e15a Merge pull request #122 from yavko/fix-nix-pixbuf-loader
Attempt to fix image blurriness on nix
2023-04-23 11:10:04 +01:00
Jake Stanger
03cd263095 Merge pull request #121 from JakeStanger/fix/icon-scale
fix(image): not scaling images for hidpi
2023-04-23 11:07:49 +01:00
Jake Stanger
db0868a3fc fix(image): not scaling icons for hidpi 2023-04-23 11:07:19 +01:00
Yavor Kolev
0382b50cf4 Merge branch 'JakeStanger:master' into fix-nix-pixbuf-loader 2023-04-22 16:49:19 -07:00
yavko
338f5a0e1b fix(nix): Attempt to fix image blurriness 2023-04-22 16:47:04 -07:00
Jake Stanger
20949a7744 Merge pull request #120 from JakeStanger/feat/icon-sizes
feat: ability to configure image icon sizes
2023-04-22 22:35:21 +01:00
Jake Stanger
2da28b9bf5 feat: ability to configure image icon sizes
Adds `icon_size` option to following widgets:

- `clipboard`
- `launcher`
- `music`
- `workspaces`

Also adds `cover_image_size` option to `music`.
2023-04-22 22:22:49 +01:00
Jake Stanger
618e97f1e8 Merge pull request #119 from JakeStanger/feat/slider-step
Custom slider widget step option
2023-04-22 21:39:50 +01:00
Jake Stanger
dd7c9f30db docs: add transition module-level options 2023-04-22 21:29:47 +01:00
Jake Stanger
1fa0c0e977 feat(custom): support mouse wheel on slider 2023-04-22 21:29:47 +01:00
Jake Stanger
74d18aedfb Merge pull request #118 from JakeStanger/fix/dynamic-string
fix(dynamic string): crash when last segment is static and a single char
2023-04-22 16:40:33 +01:00
Jake Stanger
2c88c99cb6 fix(dynamic string): crash when last segment is static and a single char
Resolves #117.
2023-04-22 16:29:54 +01:00
Jake Stanger
236bb09170 Merge pull request #116 from JakeStanger/feat/revealer
feat: wrap modules in a revealer to support animated show/hide
2023-04-22 15:24:20 +01:00
Jake Stanger
83f44fd92f feat: wrap modules in a revealer to support animated show/hide
Resolves #72.
2023-04-22 14:49:15 +01:00
Jake Stanger
1855416db4 Merge pull request #114 from JakeStanger/feat/common-in-custom
feat(custom): support common options in widgets
2023-04-22 13:48:08 +01:00
Jake Stanger
e63509a3a7 refactor: fix a few new clippy warnings 2023-04-22 13:45:44 +01:00
Jake Stanger
4a09b70854 feat(custom): support common options in widgets 2023-04-22 13:34:39 +01:00
Jake Stanger
9d09855fce Merge pull request #109 from JakeStanger/fix/tray-icons
fix(tray): icons sometimes not showing
2023-04-22 11:01:06 +01:00
Jake Stanger
e9d0273176 Merge pull request #113 from yavko/fix-nix-run
Fix `nix run` support
2023-04-22 10:47:37 +01:00
yavko
7926bb07eb fix(nix): Fix nix run support 2023-04-21 21:43:55 -07:00
Jake Stanger
6fd69d657c refactor: move module creation code to module module 2023-04-21 23:51:54 +01:00
Jake Stanger
27d11de661 refactor(config): split common code into separate file 2023-04-21 23:51:29 +01:00
Jake Stanger
07df51c249 docs: include readme in rust docs 2023-04-21 23:50:49 +01:00
Jake Stanger
b038e7671a fix(tray): icons sometimes not showing
Previously icons were only loaded from the theme based on the provided icon name. Sometimes no icon name was provided, and sometimes the name is just missing from the theme.

This falls back to using the provided pixbuf, and then falls back to just displaying the name as text if that is not available.
2023-04-21 23:02:53 +01:00
Jake Stanger
e5ab9f33b5 Merge remote-tracking branch 'origin/fix/tray-icons' into fix/tray-icons 2023-04-21 22:33:59 +01:00
Jake Stanger
68bc8230dd fix(tray): icons sometimes not showing
Previously icons were only loaded from the theme based on the provided icon name. Sometimes no icon name was provided, and sometimes the name is just missing from the theme.

This falls back to using the provided pixbuf, and then falls back to just displaying the name as text if that is not available.
2023-04-21 22:31:09 +01:00
Jake Stanger
246313136f Merge pull request #111 from JakeStanger/fix/script-parsing
fix(script): parser incorrectly handling colons
2023-04-21 20:37:13 +01:00
Jake Stanger
15a9d8d42c fix(script): parser incorrectly handling colons
The short input parser was previously splitting colons, and incorrectly handling situations where the `cmd` section contained colons. The parser now properly checks input in the `mode:interval:cmd` format, moving onto the next section regardless of whether the previous was found.

This means unless your script literally starts with `poll:` or `5000:` you won't hit this issue anymore.
2023-04-20 21:59:23 +01:00
Jake Stanger
a87d8d5c30 fix(tray): icons sometimes not showing
Previously icons were only loaded from the theme based on the provided icon name. Sometimes no icon name was provided, and sometimes the name is just missing from the theme.

This falls back to using the provided pixbuf, and then falls back to just displaying the name as text if that is not available.
2023-04-16 21:37:47 +01:00
Jake Stanger
8e99fd4d0f chore(system tray): add debug logging 2023-04-16 19:55:17 +01:00
Jake Stanger
1e1d65ae49 chore(script): add debug logging 2023-04-13 12:47:26 +01:00
Jake Stanger
2815cef440 Merge pull request #106 from JakeStanger/feat/custom-dynamic-image
Custom image dynamic src support
2023-04-10 20:17:58 +01:00
Jake Stanger
138b5b3903 docs(custom): fix potential error in progress example 2023-04-10 20:05:37 +01:00
Jake Stanger
7355db74ec fix(image): http provider not handling non-success codes 2023-04-10 20:05:13 +01:00
Jake Stanger
c214f65ecb refactor: fix strict clippy warnings 2023-04-10 20:04:59 +01:00
Jake Stanger
3d308ab572 feat(custom): support dynamic string in image source
Resolves #94.
2023-04-10 20:04:36 +01:00
Jake Stanger
b770ae716c Merge pull request #104 from JakeStanger/feat/custom-widgets
Custom module improvements
2023-04-10 14:02:42 +01:00
Jake Stanger
3613aef5c5 refactor(custom): reduce a lot of repeated code 2023-04-10 13:51:07 +01:00
Jake Stanger
a9d1233909 feat(custom): support dynamic strings on buttons 2023-04-10 13:49:09 +01:00
Jake Stanger
72b14b6c4e feat(custom): progress bar widget.
Resolves partially #68.
2023-04-10 12:59:24 +01:00
Jake Stanger
910945306c fix(dynamic string): parser issue related to incorrectly matching braces 2023-04-10 00:17:09 +01:00
Jake Stanger
dfe1964abf feat(custom): slider widget
Resolves partially #68.
2023-04-10 00:17:09 +01:00
Jake Stanger
e928b30f99 docs(custom): rewrite widget options to be clearer 2023-04-10 00:16:44 +01:00
Jake Stanger
2ab06f044e refactor(custom): split into enum with separate file per widget 2023-04-07 20:24:41 +01:00
Jake Stanger
4b4f1ffc21 Merge pull request #103 from JakeStanger/feat/popup-gap-config
feat: ability to configure popup gap
2023-04-07 15:02:58 +01:00
Jake Stanger
0691db3b87 Merge pull request #102 from JakeStanger/feat/labels
feat: new label module
2023-04-07 14:53:56 +01:00
Jake Stanger
cac064f479 feat: ability to configure popup gap 2023-04-07 14:53:18 +01:00
Jake Stanger
6c622864b3 feat: new label module
Takes a text label, with the ability to include embedded scripts.

Resolves #80.
2023-04-07 14:29:07 +01:00
Jake Stanger
55c06c4766 chore: bash script for regenerating examples 2023-04-07 14:26:17 +01:00
JakeStanger
1b0287becc docs: update CHANGELOG.md for v0.11.0 [skip ci] 2023-04-01 17:44:26 +00:00
Jake Stanger
7bf44ca75d chore(release): v0.11.0 2023-04-01 18:36:24 +01:00
Jake Stanger
fb04ceab7d Merge pull request #95 from JakeStanger/feat/module-hover
feat: module hover options
2023-04-01 18:17:52 +01:00
Jake Stanger
102d2478a9 feat: module hover options
Resolves #70.
2023-04-01 13:29:40 +01:00
Jake Stanger
80a414ab67 build: update deps
Resolves #93
2023-04-01 13:12:26 +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
90 changed files with 7322 additions and 2452 deletions

View File

@@ -23,12 +23,20 @@ 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: 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 }}
@@ -50,14 +58,14 @@ jobs:
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v17
- 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@v11
- uses: cachix/cachix-action@v12
with:
name: jakestanger
signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'

View File

@@ -18,7 +18,9 @@ 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: Update CHANGELOG
id: changelog

View File

@@ -13,7 +13,7 @@ jobs:
uses: actions/checkout@v2
- name: Install Nix
uses: cachix/install-nix-action@v16
uses: cachix/install-nix-action@v20
with:
extra_nix_config: |
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}

View File

@@ -4,6 +4,118 @@ 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.11.0] - 2023-04-01
### :boom: BREAKING CHANGES
- due to [`ca4fe42`](https://github.com/JakeStanger/ironbar/commit/ca4fe422f22866748f2cb6239b31170a974d254b) - ability to set fixed length *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This changes the behaviour of `truncate.length`. A new property, `truncate.max_length`, has been introduced that uses the old behaviour.
### :sparkles: New Features
- [`d253c4b`](https://github.com/JakeStanger/ironbar/commit/d253c4bd7f306c7b8fef223d1beb7b1f6e77629b) - add configurable margins around bar *(commit by [@ttoino](https://github.com/ttoino))*
- [`ca4fe42`](https://github.com/JakeStanger/ironbar/commit/ca4fe422f22866748f2cb6239b31170a974d254b) - **truncate**: ability to set fixed length *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`575d6cc`](https://github.com/JakeStanger/ironbar/commit/575d6cc30f9e28079aed8425566048abd3d9e022) - new clipboard manager module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`9984b63`](https://github.com/JakeStanger/ironbar/commit/9984b638b55adea11ba90412346fbb8220f05682) - **nix**: initial nix feature flags impl *(commit by [@yavko](https://github.com/yavko))*
- [`b1475a1`](https://github.com/JakeStanger/ironbar/commit/b1475a1affd2f101f1f707ab1a0e8e5509a1d99f) - **nix**: use cargo default features *(commit by [@yavko](https://github.com/yavko))*
- [`102d247`](https://github.com/JakeStanger/ironbar/commit/102d2478a9d0ecc8be12c5ea6019a5a5411cc6ab) - module hover options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`2ac5071`](https://github.com/JakeStanger/ironbar/commit/2ac507144b42a80507f8d2df214889c114c069df) - not setting layer shell namespace *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`7dff3e6`](https://github.com/JakeStanger/ironbar/commit/7dff3e6f8b989132ff0c4406caa72f063dd57c9f) - **image**: widgets missing names *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`54b9b28`](https://github.com/JakeStanger/ironbar/commit/54b9b28c75b2fe300e2bad1436d315da1950953e) - make readme more concise *(commit by [@yavko](https://github.com/yavko))*
- [`8cbb73b`](https://github.com/JakeStanger/ironbar/commit/8cbb73b75e7aca1aa163406f4583273e6ff4bac2) - **dynamic string**: dynamic sections not respecting ordering *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`d0b7bdb`](https://github.com/JakeStanger/ironbar/commit/d0b7bdbafcc34967dd5b048ea12e6267ba293566) - **nix**: home manager module, and features *(commit by [@yavko](https://github.com/yavko))*
### :recycle: Refactors
- [`d84139a`](https://github.com/JakeStanger/ironbar/commit/d84139a914f9b35054dc6048715e1ed7e79d7441) - general tidy up *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`7212bbc`](https://github.com/JakeStanger/ironbar/commit/7212bbcf61e097b35a7ab341e19e9daefd2edf95) - **dynamic string**: use vec instead of indexmap *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ecdd71a`](https://github.com/JakeStanger/ironbar/commit/ecdd71a43d267161f84e3c4a3c22e9454c0f7184) - **config**: use `universal-config` crate. *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6221f74`](https://github.com/JakeStanger/ironbar/commit/6221f7454a2da2ec8a5a7f84e6fd35a8dc1a1548) - fix new clippy warnings *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`82875cd`](https://github.com/JakeStanger/ironbar/commit/82875cde687628f3ee3436343068825440128599) - update CHANGELOG.md for v0.10.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`7c36f5c`](https://github.com/JakeStanger/ironbar/commit/7c36f5cb0cf03191c9b03e2455b63829a64e402e) - fix a couple of issues *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`83a4916`](https://github.com/JakeStanger/ironbar/commit/83a49165c42fa793ef1224f93cbc147bc69de894) - **compiling**: add info about build deps *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`5bbe64b`](https://github.com/JakeStanger/ironbar/commit/5bbe64bb86fb2db0921e284a1560db2f6c1a1920) - **clock**: format table *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`2b26eaf`](https://github.com/JakeStanger/ironbar/commit/2b26eaf41036609be4dfc57689ca8d770dcb6b9b) - **clipboard**: fix incorrect setting description *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0125ce5`](https://github.com/JakeStanger/ironbar/commit/0125ce5916c003d1ea9a141fe5a0f6a54b2778ab) - **examples**: update styles example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [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))*:
@@ -157,3 +269,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[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
[v0.11.0]: https://github.com/JakeStanger/ironbar/compare/v0.10.0...v0.11.0

1858
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,15 +1,55 @@
[package]
name = "ironbar"
version = "0.9.0"
version = "0.11.0"
edition = "2021"
license = "MIT"
description = "Customisable GTK Layer Shell wlroots/sway bar"
[features]
default = [
"http",
"config+all",
"clipboard",
"clock",
"music+all",
"sys_info",
"tray",
"upower",
"workspaces+all"
]
http = ["dep:reqwest"]
upower = ["upower_dbus", "zbus", "futures-lite"]
"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"]
workspaces = ["futures-util"]
"workspaces+all" = ["workspaces", "workspaces+sway", "workspaces+hyprland"]
"workspaces+sway" = ["workspaces", "swayipc-async"]
"workspaces+hyprland" = ["workspaces", "hyprland"]
[dependencies]
gtk = "0.16.0"
gtk-layer-shell = "0.5.0"
glib = "0.16.2"
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time", "process"] }
# core
gtk = { package = "gtk4", version = "0.6.6" }
gtk-layer-shell = { package = "gtk4-layer-shell", version = "0.0.3" }
glib = "0.17.9"
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"
@@ -17,25 +57,47 @@ 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"
libcorn = "0.6.1"
lazy_static = "1.4.0"
async_once = "0.2.6"
indexmap = "1.9.1"
futures-util = "0.3.21"
chrono = "0.4.19"
regex = { version = "1.6.0", default-features = false, features = ["std"] }
stray = { version = "0.1.2" }
dirs = "4.0.0"
dirs = "5.0.0"
walkdir = "2.3.2"
notify = { version = "5.0.0", default-features = false }
mpd_client = "1.0.0"
mpris = "2.0.0"
swayipc-async = { version = "2.0.1" }
hyprland = "0.3.0-alpha.0"
sysinfo = "0.27.0"
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.3.0", 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.28.4", optional = true }
# tray
stray = { version = "0.1.3", optional = true }
# upower
upower_dbus = { version = "0.3.2", optional = true }
futures-lite = { version = "1.12.0", optional = true }
zbus = { version = "3.11.0", optional = true }
# workspaces
swayipc-async = { version = "2.0.1", optional = true }
hyprland = { version = "0.3.1", 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

View File

@@ -6,13 +6,23 @@ It uses GTK3 and gtk-layer-shell.
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
### Cargo
Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed.
```sh
cargo install ironbar
```
@@ -29,9 +39,11 @@ yay -S 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, this is just a
proof of concept, please adapt it to your config
Here is an example nix flake that uses Ironbar.
```nix
{
@@ -58,6 +70,8 @@ proof of concept, please adapt it to your config
enable = true;
config = {};
style = "";
package = inputs.ironbar;
features = ["feature" "another_feature"];
};
}
];
@@ -67,11 +81,14 @@ proof of concept, please adapt it to your config
```
#### Binary Caching
There is also a cachix cache at `https://app.cachix.org/cache/jakestanger`
incase you don't want to compile ironbar!
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
@@ -80,6 +97,9 @@ cargo build --release
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

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. |

View File

@@ -272,6 +272,12 @@ The following table lists each of the top-level bar config options:
| `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. |
| `popup_gap` | `integer` | `5` | The gap between the bar and popup window. |
| `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. |
@@ -283,12 +289,30 @@ For details on available modules and each of their config options, check the sid
For information on the `Script` type, and embedding scripts in strings, see [here](script).
#### Events
| 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. |
| `on_mouse_enter` | `Script [oneshot]` | `null` | Runs the script when the module is hovered over. |
| `on_mouse_exit` | `Script [oneshot]` | `null` | Runs the script when the module is no longer hovered over. |
#### Visibility
| 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. |
| `transition_type` | `slide_start` or `slide_end` or `crossfade` or `none` | `slide_start` | The transition animation to use when showing/hiding the widget. |
| `transition_duration` | `Integer` | `250` | The length of the transition animation to use when showing/hiding the widget. |
#### Other
| Name | Type | Default | Description |
|-------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |

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.

View File

@@ -1,13 +1,15 @@
# Guides
- [Compiling from source](compiling)
- [Configuration guide](configuration-guide)
- [Scripts](scripts)
- [Images](images)
- [Styling guide](styling-guide)
# Examples
- [Config](config)
- [Stylesheet](stylesheet)
- [Stylesheet](https://github.com/JakeStanger/ironbar/blob/master/examples/style.css)
## Custom
@@ -15,9 +17,11 @@
# Modules
- [Clipboard](clipboard)
- [Clock](clock)
- [Custom](custom)
- [Focused](focused)
- [Label](label)
- [Launcher](launcher)
- [Music](music)
- [Script](script)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -1,202 +1,10 @@
The below config shows a module of each type being used.
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.
<details>
<summary>JSON</summary>
```json
{
"start": [
{
"all_monitors": false,
"name_map": {
"1": "ﭮ",
"2": "",
"3": "",
"Code": "",
"Games": ""
},
"type": "workspaces"
},
{
"favorites": [
"firefox",
"discord",
"Steam"
],
"icon_theme": "Paper",
"show_icons": true,
"show_names": false,
"type": "launcher"
}
],
"end": [
{
"music_dir": "/home/jake/Music",
"type": "mpd"
},
{
"host": "chloe:6600",
"type": "mpd"
},
{
"path": "/home/jake/bin/phone-battery",
"type": "script"
},
{
"format": [
"{cpu-percent}% ",
"{memory-percent}% "
],
"type": "sys-info"
},
{
"type": "clock"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[start]]
all_monitors = false
type = 'workspaces'
[start.name_map]
1 = 'ﭮ'
2 = ''
3 = ''
Code = ''
Games = ''
[[start]]
icon_theme = 'Paper'
show_icons = true
show_names = false
type = 'launcher'
favorites = [
'firefox',
'discord',
'Steam',
]
[[end]]
music_dir = '/home/jake/Music'
type = 'mpd'
[[end]]
host = 'chloe:6600'
type = 'mpd'
[[end]]
path = '/home/jake/bin/phone-battery'
type = 'script'
[[end]]
type = 'sys-info'
format = [
'{cpu-percent}% ',
'{memory-percent}% ',
]
[[end]]
type = 'clock'
```
</details>
<details>
<summary>YAML</summary>
```yaml
---
start:
- all_monitors: false
name_map:
"1":
"2":
"3":
Code:
Games:
type: workspaces
- favorites:
- firefox
- discord
- Steam
icon_theme: Paper
show_icons: true
show_names: false
type: launcher
end:
- music_dir: /home/jake/Music
type: mpd
- host: "chloe:6600"
type: mpd
- path: /home/jake/bin/phone-battery
type: script
- format:
- "{cpu-percent}% "
- "{memory-percent}% "
type: sys-info
- type: clock
```
</details>
<details>
<summary>Corn</summary>
```corn
let {
$workspaces = {
type = "workspaces"
all_monitors = false
name_map = {
1 = "ﭮ"
2 = ""
3 = ""
Games = ""
Code = ""
}
}
$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" }
$sys_info = {
type = "sys-info"
format = ["{cpu-percent}% " "{memory-percent}% "]
}
$tray = { type = "tray" }
$clock = { type = "clock" }
$phone_battery = {
type = "script"
path = "/home/jake/bin/phone-battery"
}
$start = [ $workspaces $launcher ]
$end = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
}
in {
start = $start
end = $end
}
```
</details>
- [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

@@ -1,142 +0,0 @@
The below example is a full stylesheet for all modules:
```css
* {
/* a nerd font is required to be installed for icons */
font-family: Noto Sans Nerd Font, sans-serif;
font-size: 16px;
border: none;
}
#bar {
border-top: 1px solid #424242;
}
.container {
background-color: #2d2d2d;
}
.container#end > * + * {
margin-left: 20px;
}
.popup {
background-color: #2d2d2d;
border: 1px solid #424242;
}
#workspaces .item {
color: white;
background-color: #2d2d2d;
border-radius: 0;
}
#workspaces .item.focused {
box-shadow: inset 0 -3px;
background-color: #1c1c1c;
}
#workspaces *:not(.focused):hover {
box-shadow: inset 0 -3px;
}
#launcher .item {
border-radius: 0;
background-color: #2d2d2d;
margin-right: 4px;
}
#launcher .item:not(.focused):hover {
background-color: #1c1c1c;
}
#launcher .open {
border-bottom: 2px solid #6699cc;
}
#launcher .focused {
color: white;
background-color: black;
border-bottom: 4px solid #6699cc;
}
#launcher .urgent {
color: white;
background-color: #8f0a0a;
}
#script {
color: white;
}
#sysinfo {
color: white;
}
#tray .item {
background-color: #2d2d2d;
}
#mpd {
background-color: #2d2d2d;
color: white;
}
#popup-mpd {
color: white;
padding: 1em;
}
#popup-mpd #album-art {
margin-right: 1em;
}
#popup-mpd #title .icon, #popup-mpd #title .label {
font-size: 1.7em;
}
#popup-mpd #controls * {
border-radius: 0;
background-color: #2d2d2d;
color: white;
}
#popup-mpd #controls *:disabled {
color: #424242;
}
#clock {
color: white;
background-color: #2d2d2d;
font-weight: bold;
}
#popup-clock {
padding: 1em;
}
#popup-clock #calendar-clock {
color: white;
font-size: 2.5em;
padding-bottom: 0.1em;
}
#popup-clock #calendar {
background-color: #2d2d2d;
color: white;
}
#popup-clock #calendar .header {
padding-top: 1em;
border-top: 1px solid #424242;
font-size: 1.5em;
}
#popup-clock #calendar:selected {
background-color: #6699cc;
}
#focused {
color: white;
}
```

View File

@@ -1,6 +1,6 @@
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](../../_imgs/custom-power-menu.png)
![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
@@ -16,9 +16,9 @@ Creates a button on the bar, which opens a popup. The popup contains a header, s
{
"bar": [
{
"on_click": "popup:toggle",
"label": "",
"name": "power-btn",
"on_click": "popup:toggle",
"type": "button"
}
],
@@ -38,26 +38,27 @@ Creates a button on the bar, which opens a popup. The popup contains a header, s
"widgets": [
{
"class": "power-btn",
"on_click": "!shutdown now",
"label": "<span font-size='40pt'></span>",
"on_click": "!shutdown now",
"type": "button"
},
{
"class": "power-btn",
"on_click": "!reboot",
"label": "<span font-size='40pt'></span>",
"on_click": "!reboot",
"type": "button"
}
]
},
{
"label": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
"name": "uptime",
"type": "label"
}
]
}
],
"tooltip": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
"type": "custom"
}
]
@@ -75,12 +76,13 @@ type = 'clock'
[[end]]
class = 'power-menu'
tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
type = 'custom'
[[end.bar]]
on_click = 'popup:toggle'
label = ''
name = 'power-btn'
on_click = 'popup:toggle'
type = 'button'
[[end.popup]]
@@ -97,18 +99,18 @@ type = 'box'
[[end.popup.widgets.widgets]]
class = 'power-btn'
on_click = '!shutdown now'
label = '''<span font-size='40pt'></span>'''
on_click = '!shutdown now'
type = 'button'
[[end.popup.widgets.widgets]]
class = 'power-btn'
on_click = '!reboot'
label = '''<span font-size='40pt'></span>'''
on_click = '!reboot'
type = 'button'
[[end.popup.widgets]]
label = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
name = 'uptime'
type = 'label'
```
@@ -121,10 +123,11 @@ type = 'label'
```yaml
end:
- type: clock
- bar:
- on_click: popup:toggle
label:
- label:
name: power-btn
on_click: popup:toggle
type: button
class: power-menu
popup:
@@ -137,16 +140,17 @@ end:
- type: box
widgets:
- class: power-btn
on_click: '!shutdown now'
label: <span font-size='40pt'></span>
on_click: '!shutdown now'
type: button
- class: power-btn
on_click: '!reboot'
label: <span font-size='40pt'></span>
on_click: '!reboot'
type: button
- label: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
name: uptime
type: label
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
type: custom
```
@@ -157,13 +161,9 @@ end:
```corn
let {
$power_menu = {
type = "custom"
class = "power-menu"
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
popup = [ {
$popup = {
type = "box"
orientation = "vertical"
widgets = [
@@ -175,12 +175,23 @@ let {
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
]
}
{ type = "label" name = "uptime" label = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}" }
{ 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 ]
end = [ $power_menu $clock ]
}
```
</details>

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

@@ -0,0 +1,94 @@
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. |
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
| `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. |

View File

@@ -9,7 +9,7 @@ Clicking on the widget opens a popup with the time and a calendar.
> 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>
@@ -70,7 +70,7 @@ end:
## Styling
| Selector | Description |
|-------------------------------|------------------------------------------------------------------------------------|
|--------------------------------|------------------------------------------------------------------------------------|
| `#clock` | Clock widget button |
| `#popup-clock` | Clock popup box |
| `#popup-clock #calendar-clock` | Clock inside the popup |

View File

@@ -1,7 +1,7 @@
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://user-images.githubusercontent.com/5057870/196058785-042ef171-7e77-4d5c-921a-eca03c6424bd.png)
![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?raw)
## Configuration
@@ -10,27 +10,144 @@ Labels can display dynamic content from scripts, and buttons can interact with t
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`
There are many widget types, each with their own config options.
You can think of these like HTML elements and their attributes.
Every widget has the following options available; `type` is mandatory.
You can also add common [module-level options](https://github.com/JakeStanger/ironbar/wiki/configuration-guide#32-module-level-options) on a widget.
| Name | Type | Default | Description |
|---------------|------------------------------|--------------|---------------------------------------------------------------------------|
| `widget_type` | `box` or `label` or `button` | `null` | Type of GTK widget to create. |
|---------|-------------------------------------------------------------------|---------|-------------------------------|
| `type` | `box` or `label` or `button` or `image` or `slider` or `progress` | `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). |
| `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
#### Box
A container to place nested widgets inside.
> Type: `box`
| Name | Type | Default | Description |
|---------------|----------------------------------------------------|--------------|-------------------------------------------------------------------|
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Whether child widgets should be horizontally or vertically added. |
| `widgets` | `Widget[]` | `[]` | List of widgets to add to this box. |
#### Label
A text label. Pango markup and embedded scripts are supported.
> Type `label`
| Name | Type | Default | Description |
|---------|----------|--------------|---------------------------------------------------------------------|
| `label` | `string` | `horizontal` | Widget text label. Pango markup and embedded scripts are supported. |
#### Button
A clickable button, which can run a command when clicked.
> Type `button`
| Name | Type | Default | Description |
|------------|--------------------|--------------|---------------------------------------------------------------------|
| `label` | `string` | `horizontal` | Widget text label. Pango markup and embedded scripts are supported. |
| `on_click` | `string [command]` | `null` | Command to execute. More on this [below](#commands). |
#### Image
An image or icon from disk or http.
> Type `image`
| Name | Type | Default | Description |
|--------|-----------|---------|---------------------------------------------------------------------------------------------|
| `src` | `image` | `null` | Image source. See [here](images) for information on images. Embedded scripts are supported. |
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
#### Slider
A draggable slider.
> Type: `slider`
Note that `on_change` will provide the **floating point** value as an argument.
If your input program requires an integer, you will need to round it.
| Name | Type | Default | Description |
|---------------|----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------|
| `src` | `image` | `null` | Image source. See [here](images) for information on images. |
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Orientation of the slider. |
| `value` | `Script` | `null` | Script to run to get the slider value. Output must be a valid number. |
| `on_change` | `string [command]` | `null` | Command to execute when the slider changes. More on this [below](#commands). |
| `min` | `float` | `0` | Minimum slider value. |
| `max` | `float` | `100` | Maximum slider value. |
| `step` | `float` | - | The increment to change when scrolling with the mouse wheel. If left blank, will use the default determined by the environment. |
| `length` | `integer` | `null` | Slider length. GTK will automatically size if left unset. |
| `show_label` | `boolean` | `true` | Whether to show the value label above the slider. |
The example slider widget below shows a volume control for MPC,
which updates the server when changed, and polls the server for volume changes to keep the slider in sync.
```corn
$slider = {
type = "custom"
bar = [
{
type = "slider"
length = 100
max = 100
on_change="!mpc volume ${0%.*}"
value = "200:mpc volume | cut -d ':' -f2 | cut -d '%' -f1"
}
]
}
```
#### Progress
A progress bar.
> Type: `progress`
Note that `value` expects a numeric value **between 0-`max`** as output.
| Name | Type | Default | Description |
|---------------|----------------------------------------------------|--------------|---------------------------------------------------------------------------------|
| `src` | `image` | `null` | Image source. See [here](images) for information on images. |
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Orientation of the slider. |
| `value` | `Script` | `null` | Script to run to get the progress bar value. Output must be a valid percentage. |
| `max` | `float` | `100` | Maximum progress bar value. |
| `length` | `integer` | `null` | Slider length. GTK will automatically size if left unset. |
The example below shows progress for the current playing song in MPD,
and displays the elapsed/length timestamps as a label above:
```corn
$progress = {
type = "custom"
bar = [
{
type = "progress"
value = "500:mpc | sed -n 2p | awk '{ print $4 }' | grep -Eo '[0-9]+' || echo 0"
label = "{{500:mpc | sed -n 2p | awk '{ print $3 }'}} elapsed"
length = 200
}
]
}
```
### Label Attributes
> This is different to the `label` widget, although applies to it.
Any widgets with a `label` attribute support embedded scripts,
meaning you can interpolate text from scripts to dynamically show content.
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.
@@ -39,7 +156,7 @@ For example, the following label would output your system uptime, updated every
Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}
```
Both polling and watching mode are supported. For more information on script syntax, see [here](script).
Both polling and watching mode are supported. For more information on script syntax, see [here](scripts).
### Commands
@@ -50,12 +167,17 @@ 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`.
Some widgets provide a value when they run the command, such as `slider`.
This is passed as an argument and can be accessed using `$0`.
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
@@ -234,13 +356,9 @@ end:
```corn
let {
$power_menu = {
type = "custom"
class = "power-menu"
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
popup = [ {
$popup = {
type = "box"
orientation = "vertical"
widgets = [
@@ -254,7 +372,16 @@ let {
}
{ 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-}}"
}
} in {
end = [ $power_menu ]
@@ -265,7 +392,9 @@ let {
## Styling
Since the widgets are all custom, you can target them using `#name` and `.class`.
Since the widgets are all custom, you can use the `name` and `class` attributes, then target them using `#name` and `.class`.
The following top-level selector is always available:
| Selector | Description |
|-----------|-------------------------|

View File

@@ -8,11 +8,14 @@ Displays the title and/or icon of the currently focused window.
> 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 |
| `icon_theme` | `string` | `null` | GTK icon theme to use |
| `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>
@@ -25,7 +28,7 @@ Displays the title and/or icon of the currently focused window.
"show_icon": true,
"show_title": true,
"icon_size": 32,
"icon_theme": "Paper"
"truncate": "end"
}
]
}
@@ -43,7 +46,7 @@ type = "focused"
show_icon = true
show_title = true
icon_size = 32
icon_theme = "Paper"
truncate = "end"
```
</details>
@@ -57,7 +60,7 @@ end:
show_icon: true
show_title: true
icon_size: 32
icon_theme: "Paper"
truncate: "end"
```
</details>
@@ -73,7 +76,7 @@ end:
show_icon = true
show_title = true
icon_size = 32
icon_theme = "Paper"
truncate = "end"
}
]
}

70
docs/modules/Label.md Normal file
View File

@@ -0,0 +1,70 @@
Displays custom text, with the ability to embed [scripts](https://github.com/JakeStanger/ironbar/wiki/scripts#embedding).
## Configuration
> Type: `label`
| Name | Type | Default | Description |
|---------|----------|---------|-----------------------------------------|
| `label` | `string` | `null` | Text, optionally with embedded scripts. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "label",
"label": "random num: {{500:echo $RANDOM}}"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "label"
label = "random num: {{500:echo $RANDOM}}"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "label"
label: "random num: {{500:echo $RANDOM}}"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "label"
label = "random num: {{500:echo $RANDOM}}"
}
]
}
```
</details>
## Styling
| Selector | Description |
|--------------------------------|------------------------------------------------------------------------------------|
| `#label` | Label widget |

View File

@@ -3,7 +3,7 @@ Hovering over a program with multiple windows open shows a popup with each windo
Clicking an icon/popup item focuses or launches the program.
Optionally displays a launchable set of favourites.
![Screenshot showing several open applications, including a focused terminal.](https://user-images.githubusercontent.com/5057870/184540058-120e190e-2a45-4167-99c7-ed76482d1f16.png)
![Screenshot showing several open applications, including a popup showing multiple terminal windows.](https://f.jstanger.dev/github/ironbar/launcher.png)
## Configuration
@@ -14,7 +14,7 @@ Optionally displays a launchable set of favourites.
| `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. |
| `icon_theme` | `string` | `null` | GTK icon theme to use. |
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
<details>
<summary>JSON</summary>
@@ -29,8 +29,7 @@ Optionally displays a launchable set of favourites.
"discord"
],
"show_names": false,
"show_icons": true,
"icon_theme": "Paper"
"show_icons": true
}
]
}
@@ -49,7 +48,6 @@ type = "launcher"
favorites = ["firefox", "discord"]
show_names = false
show_icons = true
icon_theme = "Paper"
```
</details>
@@ -65,7 +63,6 @@ start:
- discord
show_names: false
show_icons: true
icon_theme: "Paper"
```
</details>
@@ -81,7 +78,6 @@ start:
favorites = [ "firefox" "discord" ]
show_names = false
show_icons = true
icon_theme = "Paper"
}
]

View File

@@ -5,21 +5,35 @@ 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://user-images.githubusercontent.com/5057870/184539664-a8f3ad5b-69c0-492d-a27d-82303c09a347.png)
![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` | `{icon} {title} / {artist}` | Format string for the widget. More info below. |
| `icons.play` | `string` | `` | Icon to show when playing. |
| `icons.pause` | `string` | `` | Icon to show when paused. |
| `icons.volume` | `string` | `墳` | Icon to show under popup volume slider. |
| `host` | `string` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. |
| `music_dir` | `string` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. |
| `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. |
| `show_status_icon` | `boolean` | `true` | Whether to show the play/pause icon on the widget. |
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
| `cover_image_size` | `integer` | `128` | Size to render album art image at inside popup. |
| `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>
@@ -30,7 +44,8 @@ in MPRIS mode, the widget will listen to all players and automatically detect/di
{
"type": "music",
"player_type": "mpd",
"format": "{icon} {title} / {artist}",
"format": "{title} / {artist}",
"truncate": "end",
"icons": {
"play": "",
"pause": ""
@@ -50,8 +65,9 @@ in MPRIS mode, the widget will listen to all players and automatically detect/di
[[start]]
type = "music"
player_type = "mpd"
format = "{icon} {title} / {artist}"
format = "{title} / {artist}"
music_dir = "/home/jake/Music"
truncate = "end"
[[start.icons]]
play = ""
@@ -67,7 +83,8 @@ pause = ""
start:
- type: "music"
player_type: "mpd"
format: "{icon} {title} / {artist}"
format: "{title} / {artist}"
truncate: "end"
icons:
play: ""
pause: ""
@@ -85,7 +102,8 @@ start:
{
type = "music"
player_type = "mpd"
format = "{icon} {title} / {artist}"
format = "{title} / {artist}"
truncate = "end"
icons.play = ""
icons.pause = ""
music_dir = "/home/jake/Music"
@@ -103,7 +121,6 @@ and will be replaced with values from the currently playing track:
| Token | Description |
|--------------|--------------------------------------|
| `{icon}` | Either `icons.play` or `icons.pause` |
| `{title}` | Title |
| `{album}` | Album name |
| `{artist}` | Artist name |
@@ -117,7 +134,7 @@ and will be replaced with values from the currently playing track:
## Styling
| Selector | Description |
|------------------------------------------|------------------------------------------|
|-------------------------------------|------------------------------------------|
| `#music` | Tray widget button |
| `#popup-music` | Popup box |
| `#popup-music #album-art` | Album art image inside popup box |
@@ -132,7 +149,8 @@ and will be replaced with values from the currently playing track:
| `#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-pause` | Play/pause 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 |

80
docs/modules/Upower.md Normal file
View File

@@ -0,0 +1,80 @@
Displays system power information such as the battery percentage, and estimated time to empty.
`TODO: ADD SCREENSHOT`
[//]: # (![Screenshot]&#40;https://user-images.githubusercontent.com/5057870/184540521-2278bdec-9742-46f0-9ac2-58a7b6f6ea1d.png&#41;)
## Configuration
> Type: `upower`
| Name | Type | Default | Description |
|----------|----------|-----------------|---------------------------------------------------|
| `format` | `string` | `{percentage}%` | Format string to use for the widget button label. |
<details>
<summary>JSON</summary>
```json
{
"end": [
{
"type": "upower",
"format": "{percentage}%"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[end]]
type = "upower"
format = "{percentage}%"
```
</details>
<details>
<summary>YAML</summary>
```yaml
end:
- type: "upower"
format: "{percentage}%"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
end = [
{
type = "upower"
format = "{percentage}%"
}
]
}
```
</details>
## Styling
| Selector | Description |
|---------------------------------|-----------------------------|
| `#upower` | Upower widget container. |
| `#upower #icon` | Upower widget battery icon. |
| `#upower #button` | Upower widget button. |
| `#upower #button #label` | Upower widget button label. |
| `#popup-upower` | Clock popup box. |
| `#popup-upower #upower-details` | Label inside the popup. |

View File

@@ -9,8 +9,9 @@ Shows all current workspaces. Clicking a workspace changes focus to it.
> Type: `workspaces`
| Name | Type | Default | Description |
|----------------|---------------------------|----------------|----------------------------------------------------------------------------------------------------------------------|
| `name_map` | `Map<string, string>` | `{}` | A map of actual workspace names to their display labels. Workspaces use their actual name if not present in the map. |
|----------------|-----------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `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. |
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
| `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. |

View File

@@ -4,55 +4,75 @@ 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}% "]
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"
// show-if = "500:[ $(($(date +%s) % 2)) -eq 0 ]"
show_if.cmd = "exit 0"
show_if.interval = 500
}
$clock = { type = "clock" }
$phone_battery = {
type = "script"
cmd = "/home/jake/bin/phone-battery"
show_if.cmd = "/home/jake/bin/phone-connected"
show_if.interval = 500
}
$log_tail = {
type = "script"
path = "tail -f /home/jake/.local/share/ironbar/error.log"
mode = "watch"
}
$clipboard = { type = "clipboard" max_items = 3 truncate.mode = "end" truncate.length = 50 }
$power_menu = {
type = "custom"
class = "power-menu"
$label = { type = "label" label = "random num: {{500:echo $RANDOM}}" }
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
// -- begin custom --
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
popup = [ {
$popup = {
type = "box"
orientation = "vertical"
widgets = [
@@ -64,18 +84,29 @@ let {
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
]
}
{ type = "label" name = "uptime" label = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}" }
{ 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 $power_menu $clock ]
$left = [ $workspaces $launcher $label ]
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clipboard $power_menu $clock ]
}
in {
anchor_to_edges = true
position = "top"
start = $left end = $right
position = "bottom"
icon_theme = "Paper"
start = $left
end = $right
}

View File

@@ -1,43 +1,135 @@
{
"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"
},
{
"type": "launcher",
"icon_theme": "Paper",
"favorites": [
"firefox",
"discord",
"Steam"
],
"show_names": false
}
],
"end": [
{
"type": "mpd"
"show_icons": true,
"show_names": false,
"type": "launcher"
},
{
"type": "mpd",
"host": "chloe:6600"
},
{
"path": "/home/jake/bin/phone-battery",
"type": "script"
},
{
"format": [
"{cpu_percent}% ",
"{memory_percent}% "
],
"type": "sys_info"
},
{
"type": "tray"
},
{
"type": "clock"
"label": "random num: {{500:echo $RANDOM}}",
"type": "label"
}
]
}

122
examples/config.toml Normal file
View File

@@ -0,0 +1,122 @@
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',
]
[[start]]
label = 'random num: {{500:echo $RANDOM}}'
type = 'label'

87
examples/config.yaml Normal file
View File

@@ -0,0 +1,87 @@
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
- label: 'random num: {{500:echo $RANDOM}}'
type: label

View File

@@ -1,29 +0,0 @@
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 ]
}
} in {
end = [ $power_menu { type = "clock" } ]
}

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;
}

View File

@@ -1,23 +0,0 @@
{
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}"
]
}
]
}

12
flake.lock generated
View File

@@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1672350804,
"narHash": "sha256-jo6zkiCabUBn3ObuKXHGqqORUMH27gYDIFFfLq5P4wg=",
"lastModified": 1680213900,
"narHash": "sha256-cIDr5WZIj3EkKyCgj/6j3HBH4Jj1W296z7HTcWj1aMA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "677ed08a50931e38382dbef01cba08a8f7eac8f6",
"rev": "e3652e0735fbec227f342712f180f4f21f0594f2",
"type": "github"
},
"original": {
@@ -45,11 +45,11 @@
]
},
"locked": {
"lastModified": 1672453260,
"narHash": "sha256-ruR2xo30Vn7kY2hAgg2Z2xrCvNePxck6mgR5a8u+zow=",
"lastModified": 1680229280,
"narHash": "sha256-9UoyQCeKUmHcsIdpsAgcz41LAIDkWhI2PhVDjckrpg0=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "176b6fd3dd3d7cea8d22ab1131364a050228d94c",
"rev": "aa480d799023141e1b9e5d6108700de63d9ad002",
"type": "github"
},
"original": {

View File

@@ -6,9 +6,6 @@
url = "github:oxalica/rust-overlay";
inputs.nixpkgs.follows = "nixpkgs";
};
#nci.url = "github:yusdacra/nix-cargo-integration";
#nci.inputs.nixpkgs.follows = "nixpkgs";
#nci.inputs.rust-overlay.follows = "rust-overlay";
};
outputs = {
self,
@@ -39,18 +36,16 @@
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 = rustPlatform.buildRustPackage {
pname = "ironbar";
version = self.rev or "dirty";
src = builtins.path {
name = "ironbar";
path = prev.lib.cleanSource ./.;
};
cargoDeps = rustPlatform.importCargoLock {lockFile = ./Cargo.lock;};
cargoLock.lockFile = ./Cargo.lock;
nativeBuildInputs = with prev; [pkg-config];
buildInputs = with prev; [gtk3 gdk-pixbuf gtk-layer-shell libxkbcommon];
ironbar = prev.callPackage ./nix/default.nix {
version = props.package.version + "+date=" + (mkDate (self.lastModifiedDate or "19700101")) + "_" + (self.shortRev or "dirty");
inherit rustPlatform;
};
};
packages = genSystems (
@@ -62,6 +57,18 @@
default = self.packages.${system}.ironbar;
}
);
apps = genSystems (system: let
pkgs = pkgsFor system;
in {
default = {
type = "app";
program = "${pkgs.ironbar}/bin/ironbar";
};
ironbar = {
type = "app";
program = "${pkgs.ironbar}/bin/ironbar";
};
});
devShells = genSystems (system: let
pkgs = pkgsFor system;
rust = mkRustToolchain pkgs;
@@ -74,6 +81,7 @@
gtk3
gtk-layer-shell
pkg-config
openssl
];
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
@@ -94,26 +102,34 @@
package = lib.mkOption {
type = with lib.types; package;
default = defaultIronbarPackage;
description = "The package for ironbar to use";
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";
description = "Whether to enable to systemd service for ironbar.";
};
style = lib.mkOption {
type = lib.types.lines;
default = "";
description = "The stylesheet to apply to ironbar";
description = "The stylesheet to apply to ironbar.";
};
config = lib.mkOption {
type = jsonFormat.type;
default = {};
description = "The config to pass to ironbar";
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 = lib.mkIf cfg.enable {
home.packages = [cfg.package];
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;
@@ -129,7 +145,7 @@
};
Service = {
Type = "simple";
ExecStart = "${cfg.package}/bin/ironbar";
ExecStart = "${pkg}/bin/ironbar";
};
Install.WantedBy = [
(lib.mkIf config.wayland.windowManager.hyprland.systemdIntegration "hyprland-session.target")
@@ -139,4 +155,8 @@
};
};
};
nixConfig = {
extra-substituters = ["https://jakestanger.cachix.org"];
extra-trusted-public-keys = ["jakestanger.cachix.org-1:VWJE7AWNe5/KOEvCQRxoE8UsI2Xs2nHULJ7TEjYm7mM="];
};
}

64
nix/default.nix Normal file
View File

@@ -0,0 +1,64 @@
{
gtk3,
gdk-pixbuf,
librsvg,
webp-pixbuf-loader,
gobject-introspection,
glib-networking,
glib,
shared-mime-info,
gsettings-desktop-schemas,
wrapGAppsHook,
gtk-layer-shell,
gnome,
libxkbcommon,
openssl,
pkg-config,
hicolor-icon-theme,
rustPlatform,
lib,
version ? "git",
features ? [],
}:
rustPlatform.buildRustPackage rec {
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 wrapGAppsHook gobject-introspection];
buildInputs = [gtk3 gdk-pixbuf glib gtk-layer-shell glib-networking shared-mime-info gnome.adwaita-icon-theme hicolor-icon-theme gsettings-desktop-schemas libxkbcommon openssl];
propagatedBuildInputs = [
gtk3
];
preFixup = ''
gappsWrapperArgs+=(
# Thumbnailers
--prefix XDG_DATA_DIRS : "${gdk-pixbuf}/share"
--prefix XDG_DATA_DIRS : "${librsvg}/share"
--prefix XDG_DATA_DIRS : "${webp-pixbuf-loader}/share"
--prefix XDG_DATA_DIRS : "${shared-mime-info}/share"
)
'';
passthru = {
updateScript = gnome.updateScript {
packageName = pname;
attrPath = "gnome.${pname}";
};
};
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 = "ironbar";
};
}

5
scripts/generate-examples.sh Executable file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env bash
corn examples/config.corn -t json > examples/config.json
corn examples/config.corn -t toml > examples/config.toml
corn examples/config.corn -t yaml > examples/config.yaml

View File

@@ -1,18 +1,14 @@
use crate::bridge_channel::BridgeChannel;
use crate::config::{BarPosition, CommonConfig, ModuleConfig};
use crate::dynamic_string::DynamicString;
use crate::modules::{Module, ModuleInfo, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::config::{BarPosition, MarginConfig, ModuleConfig};
use crate::modules::{create_module, wrap_widget, ModuleInfo, ModuleLocation};
use crate::popup::Popup;
use crate::script::{OutputStream, Script};
use crate::{await_sync, read_lock, send, write_lock, Config};
use crate::Config;
use color_eyre::Result;
use gtk::gdk::{EventMask, Monitor, ScrollDirection};
use gtk::gdk::Monitor;
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, EventBox, Orientation, Widget};
use gtk::{Application, ApplicationWindow, IconTheme, Orientation};
use std::sync::{Arc, RwLock};
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace};
use glib::signal::Inhibit;
use tracing::{debug, info};
/// Creates a new window for a bar,
/// sets it up and adds its widgets.
@@ -24,7 +20,13 @@ pub fn create_bar(
) -> Result<()> {
let win = ApplicationWindow::builder().application(app).build();
setup_layer_shell(&win, monitor, config.position, config.anchor_to_edges);
setup_layer_shell(
&win,
monitor,
config.position,
config.anchor_to_edges,
config.margin,
);
let orientation = config.position.get_orientation();
@@ -47,16 +49,16 @@ pub fn create_bar(
let center = create_container("center", orientation);
let end = create_container("end", orientation);
content.add(&start);
content.append(&start);
content.set_center_widget(Some(&center));
content.pack_end(&end, false, false, 0);
load_modules(&start, &center, &end, app, config, monitor, monitor_name)?;
win.add(&content);
win.append(&content);
win.connect_destroy_event(|_, _| {
info!("Shutting down");
gtk::main_quit();
// gtk::main_quit();
Inhibit(false)
});
@@ -79,16 +81,18 @@ fn setup_layer_shell(
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, 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);
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();
@@ -140,6 +144,11 @@ fn load_modules(
monitor: &Monitor,
output_name: &str,
) -> Result<()> {
let icon_theme = IconTheme::new();
if let Some(ref theme) = config.icon_theme {
icon_theme.set_custom_theme(Some(theme));
}
macro_rules! info {
($location:expr) => {
ModuleInfo {
@@ -148,23 +157,24 @@ fn load_modules(
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)?;
add_modules(left, modules, &info, config.popup_gap)?;
}
if let Some(modules) = config.center {
let info = info!(ModuleLocation::Center);
add_modules(center, modules, &info)?;
add_modules(center, modules, &info, config.popup_gap)?;
}
if let Some(modules) = config.end {
let info = info!(ModuleLocation::Right);
add_modules(right, modules, &info)?;
add_modules(right, modules, &info, config.popup_gap)?;
}
Ok(())
@@ -172,234 +182,49 @@ fn load_modules(
/// 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);
fn add_modules(
content: &gtk::Box,
modules: Vec<ModuleConfig>,
info: &ModuleInfo,
popup_gap: i32,
) -> Result<()> {
let popup = Popup::new(info, popup_gap);
let popup = Arc::new(RwLock::new(popup));
let orientation = info.bar_position.get_orientation();
macro_rules! add_module {
($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);
let widget = create_module(*$module, $id, &info, &Arc::clone(&popup))?;
let container = wrap_widget(&widget, common, orientation);
content.append(&container);
}};
}
for (id, config) in modules.into_iter().enumerate() {
match config {
#[cfg(feature = "clipboard")]
ModuleConfig::Clipboard(mut module) => add_module!(module, id),
#[cfg(feature = "clock")]
ModuleConfig::Clock(mut module) => add_module!(module, id),
ModuleConfig::Script(mut module) => add_module!(module, id),
ModuleConfig::SysInfo(mut module) => add_module!(module, id),
ModuleConfig::Focused(mut module) => add_module!(module, id),
ModuleConfig::Workspaces(mut module) => add_module!(module, id),
ModuleConfig::Tray(mut module) => add_module!(module, id),
ModuleConfig::Music(mut module) => add_module!(module, id),
ModuleConfig::Launcher(mut module) => add_module!(module, id),
ModuleConfig::Custom(mut module) => add_module!(module, id),
ModuleConfig::Focused(mut module) => add_module!(module, id),
ModuleConfig::Label(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 = "upower")]
ModuleConfig::Upower(mut module) => add_module!(module, id),
#[cfg(feature = "workspaces")]
ModuleConfig::Workspaces(mut module) => add_module!(module, id),
}
}
Ok(())
}
/// 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);
let channel = BridgeChannel::<ModuleUpdateEvent<TSend>>::new();
let (ui_tx, ui_rx) = mpsc::channel::<TRec>(16);
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,
{
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);
}
}
ModuleUpdateEvent::OpenPopup(geometry) => {
debug!("Opening popup for {} [#{}]", name, id);
let popup = read_lock!(popup);
popup.hide();
popup.show_content(id);
popup.show(geometry);
}
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| {
println!("{:?}", event.direction());
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)
});
}
}

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

@@ -0,0 +1,243 @@
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 = lock!(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) {
lock!(self.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

@@ -1,5 +1,6 @@
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::error::{ERR_CHANNEL_SEND, ERR_MUTEX_LOCK};
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;
@@ -9,10 +10,9 @@ use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::task::spawn_blocking;
use tracing::{error, info};
use tracing::{debug, error, info};
pub struct EventClient {
workspaces: Arc<Mutex<Vec<Workspace>>>,
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
}
@@ -21,12 +21,7 @@ impl EventClient {
fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16);
let workspaces = Arc::new(Mutex::new(vec![]));
// load initial list
Self::refresh_workspaces(&workspaces);
Self {
workspaces,
workspace_tx,
_workspace_rx: workspace_rx,
}
@@ -35,164 +30,198 @@ impl EventClient {
fn listen_workspace_events(&self) {
info!("Starting Hyprland event listener");
let workspaces = self.workspaces.clone();
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 workspaces = workspaces.clone();
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_added_handler(move |workspace_type, _state| {
Self::refresh_workspaces(&workspaces);
let _lock = lock!(lock);
debug!("Added workspace: {workspace_type:?}");
let workspace = Self::get_workspace(&workspaces, workspace_type);
workspace.map_or_else(
|| error!("Unable to locate workspace"),
|workspace| {
tx.send(WorkspaceUpdate::Add(workspace))
.expect(ERR_CHANNEL_SEND);
},
);
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 workspaces = workspaces.clone();
let tx = tx.clone();
let lock = lock.clone();
let active = active.clone();
event_listener.add_workspace_change_handler(move |workspace_type, _state| {
let prev_workspace = Self::get_focused_workspace(&workspaces);
let _lock = lock!(lock);
Self::refresh_workspaces(&workspaces);
let mut prev_workspace = lock!(active);
let workspace = Self::get_workspace(&workspaces, workspace_type);
debug!(
"Received workspace change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.id)
);
if let (Some(prev_workspace), Some(workspace)) = (prev_workspace, workspace) {
if prev_workspace.id != workspace.id {
tx.send(WorkspaceUpdate::Focus {
old: prev_workspace,
new: workspace.clone(),
})
.expect(ERR_CHANNEL_SEND);
}
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);
// there may be another type of update so dispatch that regardless of focus change
tx.send(WorkspaceUpdate::Update(workspace))
.expect(ERR_CHANNEL_SEND);
} else {
workspace.map_or_else(
|| {
error!("Unable to locate workspace");
}
});
}
{
let workspaces = workspaces.clone();
let tx = tx.clone();
event_listener.add_workspace_destroy_handler(move |workspace_type, _state| {
let workspace = Self::get_workspace(&workspaces, workspace_type);
workspace.map_or_else(
|| error!("Unable to locate workspace"),
|workspace| {
tx.send(WorkspaceUpdate::Remove(workspace))
.expect(ERR_CHANNEL_SEND);
},
);
Self::refresh_workspaces(&workspaces);
});
}
{
let workspaces = workspaces.clone();
let tx = tx.clone();
event_listener.add_workspace_moved_handler(move |event_data, _state| {
let workspace_type = event_data.1;
Self::refresh_workspaces(&workspaces);
let workspace = Self::get_workspace(&workspaces, workspace_type);
workspace.map_or_else(
|| error!("Unable to locate workspace"),
|workspace| {
tx.send(WorkspaceUpdate::Move(workspace))
.expect(ERR_CHANNEL_SEND);
// 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 workspaces = workspaces.clone();
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 prev_workspace = Self::get_focused_workspace(&workspaces);
let mut prev_workspace = lock!(active);
Self::refresh_workspaces(&workspaces);
debug!(
"Received active monitor change: {:?} -> {workspace_type:?}",
prev_workspace.as_ref().map(|w| &w.name)
);
let workspace = Self::get_workspace(&workspaces, workspace_type);
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(prev_workspace), Some(workspace)) = (prev_workspace, workspace) {
if prev_workspace.id != workspace.id {
tx.send(WorkspaceUpdate::Focus {
old: prev_workspace,
new: workspace,
})
.expect(ERR_CHANNEL_SEND);
}
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");
});
}
fn refresh_workspaces(workspaces: &Mutex<Vec<Workspace>>) {
let mut workspaces = workspaces.lock().expect(ERR_MUTEX_LOCK);
/// 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();
let active = HWorkspace::get_active().expect("Failed to get active workspace");
let new_workspaces = Workspaces::get()
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")
.collect()
.into_iter()
.map(|workspace| Workspace::from((workspace.id == active.id, workspace)));
workspaces.clear();
workspaces.extend(new_workspaces);
.find_map(|w| {
if w.name == name {
Some(Workspace::from((focused, w)))
} else {
None
}
})
}
fn get_workspace(workspaces: &Mutex<Vec<Workspace>>, id: WorkspaceType) -> Option<Workspace> {
let id_string = id_to_string(id);
let workspaces = workspaces.lock().expect(ERR_MUTEX_LOCK);
workspaces
.iter()
.find(|workspace| workspace.id == id_string)
.cloned()
}
fn get_focused_workspace(workspaces: &Mutex<Vec<Workspace>>) -> Option<Workspace> {
let workspaces = workspaces.lock().expect(ERR_MUTEX_LOCK);
workspaces
.iter()
.find(|workspace| workspace.focused)
.cloned()
/// 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) -> color_eyre::Result<()> {
fn focus(&self, id: String) -> Result<()> {
Dispatch::call(DispatchType::Workspace(
WorkspaceIdentifierWithSpecial::Name(&id),
))?;
@@ -205,13 +234,16 @@ impl WorkspaceClient for EventClient {
{
let tx = self.workspace_tx.clone();
let workspaces = self.workspaces.clone();
Self::refresh_workspaces(&workspaces);
let active_name = HWorkspace::get_active()
.map(|active| active.name)
.unwrap_or_default();
let workspaces = workspaces.lock().expect(ERR_MUTEX_LOCK);
let workspaces = Workspaces::get()
.expect("Failed to get workspaces")
.map(|w| Workspace::from((w.name == active_name, w)))
.collect();
tx.send(WorkspaceUpdate::Init(workspaces.clone()))
.expect(ERR_CHANNEL_SEND);
send!(tx, WorkspaceUpdate::Init(workspaces));
}
rx
@@ -230,10 +262,9 @@ pub fn get_client() -> &'static EventClient {
&CLIENT
}
fn id_to_string(id: WorkspaceType) -> String {
match id {
WorkspaceType::Unnamed(id) => id.to_string(),
WorkspaceType::Named(name) => name,
fn get_workspace_name(name: WorkspaceType) -> String {
match name {
WorkspaceType::Regular(name) => name,
WorkspaceType::Special(name) => name.unwrap_or_default(),
}
}
@@ -241,7 +272,7 @@ fn id_to_string(id: WorkspaceType) -> String {
impl From<(bool, hyprland::data::Workspace)> for Workspace {
fn from((focused, workspace): (bool, hyprland::data::Workspace)) -> Self {
Self {
id: id_to_string(workspace.id),
id: workspace.id.to_string(),
name: workspace.name,
monitor: workspace.monitor,
focused,

View File

@@ -1,13 +1,18 @@
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,
}
@@ -18,7 +23,9 @@ impl Display for Compositor {
f,
"{}",
match self {
#[cfg(feature = "workspaces+sway")]
Self::Sway => "Sway",
#[cfg(feature = "workspaces+hyprland")]
Self::Hyprland => "Hyprland",
Self::Unsupported => "Unsupported",
}
@@ -31,9 +38,15 @@ impl Compositor {
/// This is done by checking system env vars.
fn get_current() -> Self {
if std::env::var("SWAYSOCK").is_ok() {
Self::Sway
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() {
Self::Hyprland
cfg_if! {
if #[cfg(feature = "workspaces+hyprland")] { Self::Hyprland}
else { tracing::error!("Not compiled with Hyprland support"); Self::Unsupported }
}
} else {
Self::Unsupported
}
@@ -44,7 +57,9 @@ impl Compositor {
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")),
@@ -70,13 +85,13 @@ pub enum WorkspaceUpdate {
/// This is re-sent to all subscribers when a new subscription is created.
Init(Vec<Workspace>),
Add(Workspace),
Remove(Workspace),
Remove(String),
Update(Workspace),
Move(Workspace),
/// Declares focus moved from the old workspace to the new.
Focus {
old: Workspace,
new: Workspace,
old: String,
new: String,
},
}

View File

@@ -1,6 +1,5 @@
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::await_sync;
use crate::error::ERR_CHANNEL_SEND;
use crate::{await_sync, send};
use async_once::AsyncOnce;
use color_eyre::Report;
use futures_util::StreamExt;
@@ -75,7 +74,7 @@ impl WorkspaceClient for SwayEventClient {
let event =
WorkspaceUpdate::Init(workspaces.into_iter().map(Workspace::from).collect());
tx.send(event).expect(ERR_CHANNEL_SEND);
send!(tx, event);
});
}
@@ -132,12 +131,24 @@ impl From<WorkspaceEvent> for WorkspaceUpdate {
WorkspaceChange::Init => {
Self::Add(event.current.expect("Missing current workspace").into())
}
WorkspaceChange::Empty => {
Self::Remove(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").into(),
new: event.current.expect("Missing current workspace").into(),
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())

View File

@@ -1,4 +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;
#[cfg(feature = "upower")]
pub mod upower;
pub mod wayland;

View File

@@ -4,7 +4,9 @@ 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)]
@@ -22,7 +24,7 @@ pub struct Track {
pub disc: Option<u64>,
pub genre: Option<String>,
pub track: Option<u64>,
pub cover_path: Option<PathBuf>,
pub cover_path: Option<String>,
}
#[derive(Clone, Debug)]

View File

@@ -121,12 +121,16 @@ impl MpdClient {
fn convert_song(song: &Song, music_dir: &Path) -> Track {
let (track, disc) = song.number();
let cover_path = music_dir.join(
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),
@@ -136,7 +140,7 @@ impl MpdClient {
genre: try_get_first_tag(song, &Tag::Genre).map(std::string::ToString::to_string),
disc: Some(disc),
track: Some(track),
cover_path: Some(cover_path),
cover_path,
}
}
}

View File

@@ -1,11 +1,10 @@
use super::{MusicClient, PlayerUpdate, Status, Track};
use crate::clients::music::PlayerState;
use crate::error::ERR_MUTEX_LOCK;
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::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::thread::sleep;
use std::time::Duration;
@@ -44,7 +43,7 @@ impl Client {
.find_all()
.expect("Failed to connect to D-Bus");
let mut players_list_val = players_list.lock().expect(ERR_MUTEX_LOCK);
let mut players_list_val = lock!(players_list);
for player in players {
let identity = player.identity();
@@ -57,8 +56,7 @@ impl Client {
.expect("Failed to connect to D-Bus");
{
let mut current_player =
current_player.lock().expect(ERR_MUTEX_LOCK);
let mut current_player = lock!(current_player);
if status == PlaybackStatus::Playing || current_player.is_none() {
debug!("Setting active player to '{identity}'");
@@ -108,22 +106,19 @@ impl Client {
trace!("Received player event from '{identity}': {event:?}");
match event {
Ok(Event::PlayerShutDown) => {
current_player.lock().expect(ERR_MUTEX_LOCK).take();
players.lock().expect(ERR_MUTEX_LOCK).remove(identity);
lock!(current_player).take();
lock!(players).remove(identity);
break;
}
Ok(Event::Playing) => {
current_player
.lock()
.expect(ERR_MUTEX_LOCK)
.replace(identity.to_string());
lock!(current_player).replace(identity.to_string());
if let Err(err) = Self::send_update(&player, &tx) {
error!("{err:?}");
}
}
Ok(_) => {
let current_player = current_player.lock().expect(ERR_MUTEX_LOCK);
let current_player = lock!(current_player);
let current_player = current_player.as_ref();
if let Some(current_player) = current_player {
if current_player == identity {
@@ -171,15 +166,13 @@ impl Client {
let track = Track::from(metadata);
let player_update = PlayerUpdate::Update(Box::new(Some(track)), status);
tx.send(player_update)
.expect("Failed to send player update");
send!(tx, player_update);
Ok(())
}
fn get_player(&self) -> Option<Player> {
let player_name = self.current_player.lock().expect(ERR_MUTEX_LOCK);
let player_name = lock!(self.current_player);
let player_name = player_name.as_ref();
player_name.and_then(|player_name| {
@@ -266,10 +259,7 @@ impl From<Metadata> for Track {
.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(|path| path.replace("file://", ""))
.map(PathBuf::from),
cover_path: value.art_url().map(|s| s.to_string()),
}
}
}

View File

@@ -1,15 +1,25 @@
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::debug;
use tracing::{debug, error, trace};
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 {
@@ -20,19 +30,43 @@ impl TrayEventReceiver {
let tray = StatusNotifierWatcher::new(rx).await?;
let mut host = tray.create_notifier_host("ironbar").await?;
let b_tx2 = b_tx.clone();
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 {
b_tx2.send(message)?;
trace!("Received message: {message:?} ");
send!(b_tx, message.clone());
let mut tray = lock!(tray);
match message {
NotifierItemMessage::Update {
address,
item,
menu,
} => {
debug!("Adding item with address '{address}'");
tray.insert(address, (item, menu));
}
NotifierItemMessage::Remove { address } => {
debug!("Removing item with address '{address}'");
tray.remove(&address);
}
}
}
Ok::<(), broadcast::error::SendError<NotifierItemMessage>>(())
});
}
Ok(Self {
tx,
b_tx,
_b_rx: b_rx,
tray,
})
}
@@ -42,7 +76,20 @@ impl TrayEventReceiver {
mpsc::Sender<NotifierItemCommand>,
broadcast::Receiver<NotifierItemMessage>,
) {
(self.tx.clone(), self.b_tx.subscribe())
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)
}
}
@@ -58,11 +105,14 @@ lazy_static! {
let tray = TrayEventReceiver::new().await;
if tray.is_ok() || retries == MAX_RETRIES {
break tray;
match tray {
Ok(tray) => break Some(tray),
Err(err) => error!("{:?}", Report::new(err).wrap_err(format!("Failed to create StatusNotifierWatcher (attempt {retries})")))
}
debug!("Failed to create StatusNotifierWatcher (attempt {retries})");
if retries == MAX_RETRIES {
break None;
}
};
value.expect("Failed to create StatusNotifierWatcher")

40
src/clients/upower.rs Normal file
View File

@@ -0,0 +1,40 @@
use async_once::AsyncOnce;
use lazy_static::lazy_static;
use std::sync::Arc;
use upower_dbus::UPowerProxy;
use zbus::fdo::PropertiesProxy;
lazy_static! {
static ref DISPLAY_PROXY: AsyncOnce<Arc<PropertiesProxy<'static>>> = AsyncOnce::new(async {
let dbus = zbus::Connection::system()
.await
.expect("failed to create connection to system bus");
let device_proxy = UPowerProxy::new(&dbus)
.await
.expect("failed to create upower proxy");
let display_device = device_proxy
.get_display_device()
.await
.unwrap_or_else(|_| panic!("failed to get display device for {device_proxy:?}"));
let path = display_device.path().to_owned();
let proxy = PropertiesProxy::builder(&dbus)
.destination("org.freedesktop.UPower")
.expect("failed to set proxy destination address")
.path(path)
.expect("failed to set proxy path")
.cache_properties(zbus::CacheProperties::No)
.build()
.await
.expect("failed to build proxy");
Arc::new(proxy)
});
}
pub async fn get_display_proxy() -> &'static PropertiesProxy<'static> {
DISPLAY_PROXY.get().await
}

View File

@@ -1,31 +1,61 @@
use super::toplevel::{ToplevelEvent, ToplevelInfo};
use super::toplevel_manager::listen_for_toplevels;
use super::ToplevelChange;
use super::{Env, ToplevelHandler};
use crate::{error as err, send, write_lock};
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;
use smithay_client_toolkit::{new_default_environment, WaylandSource};
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 std::time::Duration;
use tokio::sync::{broadcast, oneshot};
use tokio::task::spawn_blocking;
use tracing::{error, trace};
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 {
@@ -35,21 +65,44 @@ impl WaylandClient {
let (toplevel_tx, toplevel_rx) = broadcast::channel(32);
let toplevel_tx2 = toplevel_tx.clone();
let toplevels = Arc::new(RwLock::new(IndexMap::new()));
let toplevels2 = toplevels.clone();
// `queue` is not send so we need to handle everything inside the task
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) =
new_default_environment!(Env, fields = [toplevel: ToplevelHandler::init()])
.expect("Failed to connect to Wayland compositor");
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
@@ -58,30 +111,56 @@ impl WaylandClient {
.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 _listener = listen_for_toplevels(env, move |handle, event, _ddata| {
trace!("Received toplevel event: {:?}", event);
if event.change == ToplevelChange::Close {
write_lock!(toplevels2).remove(&event.toplevel.id);
} else {
write_lock!(toplevels2)
.insert(event.toplevel.id, (event.toplevel.clone(), handle));
}
send!(toplevel_tx2, event);
let _toplevel_listener = listen_for_toplevels(&env, move |handle, event, _ddata| {
super::wlr_foreign_toplevel::update_toplevels(
&toplevels,
handle,
event,
&toplevel_tx,
);
});
let mut event_loop =
calloop::EventLoop::<()>::try_new().expect("Failed to create new event loop");
WaylandSource::new(queue)
.quick_insert(event_loop.handle())
.expect("Failed to insert event loop into wayland event queue");
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 {
// TODO: Avoid need for duration here - can we force some event when sending requests?
if let Err(err) = event_loop.dispatch(Duration::from_millis(50), &mut ()) {
if let Err(err) = event_loop.dispatch(None, &mut data) {
error!(
"{:?}",
Report::new(err).wrap_err("Failed to dispatch pending wayland events")
@@ -90,6 +169,18 @@ impl WaylandClient {
}
});
// 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);
@@ -97,9 +188,14 @@ impl WaylandClient {
Self {
outputs,
seats,
#[cfg(feature = "clipboard")]
clipboard,
toplevels,
toplevel_tx,
_toplevel_rx: toplevel_rx,
#[cfg(feature = "clipboard")]
clipboard_tx,
request_tx: ev_tx,
}
}
@@ -107,6 +203,26 @@ impl WaylandClient {
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();
@@ -115,4 +231,57 @@ impl WaylandClient {
.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

@@ -1,21 +1,32 @@
mod client;
mod toplevel;
mod toplevel_manager;
extern crate smithay_client_toolkit as sctk;
mod wlr_foreign_toplevel;
use std::collections::HashMap;
use async_once::AsyncOnce;
use lazy_static::lazy_static;
pub use toplevel::{ToplevelChange, ToplevelEvent, ToplevelInfo};
use toplevel_manager::{ToplevelHandler, ToplevelHandling, ToplevelStatusListener};
use wayland_client::{Attached, DispatchData, Interface};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
};
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)]
@@ -25,21 +36,32 @@ enum LazyGlobal<I: Interface> {
Bound(Attached<I>),
}
sctk::default_environment!(Env,
pub struct DData {
env: Environment<Env>,
offer_tokens: HashMap<u128, RegistrationToken>,
}
cfg_if! {
if #[cfg(feature = "clipboard")] {
default_environment!(Env,
fields = [
toplevel: ToplevelHandler
toplevel: ToplevelHandler,
data_control_device: DataControlDeviceHandler
],
singles = [
ZwlrForeignToplevelManagerV1 => toplevel
ZwlrForeignToplevelManagerV1 => toplevel,
ZwlrDataControlManagerV1 => data_control_device
],
);
} else {
default_environment!(Env,
fields = [
toplevel: ToplevelHandler,
],
singles = [
ZwlrForeignToplevelManagerV1 => toplevel,
],
);
impl ToplevelHandling for Env {
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
where
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
{
self.toplevel.listen(f)
}
}

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

@@ -1,9 +1,8 @@
use super::toplevel::{Toplevel, ToplevelEvent};
use super::LazyGlobal;
use super::handle::{Toplevel, ToplevelEvent};
use crate::wayland::LazyGlobal;
use smithay_client_toolkit::environment::{Environment, GlobalHandler};
use std::cell::RefCell;
use std::rc;
use std::rc::Rc;
use std::rc::{self, Rc};
use tracing::warn;
use wayland_client::protocol::wl_registry::WlRegistry;
use wayland_client::{Attached, DispatchData};
@@ -155,7 +154,7 @@ impl ToplevelHandling for ToplevelHandler {
}
}
pub fn listen_for_toplevels<E, F>(env: Environment<E>, f: F) -> ToplevelStatusListener
pub fn listen_for_toplevels<E, F>(env: &Environment<E>, f: F) -> ToplevelStatusListener
where
E: ToplevelHandling,
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,

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);
}

162
src/config/common.rs Normal file
View File

@@ -0,0 +1,162 @@
use glib::signal::Inhibit;
use crate::dynamic_string::DynamicString;
use crate::script::{Script, ScriptInput};
use crate::send;
use gtk::gdk::ScrollDirection;
use gtk::prelude::*;
use gtk::{GestureClick, Orientation, Revealer, RevealerTransitionType, Widget};
use serde::Deserialize;
use tokio::spawn;
use tracing::trace;
/// Common configuration options
/// which can be set on every module.
#[derive(Debug, Deserialize, Clone)]
pub struct CommonConfig {
pub show_if: Option<ScriptInput>,
pub transition_type: Option<TransitionType>,
pub transition_duration: Option<u32>,
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 on_mouse_enter: Option<ScriptInput>,
pub on_mouse_exit: Option<ScriptInput>,
pub tooltip: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum TransitionType {
None,
Crossfade,
SlideStart,
SlideEnd,
}
impl TransitionType {
pub fn to_revealer_transition_type(&self, orientation: Orientation) -> RevealerTransitionType {
match (self, orientation) {
(TransitionType::SlideStart, Orientation::Horizontal) => {
RevealerTransitionType::SlideLeft
}
(TransitionType::SlideStart, Orientation::Vertical) => RevealerTransitionType::SlideUp,
(TransitionType::SlideEnd, Orientation::Horizontal) => {
RevealerTransitionType::SlideRight
}
(TransitionType::SlideEnd, Orientation::Vertical) => RevealerTransitionType::SlideDown,
(TransitionType::Crossfade, _) => RevealerTransitionType::Crossfade,
_ => RevealerTransitionType::None,
}
}
}
impl CommonConfig {
/// Configures the module's container according to the common config options.
pub fn install<W: IsA<Widget>>(mut self, widget: &W, revealer: &Revealer) {
self.install_show_if(widget, revealer);
let left_click_script = self.on_click_left.map(Script::new_polling);
let middle_click_script = self.on_click_middle.map(Script::new_polling);
let right_click_script = self.on_click_right.map(Script::new_polling);
let gesture = GestureClick::new();
gesture.connect_pressed(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());
script.run_as_oneshot(None);
}
});
let scroll_up_script = self.on_scroll_up.map(Script::new_polling);
let scroll_down_script = self.on_scroll_down.map(Script::new_polling);
widget.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());
script.run_as_oneshot(None);
}
Inhibit(false)
});
macro_rules! install_oneshot {
($option:expr, $method:ident) => {
$option.map(Script::new_polling).map(|script| {
widget.$method(move |_, _| {
script.run_as_oneshot(None);
Inhibit(false)
});
})
};
}
install_oneshot!(self.on_mouse_enter, connect_enter_notify_event);
install_oneshot!(self.on_mouse_exit, connect_leave_notify_event);
if let Some(tooltip) = self.tooltip {
let container = widget.clone();
DynamicString::new(&tooltip, move |string| {
container.set_tooltip_text(Some(&string));
Continue(true)
});
}
}
fn install_show_if<W: IsA<Widget>>(&mut self, widget: &W, revealer: &Revealer) {
self.show_if.take().map_or_else(
|| {
widget.set_visible(true)
},
|show_if| {
let script = Script::new_polling(show_if);
let widget = widget.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(None, |_, success| {
send!(tx, success);
})
.await;
});
{
let revealer = revealer.clone();
let container = container.clone();
rx.attach(None, move |success| {
if success {
container.show_all();
}
revealer.set_reveal_child(success);
Continue(true)
});
}
revealer.connect_child_revealed_notify(move |revealer| {
if !revealer.reveals_child() {
container.hide()
}
});
},
);
}
}

View File

@@ -1,18 +1,12 @@
use super::{BarPosition, Config, MonitorConfig};
use color_eyre::eyre::Result;
use color_eyre::eyre::{ContextCompat, WrapErr};
use color_eyre::{Help, Report};
use dirs::config_dir;
use gtk::Orientation;
use serde::{Deserialize, Deserializer};
use std::path::{Path, PathBuf};
use std::{env, fs};
use tracing::instrument;
// 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) -> std::result::Result<Self, D::Error>
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
@@ -62,79 +56,3 @@ impl BarPosition {
}
}
}
impl Config {
/// Attempts to load the config file from file,
/// parse it and return a new instance of `Self`.
pub fn load() -> Result<Self> {
let config_path = env::var("IRONBAR_CONFIG").map_or_else(
|_| Self::try_find_config(),
|config_path| {
let path = PathBuf::from(config_path);
if path.exists() {
Ok(path)
} else {
Err(Report::msg(format!(
"Specified config file does not exist: {}",
path.display()
))
.note("Config file was specified using `IRONBAR_CONFIG` environment variable"))
}
},
)?;
Self::load_file(&config_path)
}
/// Attempts to discover the location of the config file
/// by checking each valid format's extension.
///
/// Returns the path of the first valid match, if any.
#[instrument]
fn try_find_config() -> Result<PathBuf> {
let config_dir = config_dir().wrap_err("Failed to locate user config dir")?;
let extensions = vec!["json", "toml", "yaml", "yml", "corn"];
let file = extensions.into_iter().find_map(|extension| {
let full_path = config_dir
.join("ironbar")
.join(format!("config.{extension}"));
if Path::exists(&full_path) {
Some(full_path)
} else {
None
}
});
file.map_or_else(
|| {
Err(Report::msg("Could not find config file")
.suggestion("Ironbar does not include a configuration out of the box")
.suggestion("A guide on writing a config can be found on the wiki:")
.suggestion("https://github.com/JakeStanger/ironbar/wiki/configuration-guide"))
},
Ok,
)
}
/// Loads the config file at the specified path
/// and parses it into `Self` based on its extension.
fn load_file(path: &Path) -> Result<Self> {
let file = fs::read(path).wrap_err("Failed to read config file")?;
let extension = path
.extension()
.unwrap_or_default()
.to_str()
.unwrap_or_default();
match extension {
"json" => serde_json::from_slice(&file).wrap_err("Invalid JSON config"),
"toml" => toml::from_slice(&file).wrap_err("Invalid TOML config"),
"yaml" | "yml" => serde_yaml::from_slice(&file).wrap_err("Invalid YAML config"),
"corn" => libcorn::from_slice(&file).wrap_err("Invalid Corn config"),
_ => unreachable!(),
}
}
}

View File

@@ -1,43 +1,54 @@
mod common;
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::label::LabelModule;
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 = "upower")]
use crate::modules::upower::UpowerModule;
#[cfg(feature = "workspaces")]
use crate::modules::workspaces::WorkspacesModule;
use crate::script::ScriptInput;
use serde::Deserialize;
use std::collections::HashMap;
#[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>,
}
pub use self::common::{CommonConfig, TransitionType};
pub use self::truncate::{EllipsizeMode, TruncateMode};
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ModuleConfig {
Clock(ClockModule),
Music(MusicModule),
Tray(TrayModule),
Workspaces(WorkspacesModule),
SysInfo(SysInfoModule),
Launcher(LauncherModule),
Script(ScriptModule),
Focused(FocusedModule),
Custom(CustomModule),
#[cfg(feature = "clock")]
Clipboard(Box<ClipboardModule>),
#[cfg(feature = "clock")]
Clock(Box<ClockModule>),
Custom(Box<CustomModule>),
Focused(Box<FocusedModule>),
Label(Box<LabelModule>),
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 = "upower")]
Upower(Box<UpowerModule>),
#[cfg(feature = "workspaces")]
Workspaces(Box<WorkspacesModule>),
}
#[derive(Debug, Clone)]
@@ -61,14 +72,33 @@ impl Default for BarPosition {
}
}
#[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 = "default_bar_position")]
#[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,
#[serde(default = "default_popup_gap")]
pub popup_gap: i32,
/// GTK icon theme to use.
pub icon_theme: Option<String>,
pub start: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>,
@@ -77,14 +107,14 @@ pub struct Config {
pub monitors: Option<HashMap<String, MonitorConfig>>,
}
const fn default_bar_position() -> BarPosition {
BarPosition::Bottom
}
const fn default_bar_height() -> i32 {
42
}
const fn default_popup_gap() -> i32 {
5
}
pub const fn default_false() -> bool {
false
}

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);
}
}
}

View File

@@ -1,6 +1,3 @@
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme};
use std::collections::HashMap;
use std::fs::File;
use std::io;
@@ -67,7 +64,7 @@ fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
}
/// Attempts to get the icon name from the app's `.desktop` file.
fn get_desktop_icon_name(app_id: &str) -> Option<String> {
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| {
@@ -75,65 +72,3 @@ fn get_desktop_icon_name(app_id: &str) -> Option<String> {
})
})
}
enum IconLocation {
Theme(String),
File(PathBuf),
}
/// Attempts to get the location of an icon.
///
/// Handles icons that are part of a GTK theme, icons specified as path
/// and icons for steam games.
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();
return match dirs::data_dir() {
Some(dir) => {
let path = dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
));
return Some(IconLocation::File(path));
}
None => None,
};
}
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);
icon.map_or(None, |icon| icon)
}
Some(IconLocation::File(path)) => Pixbuf::from_file_at_scale(path, size, size, true).ok(),
None => None,
}
}

View File

@@ -1,90 +1,63 @@
use crate::script::{OutputStream, Script};
use crate::{lock, send};
use gtk::prelude::*;
use indexmap::IndexMap;
use std::sync::{Arc, Mutex};
use tokio::spawn;
/// A segment of a dynamic string,
/// containing either a static string
/// or a script.
#[derive(Debug)]
enum DynamicStringSegment {
Static(String),
Dynamic(Script),
}
/// A string with embedded scripts for dynamic content.
pub struct DynamicString;
impl DynamicString {
/// Creates a new dynamic string, based off the input template.
/// Runs `f` with the compiled string each time one of the scripts updates.
///
/// # Example
///
/// ```rs
/// DynamicString::new(&text, move |string| {
/// label.set_markup(&string);
/// Continue(true)
/// });
/// ```
pub fn new<F>(input: &str, f: F) -> Self
where
F: FnMut(String) -> Continue + 'static,
{
let mut segments = vec![];
let segments = Self::parse_input(input);
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(IndexMap::new()));
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).insert(i, 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, _)| {
.run(None, |out, _| {
if let OutputStream::Stdout(out) = out {
let mut label_parts = lock!(label_parts);
label_parts.insert(i, out);
let string = label_parts
.iter()
.map(|(_, part)| part.as_str())
.collect::<String>();
let _ = std::mem::replace(&mut label_parts[i], out);
let string = label_parts.join("");
send!(tx, string);
}
})
@@ -96,11 +69,7 @@ impl DynamicString {
// initialize
{
let label_parts = lock!(label_parts)
.iter()
.map(|(_, part)| part.as_str())
.collect::<String>();
let label_parts = lock!(label_parts).join("");
send!(tx, label_parts);
}
@@ -108,6 +77,66 @@ impl DynamicString {
Self
}
/// Parses the input string into static and dynamic segments
fn parse_input(input: &str) -> Vec<DynamicStringSegment> {
if !input.contains("{{") {
return vec![DynamicStringSegment::Static(input.to_string())];
}
let mut segments = vec![];
let mut chars = input.chars().collect::<Vec<_>>();
while !chars.is_empty() {
let char_pair = if chars.len() > 1 {
Some(&chars[..=1])
} else {
None
};
let (token, skip) = if let Some(['{', '{']) = char_pair {
const SKIP_BRACKETS: usize = 4; // two braces either side
let str = chars
.windows(2)
.skip(2)
.take_while(|win| win != &['}', '}'])
.map(|w| w[0])
.collect::<String>();
let len = str.len();
(
DynamicStringSegment::Dynamic(Script::from(str.as_str())),
len + SKIP_BRACKETS,
)
} else {
let mut str = chars
.windows(2)
.take_while(|win| win != &['{', '{'])
.map(|w| w[0])
.collect::<String>();
// if segment is at end of string, last char gets missed above due to uneven window.
if chars.len() == str.len() + 1 {
let remaining_char = *chars.get(str.len()).expect("Failed to find last char");
str.push(remaining_char);
}
let len = str.len();
(DynamicStringSegment::Static(str), len)
};
// quick runtime check to make sure the parser is working as expected
assert_ne!(skip, 0);
segments.push(token);
chars.drain(..skip);
}
segments
}
}
#[cfg(test)]

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.append(&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.append(&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;

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

@@ -0,0 +1,211 @@
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, image.scale_factor())
}
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, scale: i32) -> Result<Pixbuf> {
let pixbuf =
match theme.lookup_icon_for_scale(name, self.size, scale, 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 res = reqwest::get(url).await?;
let status = res.status();
if status.is_success() {
let bytes = res.bytes().await?;
Ok(glib::Bytes::from_owned(bytes))
} else {
Err(Report::msg(format!(
"Received non-success HTTP code ({status})"
)))
}
}
}

View File

@@ -1,10 +1,13 @@
#![doc = include_str!("../README.md")]
mod bar;
mod bridge_channel;
mod clients;
mod config;
mod desktop_file;
mod dynamic_string;
mod error;
mod icon;
mod image;
mod logging;
mod macros;
mod modules;
@@ -18,7 +21,7 @@ use crate::style::load_css;
use color_eyre::eyre::Result;
use color_eyre::Report;
use dirs::config_dir;
use gtk::gdk::Display;
use gtk::gdk::{Display, Monitor};
use gtk::prelude::*;
use gtk::Application;
use std::env;
@@ -31,6 +34,7 @@ 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");
@@ -56,13 +60,19 @@ async fn main() -> Result<()> {
|display| display,
);
let config = match Config::load() {
let config_res = env::var("IRONBAR_CONFIG").map_or_else(
|_| ConfigLoader::new("ironbar").find_and_load(),
ConfigLoader::load,
);
let config = match config_res {
Ok(config) => config,
Err(err) => {
error!("{:?}", err);
exit(ExitCode::Config as i32)
}
};
debug!("Loaded config file");
if let Err(err) = create_bars(app, &display, wayland_client, &config) {
@@ -110,7 +120,10 @@ fn create_bars(
debug!("Received {} outputs from Wayland", outputs.len());
debug!("Outputs: {:?}", outputs);
let num_monitors = display.n_monitors();
for monitor in display.monitors().iter::<Monitor>() {
let monitor = monitor.unwrap();
}
for i in 0..num_monitors {
let monitor = display

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

@@ -0,0 +1,321 @@
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, Image, Label, Orientation, RadioButton, Widget};
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::Arc;
use glib::signal::Inhibit;
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_icon_size")]
icon_size: i32,
#[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_icon_size() -> i32 {
32
}
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, self.icon_size);
button.style_context().add_class("btn");
button.connect_clicked(move |button| {
let pos = Popup::widget_geometry(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.append(&entries);
let hidden_option = RadioButton::new();
entries.append(&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.append(&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.append(&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.append(&button_wrapper);
row.pack_end(&remove_button, false, false, 0);
entries.append(&row);
entries.reorder_child(&row, 0);
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)
});
}
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())
}

View File

@@ -63,13 +63,13 @@ impl Module<Button> for ClockModule {
let button = Button::new();
let label = Label::new(None);
label.set_angle(info.bar_position.get_angle());
button.add(&label);
button.append(&label);
let orientation = info.bar_position.get_orientation();
button.connect_clicked(move |button| {
try_send!(
context.tx,
ModuleUpdateEvent::TogglePopup(Popup::button_pos(button, orientation))
ModuleUpdateEvent::TogglePopup(Popup::widget_geometry(button, orientation))
);
});
@@ -82,7 +82,7 @@ impl Module<Button> for ClockModule {
});
}
let popup = self.into_popup(context.controller_tx, context.popup_rx);
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: button,
@@ -94,6 +94,7 @@ impl Module<Button> for ClockModule {
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
let container = gtk::Box::builder()
.orientation(Orientation::Vertical)
@@ -106,10 +107,10 @@ impl Module<Button> for ClockModule {
.build();
let format = "%H:%M:%S";
container.add(&clock);
container.append(&clock);
let calendar = Calendar::builder().name("calendar").build();
container.add(&calendar);
container.append(&calendar);
{
rx.attach(None, move |date| {

View File

@@ -1,255 +0,0 @@
use crate::config::CommonConfig;
use crate::dynamic_string::DynamicString;
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, 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>,
}
/// Supported GTK widget types
#[derive(Debug, Deserialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum WidgetType {
Box,
Label,
Button,
}
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) {
match self.widget_type {
WidgetType::Box => parent.add(&self.into_box(&tx, bar_orientation)),
WidgetType::Label => parent.add(&self.into_label()),
WidgetType::Button => parent.add(&self.into_button(tx, bar_orientation)),
}
}
/// Creates a `gtk::Box` from this widget
fn into_box(self, tx: &Sender<ExecEvent>, bar_orientation: Orientation) -> 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 {
widgets
.into_iter()
.for_each(|widget| widget.add_to(&container, tx.clone(), bar_orientation));
}
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
}
}
#[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);
});
let popup = self.into_popup(context.controller_tx, context.popup_rx);
Ok(ModuleWidget {
widget: container,
popup,
})
}
fn into_popup(
self,
tx: Sender<Self::ReceiveMessage>,
_rx: glib::Receiver<Self::SendMessage>,
) -> 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 {
popup
.into_iter()
.for_each(|widget| widget.add_to(&container, tx.clone(), Orientation::Horizontal));
}
Some(container)
}
}

36
src/modules/custom/box.rs Normal file
View File

@@ -0,0 +1,36 @@
use super::{try_get_orientation, CustomWidget, CustomWidgetContext};
use crate::build;
use crate::modules::custom::WidgetConfig;
use gtk::prelude::*;
use gtk::Orientation;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct BoxWidget {
name: Option<String>,
class: Option<String>,
orientation: Option<String>,
widgets: Option<Vec<WidgetConfig>>,
}
impl CustomWidget for BoxWidget {
type Widget = gtk::Box;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let container = build!(self, Self::Widget);
if let Some(orientation) = self.orientation {
container.set_orientation(
try_get_orientation(&orientation).unwrap_or(Orientation::Horizontal),
);
}
if let Some(widgets) = self.widgets {
for widget in widgets {
widget.widget.add_to(&container, context, widget.common);
}
}
container
}
}

View File

@@ -0,0 +1,52 @@
use super::{CustomWidget, CustomWidgetContext, ExecEvent};
use crate::dynamic_string::DynamicString;
use crate::popup::Popup;
use crate::{build, try_send};
use gtk::prelude::*;
use gtk::{Button, Label};
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct ButtonWidget {
name: Option<String>,
class: Option<String>,
label: Option<String>,
on_click: Option<String>,
}
impl CustomWidget for ButtonWidget {
type Widget = Button;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let button = build!(self, Self::Widget);
if let Some(text) = self.label {
let label = Label::new(None);
label.set_use_markup(true);
button.append(&label);
DynamicString::new(&text, move |string| {
label.set_markup(&string);
Continue(true)
});
}
if let Some(exec) = self.on_click {
let bar_orientation = context.bar_orientation;
let tx = context.tx.clone();
button.connect_clicked(move |button| {
try_send!(
tx,
ExecEvent {
cmd: exec.clone(),
args: None,
geometry: Popup::widget_geometry(button, bar_orientation),
}
);
});
}
button
}
}

View File

@@ -0,0 +1,47 @@
use super::{CustomWidget, CustomWidgetContext};
use crate::build;
use crate::dynamic_string::DynamicString;
use crate::image::ImageProvider;
use gtk::prelude::*;
use gtk::Image;
use serde::Deserialize;
use tracing::error;
#[derive(Debug, Deserialize, Clone)]
pub struct ImageWidget {
name: Option<String>,
class: Option<String>,
src: String,
#[serde(default = "default_size")]
size: i32,
}
const fn default_size() -> i32 {
32
}
impl CustomWidget for ImageWidget {
type Widget = Image;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let gtk_image = build!(self, Self::Widget);
{
let gtk_image = gtk_image.clone();
let icon_theme = context.icon_theme.clone();
DynamicString::new(&self.src, move |src| {
let res = ImageProvider::parse(&src, &icon_theme, self.size)
.and_then(|image| image.load_into_image(gtk_image.clone()));
if let Err(err) = res {
error!("{err:?}");
}
Continue(true)
});
}
gtk_image
}
}

View File

@@ -0,0 +1,33 @@
use super::{CustomWidget, CustomWidgetContext};
use crate::build;
use crate::dynamic_string::DynamicString;
use gtk::prelude::*;
use gtk::Label;
use serde::Deserialize;
#[derive(Debug, Deserialize, Clone)]
pub struct LabelWidget {
name: Option<String>,
class: Option<String>,
label: String,
}
impl CustomWidget for LabelWidget {
type Widget = Label;
fn into_widget(self, _context: CustomWidgetContext) -> Self::Widget {
let label = build!(self, Self::Widget);
label.set_use_markup(true);
{
let label = label.clone();
DynamicString::new(&self.label, move |string| {
label.set_markup(&string);
Continue(true)
});
}
label
}
}

257
src/modules/custom/mod.rs Normal file
View File

@@ -0,0 +1,257 @@
mod r#box;
mod button;
mod image;
mod label;
mod progress;
mod slider;
use self::image::ImageWidget;
use self::label::LabelWidget;
use self::r#box::BoxWidget;
use self::slider::SliderWidget;
use crate::config::CommonConfig;
use crate::modules::custom::button::ButtonWidget;
use crate::modules::custom::progress::ProgressWidget;
use crate::modules::{
wrap_widget, Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext,
};
use crate::popup::WidgetGeometry;
use crate::script::Script;
use crate::send_async;
use color_eyre::{Report, Result};
use gtk::prelude::*;
use gtk::{IconTheme, 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<WidgetConfig>,
/// Widgets to add to the popup container
popup: Option<Vec<WidgetConfig>>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct WidgetConfig {
#[serde(flatten)]
widget: Widget,
#[serde(flatten)]
common: CommonConfig,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Widget {
Box(BoxWidget),
Label(LabelWidget),
Button(ButtonWidget),
Image(ImageWidget),
Slider(SliderWidget),
Progress(ProgressWidget),
}
#[derive(Clone, Copy)]
struct CustomWidgetContext<'a> {
tx: &'a Sender<ExecEvent>,
bar_orientation: Orientation,
icon_theme: &'a IconTheme,
}
trait CustomWidget {
type Widget;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget;
}
/// Creates a new widget of type `ty`,
/// setting its name and class based on
/// the values available on `self`.
#[macro_export]
macro_rules! build {
($self:ident, $ty:ty) => {{
let mut builder = <$ty>::builder();
if let Some(name) = &$self.name {
builder = builder.name(name);
}
let widget = builder.build();
if let Some(class) = &$self.class {
widget.style_context().add_class(class);
}
widget
}};
}
/// Sets the widget length,
/// using either a width or height request
/// based on the bar's orientation.
pub fn set_length<W: WidgetExt>(widget: &W, length: i32, bar_orientation: Orientation) {
match bar_orientation {
Orientation::Horizontal => widget.set_width_request(length),
Orientation::Vertical => widget.set_height_request(length),
_ => {}
};
}
/// Attempts to parse an `Orientation` from `String`.
/// Will accept `horizontal`, `vertical`, `h` or `v`.
/// Ignores case.
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")),
}
}
impl Widget {
/// Creates this widget and adds it to the parent container
fn add_to(self, parent: &gtk::Box, context: CustomWidgetContext, common: CommonConfig) {
macro_rules! create {
($widget:expr) => {
wrap_widget(
&$widget.into_widget(context),
common,
context.bar_orientation,
)
};
}
let event_box = match self {
Self::Box(widget) => create!(widget),
Self::Label(widget) => create!(widget),
Self::Button(widget) => create!(widget),
Self::Image(widget) => create!(widget),
Self::Slider(widget) => create!(widget),
Self::Progress(widget) => create!(widget),
};
parent.add(&event_box);
}
}
#[derive(Debug)]
pub struct ExecEvent {
cmd: String,
args: Option<Vec<String>>,
geometry: WidgetGeometry,
}
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);
let args = event.args.unwrap_or_default();
if let Err(err) = script.get_output(Some(&args)).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);
}
let custom_context = CustomWidgetContext {
tx: &context.controller_tx,
bar_orientation: orientation,
icon_theme: info.icon_theme,
};
self.bar.clone().into_iter().for_each(|widget| {
widget
.widget
.add_to(&container, custom_context, widget.common);
});
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 {
let custom_context = CustomWidgetContext {
tx: &tx,
bar_orientation: info.bar_position.get_orientation(),
icon_theme: info.icon_theme,
};
for widget in popup {
widget
.widget
.add_to(&container, custom_context, widget.common);
}
}
Some(container)
}
}

View File

@@ -0,0 +1,80 @@
use super::{try_get_orientation, CustomWidget, CustomWidgetContext};
use crate::dynamic_string::DynamicString;
use crate::modules::custom::set_length;
use crate::script::{OutputStream, Script, ScriptInput};
use crate::{build, send};
use gtk::prelude::*;
use gtk::ProgressBar;
use serde::Deserialize;
use tokio::spawn;
use tracing::error;
#[derive(Debug, Deserialize, Clone)]
pub struct ProgressWidget {
name: Option<String>,
class: Option<String>,
orientation: Option<String>,
label: Option<String>,
value: Option<ScriptInput>,
#[serde(default = "default_max")]
max: f64,
length: Option<i32>,
}
const fn default_max() -> f64 {
100.0
}
impl CustomWidget for ProgressWidget {
type Widget = ProgressBar;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let progress = build!(self, Self::Widget);
if let Some(orientation) = self.orientation {
progress.set_orientation(
try_get_orientation(&orientation).unwrap_or(context.bar_orientation),
);
}
if let Some(length) = self.length {
set_length(&progress, length, context.bar_orientation);
}
if let Some(value) = self.value {
let script = Script::from(value);
let progress = progress.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(None, move |stream, _success| match stream {
OutputStream::Stdout(out) => match out.parse::<f64>() {
Ok(value) => send!(tx, value),
Err(err) => error!("{err:?}"),
},
OutputStream::Stderr(err) => error!("{err:?}"),
})
.await;
});
rx.attach(None, move |value| {
progress.set_fraction(value / self.max);
Continue(true)
});
}
if let Some(text) = self.label {
let progress = progress.clone();
progress.set_show_text(true);
DynamicString::new(&text, move |string| {
progress.set_text(Some(&string));
Continue(true)
});
}
progress
}
}

View File

@@ -0,0 +1,129 @@
use super::{try_get_orientation, CustomWidget, CustomWidgetContext, ExecEvent};
use crate::modules::custom::set_length;
use crate::popup::Popup;
use crate::script::{OutputStream, Script, ScriptInput};
use crate::{build, send, try_send};
use gtk::prelude::*;
use gtk::Scale;
use serde::Deserialize;
use std::cell::Cell;
use std::ops::Neg;
use glib::signal::Inhibit;
use tokio::spawn;
use tracing::error;
#[derive(Debug, Deserialize, Clone)]
pub struct SliderWidget {
name: Option<String>,
class: Option<String>,
orientation: Option<String>,
value: Option<ScriptInput>,
on_change: Option<String>,
#[serde(default = "default_min")]
min: f64,
#[serde(default = "default_max")]
max: f64,
step: Option<f64>,
length: Option<i32>,
#[serde(default = "crate::config::default_true")]
show_label: bool,
}
const fn default_min() -> f64 {
0.0
}
const fn default_max() -> f64 {
100.0
}
impl CustomWidget for SliderWidget {
type Widget = Scale;
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
let scale = build!(self, Self::Widget);
if let Some(orientation) = self.orientation {
scale.set_orientation(
try_get_orientation(&orientation).unwrap_or(context.bar_orientation),
);
}
if let Some(length) = self.length {
set_length(&scale, length, context.bar_orientation);
}
scale.set_range(self.min, self.max);
scale.set_draw_value(self.show_label);
if let Some(on_change) = self.on_change {
let min = self.min;
let max = self.max;
let step = self.step;
let tx = context.tx.clone();
// GTK will spam the same value over and over
let prev_value = Cell::new(scale.value());
scale.connect_scroll_event(move |scale, event| {
let value = scale.value();
let delta = event.delta().1.neg();
let delta = match (step, delta.is_sign_positive()) {
(Some(step), true) => step,
(Some(step), false) => -step,
(None, _) => delta,
};
scale.set_value(value + delta);
Inhibit(false)
});
scale.connect_change_value(move |scale, _, val| {
// GTK will send values outside min/max range
let val = val.clamp(min, max);
if val != prev_value.get() {
try_send!(
tx,
ExecEvent {
cmd: on_change.clone(),
args: Some(vec![val.to_string()]),
geometry: Popup::widget_geometry(scale, context.bar_orientation),
}
);
prev_value.set(val);
}
Inhibit(false)
});
}
if let Some(value) = self.value {
let script = Script::from(value);
let scale = scale.clone();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move {
script
.run(None, move |stream, _success| match stream {
OutputStream::Stdout(out) => match out.parse() {
Ok(value) => send!(tx, value),
Err(err) => error!("{err:?}"),
},
OutputStream::Stderr(err) => error!("{err:?}"),
})
.await;
});
rx.attach(None, move |value| {
scale.set_value(value);
Continue(true)
});
}
scale
}
}

View File

@@ -1,14 +1,16 @@
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::config::{CommonConfig, TruncateMode};
use crate::image::ImageProvider;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, icon, read_lock, send_async};
use crate::{await_sync, read_lock, send_async};
use color_eyre::Result;
use glib::Continue;
use gtk::prelude::*;
use gtk::{IconTheme, Image, Label};
use gtk::Label;
use serde::Deserialize;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::error;
#[derive(Debug, Deserialize, Clone)]
pub struct FocusedModule {
@@ -22,8 +24,8 @@ pub struct FocusedModule {
/// Icon size in pixels.
#[serde(default = "default_icon_size")]
icon_size: i32,
/// GTK icon theme to use.
icon_theme: Option<String>,
truncate: Option<TruncateMode>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
@@ -91,26 +93,29 @@ impl Module<gtk::Box> for FocusedModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let icon_theme = IconTheme::new();
if let Some(theme) = self.icon_theme {
icon_theme.set_custom_theme(Some(&theme));
}
let icon_theme = info.icon_theme;
let container = gtk::Box::new(info.bar_position.get_orientation(), 5);
let icon = Image::builder().name("icon").build();
let icon = gtk::Image::builder().name("icon").build();
let label = Label::builder().name("label").build();
container.add(&icon);
container.add(&label);
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
}
container.append(&icon);
container.append(&label);
{
let icon_theme = icon_theme.clone();
context.widget_rx.attach(None, move |(name, id)| {
let pixbuf = icon::get_icon(&icon_theme, &id, self.icon_size);
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 {

62
src/modules/label.rs Normal file
View File

@@ -0,0 +1,62 @@
use crate::config::CommonConfig;
use crate::dynamic_string::DynamicString;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::try_send;
use color_eyre::Result;
use glib::Continue;
use gtk::prelude::*;
use gtk::Label;
use serde::Deserialize;
use tokio::sync::mpsc;
#[derive(Debug, Deserialize, Clone)]
pub struct LabelModule {
label: String,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
impl Module<Label> for LabelModule {
type SendMessage = String;
type ReceiveMessage = ();
fn name() -> &'static str {
"label"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: mpsc::Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()> {
DynamicString::new(&self.label, move |string| {
try_send!(tx, ModuleUpdateEvent::Update(string));
Continue(true)
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Result<ModuleWidget<Label>> {
let label = Label::new(None);
{
let label = label.clone();
context.widget_rx.attach(None, move |string| {
label.set_label(&string);
Continue(true)
});
}
Ok(ModuleWidget {
widget: label,
popup: None,
})
}
}

View File

@@ -1,16 +1,18 @@
use super::open_state::OpenState;
use crate::clients::wayland::ToplevelInfo;
use crate::icon::get_icon;
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, Orientation};
use gtk::{Button, IconTheme, Orientation};
use indexmap::IndexMap;
use std::rc::Rc;
use std::sync::RwLock;
use glib::signal::Inhibit;
use tokio::sync::mpsc::Sender;
use tracing::error;
#[derive(Debug, Clone)]
pub struct Item {
@@ -135,32 +137,47 @@ pub struct ItemButton {
pub menu_state: Rc<RwLock<MenuState>>,
}
#[derive(Clone, Copy)]
pub struct AppearanceOptions {
pub show_names: bool,
pub show_icons: bool,
pub icon_size: i32,
}
impl ItemButton {
pub fn new(
item: &Item,
show_names: bool,
show_icons: bool,
orientation: Orientation,
appearance: AppearanceOptions,
icon_theme: &IconTheme,
orientation: Orientation,
tx: &Sender<ModuleUpdateEvent<LauncherUpdate>>,
controller_tx: &Sender<ItemEvent>,
) -> Self {
let mut button = Button::builder();
if show_names {
if appearance.show_names {
button = button.label(&item.name);
}
if show_icons {
let icon = get_icon(icon_theme, &item.app_id, 32);
if icon.is_some() {
let image = Image::from_pixbuf(icon.as_ref());
button = button.image(&image).always_show_image(true);
}
}
let button = button.build();
if appearance.show_icons {
let gtk_image = gtk::Image::new();
let image =
ImageProvider::parse(&item.app_id.clone(), icon_theme, appearance.icon_size);
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");
@@ -208,7 +225,7 @@ impl ItemButton {
try_send!(
tx,
ModuleUpdateEvent::OpenPopup(Popup::button_pos(button, orientation,))
ModuleUpdateEvent::OpenPopup(Popup::widget_geometry(button, orientation))
);
} else {
try_send!(tx, ModuleUpdateEvent::ClosePopup);
@@ -218,12 +235,10 @@ impl ItemButton {
});
}
button.show_all();
Self {
button,
persistent: item.favorite,
show_names,
show_names: appearance.show_names,
menu_state,
}
}

View File

@@ -5,13 +5,14 @@ use self::item::{Item, ItemButton, Window};
use self::open_state::OpenState;
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::icon::find_desktop_file;
use crate::desktop_file::find_desktop_file;
use crate::modules::launcher::item::AppearanceOptions;
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::{Button, IconTheme, Orientation};
use gtk::{Button, Orientation};
use indexmap::IndexMap;
use serde::Deserialize;
use std::process::{Command, Stdio};
@@ -33,13 +34,17 @@ pub struct LauncherModule {
#[serde(default = "crate::config::default_true")]
show_icons: bool,
/// Name of the GTK icon theme to use.
icon_theme: Option<String>,
#[serde(default = "default_icon_size")]
icon_size: i32,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
32
}
#[derive(Debug, Clone)]
pub enum LauncherUpdate {
/// Adds item
@@ -113,9 +118,9 @@ impl Module<gtk::Box> for LauncherModule {
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);
for (_, (window, _)) in open_windows.clone() {
let item = items.get_mut(&window.app_id);
match item {
Some(item) => {
@@ -127,6 +132,7 @@ impl Module<gtk::Box> for LauncherModule {
}
}
let items = lock!(items);
let items = items.iter();
for (_, item) in items {
tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::AddItem(
@@ -284,7 +290,7 @@ impl Module<gtk::Box> for LauncherModule {
ItemEvent::FocusItem(app_id) => items
.get(&app_id)
.and_then(|item| item.windows.first().map(|(_, win)| win.id)),
ItemEvent::FocusWindow(id) => Some(id),
ItemEvent::FocusWindow(id) => Some(id), // FIXME: Broken on wlroots-git
ItemEvent::OpenItem(_) => unreachable!(),
};
@@ -295,10 +301,11 @@ impl Module<gtk::Box> for LauncherModule {
handle.activate(seat);
};
}
}
}
Ok::<(), swayipc_async::Error>(())
// roundtrip to immediately send activate event
wl.roundtrip();
}
}
});
Ok(())
@@ -309,23 +316,27 @@ impl Module<gtk::Box> for LauncherModule {
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> crate::Result<ModuleWidget<gtk::Box>> {
let icon_theme = IconTheme::new();
if let Some(ref theme) = self.icon_theme {
icon_theme.set_custom_theme(Some(theme));
}
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 appearance_options = AppearanceOptions {
show_names: self.show_names,
show_icons: self.show_icons,
icon_size: self.icon_size,
};
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();
let controller_tx2 = context.controller_tx.clone();
context.widget_rx.attach(None, move |event| {
match event {
LauncherUpdate::AddItem(item) => {
@@ -336,15 +347,14 @@ impl Module<gtk::Box> for LauncherModule {
} else {
let button = ItemButton::new(
&item,
show_names,
show_icons,
orientation,
appearance_options,
&icon_theme,
orientation,
&context.tx,
&controller_tx2,
&controller_tx,
);
container.add(&button.button);
container.append(&button.button);
buttons.insert(item.app_id, button);
}
}
@@ -400,7 +410,7 @@ impl Module<gtk::Box> for LauncherModule {
});
}
let popup = self.into_popup(context.controller_tx, context.popup_rx);
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: container,
popup,
@@ -411,6 +421,7 @@ impl Module<gtk::Box> for LauncherModule {
self,
controller_tx: Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box> {
const MAX_WIDTH: i32 = 250;
@@ -422,7 +433,7 @@ impl Module<gtk::Box> for LauncherModule {
// 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);
container.append(&placeholder);
let mut buttons = IndexMap::<String, IndexMap<usize, Button>>::new();
@@ -439,7 +450,7 @@ impl Module<gtk::Box> for LauncherModule {
.into_iter()
.map(|(_, win)| {
let button = Button::builder()
.label(&clamp(&win.name))
.label(clamp(&win.name))
.height_request(40)
.build();
@@ -469,7 +480,7 @@ impl Module<gtk::Box> for LauncherModule {
if let Some(buttons) = buttons.get_mut(&app_id) {
let button = Button::builder()
.height_request(40)
.label(&clamp(&win.name))
.label(clamp(&win.name))
.build();
{
@@ -514,10 +525,9 @@ impl Module<gtk::Box> for LauncherModule {
if let Some(buttons) = buttons.get(&app_id) {
for (_, button) in buttons {
button.style_context().add_class("popup-item");
container.add(button);
container.append(button);
}
container.show_all();
container.set_width_request(MAX_WIDTH);
}
}

View File

@@ -1,26 +1,41 @@
#[cfg(feature = "clipboard")]
pub mod clipboard;
/// Displays the current date and time.
///
/// A custom date/time format string can be set in the config.
///
/// Clicking the widget opens a popup containing the current time
/// with second-level precision and a calendar.
#[cfg(feature = "clock")]
pub mod clock;
pub mod custom;
pub mod focused;
pub mod label;
pub mod launcher;
#[cfg(feature = "music")]
pub mod music;
pub mod script;
#[cfg(feature = "sys_info")]
pub mod sysinfo;
#[cfg(feature = "tray")]
pub mod tray;
#[cfg(feature = "upower")]
pub mod upower;
#[cfg(feature = "workspaces")]
pub mod workspaces;
use crate::config::BarPosition;
use crate::popup::ButtonGeometry;
use crate::bridge_channel::BridgeChannel;
use crate::config::{BarPosition, CommonConfig, TransitionType};
use crate::popup::{Popup, WidgetGeometry};
use crate::{read_lock, send, write_lock};
use color_eyre::Result;
use glib::IsA;
use gtk::gdk::Monitor;
use gtk::{Application, Widget};
use gtk::gdk::{EventMask, Monitor};
use gtk::prelude::*;
use gtk::{Application, EventBox, IconTheme, Orientation, Revealer, Widget};
use std::sync::{Arc, RwLock};
use tokio::sync::mpsc;
use tracing::debug;
#[derive(Clone)]
pub enum ModuleLocation {
@@ -34,6 +49,7 @@ pub struct ModuleInfo<'a> {
pub bar_position: BarPosition,
pub monitor: &'a Monitor,
pub output_name: &'a str,
pub icon_theme: &'a IconTheme,
}
#[derive(Debug)]
@@ -41,10 +57,10 @@ pub enum ModuleUpdateEvent<T> {
/// Sends an update to the module UI
Update(T),
/// Toggles the open state of the popup.
TogglePopup(ButtonGeometry),
TogglePopup(WidgetGeometry),
/// Force sets the popup open.
/// Takes the button X position and width.
OpenPopup(ButtonGeometry),
OpenPopup(WidgetGeometry),
/// Force sets the popup closed.
ClosePopup,
}
@@ -88,6 +104,7 @@ where
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
_rx: glib::Receiver<Self::SendMessage>,
_info: &ModuleInfo,
) -> Option<gtk::Box>
where
Self: Sized,
@@ -95,3 +112,154 @@ where
None
}
}
/// Creates a module and sets it up.
/// This setup includes widget/popup content and event channels.
pub 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);
let channel = BridgeChannel::<ModuleUpdateEvent<TSend>>::new();
let (ui_tx, ui_rx) = mpsc::channel::<TRec>(16);
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.
pub fn wrap_widget<W: IsA<Widget>>(
widget: &W,
common: CommonConfig,
orientation: Orientation,
) -> EventBox {
let revealer = Revealer::builder()
.transition_type(
common
.transition_type
.as_ref()
.unwrap_or(&TransitionType::SlideStart)
.to_revealer_transition_type(orientation),
)
.transition_duration(common.transition_duration.unwrap_or(250))
.build();
revealer.add(widget);
revealer.set_reveal_child(true);
let container = EventBox::new();
container.add_events(EventMask::SCROLL_MASK);
container.append(&revealer);
common.install(&container, &revealer);
container
}

157
src/modules/music/config.rs Normal file
View File

@@ -0,0 +1,157 @@
use crate::config::{CommonConfig, TruncateMode};
use dirs::{audio_dir, home_dir};
use serde::Deserialize;
use std::path::PathBuf;
#[derive(Debug, Deserialize, Clone)]
pub struct Icons {
/// Icon to display when playing.
#[serde(default = "default_icon_play")]
pub(crate) play: String,
/// Icon to display when paused.
#[serde(default = "default_icon_pause")]
pub(crate) pause: String,
/// Icon to display for previous button.
#[serde(default = "default_icon_prev")]
pub(crate) prev: String,
/// Icon to display for next button.
#[serde(default = "default_icon_next")]
pub(crate) next: String,
/// Icon to display under volume slider
#[serde(default = "default_icon_volume")]
pub(crate) volume: String,
/// Icon to display nex to track title
#[serde(default = "default_icon_track")]
pub(crate) track: String,
/// Icon to display nex to album name
#[serde(default = "default_icon_album")]
pub(crate) album: String,
/// Icon to display nex to artist name
#[serde(default = "default_icon_artist")]
pub(crate) artist: String,
}
impl Default for Icons {
fn default() -> Self {
Self {
pause: default_icon_pause(),
play: default_icon_play(),
prev: default_icon_prev(),
next: default_icon_next(),
volume: default_icon_volume(),
track: default_icon_track(),
album: default_icon_album(),
artist: default_icon_artist(),
}
}
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum PlayerType {
Mpd,
Mpris,
}
impl Default for PlayerType {
fn default() -> Self {
Self::Mpris
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct MusicModule {
/// Type of player to connect to
#[serde(default)]
pub(crate) player_type: PlayerType,
/// Format of current song info to display on the bar.
#[serde(default = "default_format")]
pub(crate) format: String,
/// Player state icons
#[serde(default)]
pub(crate) icons: Icons,
// -- MPD --
/// TCP or Unix socket address.
#[serde(default = "default_socket")]
pub(crate) host: String,
/// Path to root of music directory.
#[serde(default = "default_music_dir")]
pub(crate) music_dir: PathBuf,
#[serde(default = "crate::config::default_true")]
pub(crate) show_status_icon: bool,
#[serde(default = "default_icon_size")]
pub(crate) icon_size: i32,
#[serde(default = "default_cover_image_size")]
pub(crate) cover_image_size: i32,
// -- Common --
pub(crate) truncate: Option<TruncateMode>,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
fn default_socket() -> String {
String::from("localhost:6600")
}
fn default_format() -> String {
String::from("{title} / {artist}")
}
fn default_icon_play() -> String {
String::from("")
}
fn default_icon_pause() -> String {
String::from("")
}
fn default_icon_prev() -> String {
String::from("\u{f9ad}")
}
fn default_icon_next() -> String {
String::from("\u{f9ac}")
}
fn default_icon_volume() -> String {
String::from("")
}
fn default_icon_track() -> String {
String::from("\u{f886}")
}
fn default_icon_album() -> String {
String::from("\u{f524}")
}
fn default_icon_artist() -> String {
String::from("\u{fd01}")
}
fn default_music_dir() -> PathBuf {
audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
}
const fn default_icon_size() -> i32 {
24
}
const fn default_cover_image_size() -> i32 {
128
}

View File

@@ -1,24 +1,26 @@
mod config;
use crate::clients::music::{self, MusicClient, PlayerState, PlayerUpdate, Status, Track};
use crate::config::CommonConfig;
use crate::error::ERR_CHANNEL_SEND;
use crate::image::{new_icon_button, new_icon_label, ImageProvider};
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::try_send;
use crate::{send_async, try_send};
use color_eyre::Result;
use dirs::{audio_dir, home_dir};
use glib::Continue;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{Button, Image, Label, Orientation, Scale};
use gtk::{Button, IconTheme, Label, Orientation, Scale};
use regex::Regex;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use glib::signal::Inhibit;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::error;
pub use self::config::MusicModule;
use self::config::PlayerType;
#[derive(Debug)]
pub enum PlayerCommand {
Previous,
@@ -28,93 +30,6 @@ pub enum PlayerCommand {
Volume(u8),
}
#[derive(Debug, Deserialize, Clone)]
pub struct Icons {
/// Icon to display when playing.
#[serde(default = "default_icon_play")]
play: String,
/// Icon to display when paused.
#[serde(default = "default_icon_pause")]
pause: String,
/// Icon to display under volume slider
#[serde(default = "default_icon_volume")]
volume: String,
}
impl Default for Icons {
fn default() -> Self {
Self {
pause: default_icon_pause(),
play: default_icon_play(),
volume: default_icon_volume(),
}
}
}
#[derive(Debug, Deserialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum PlayerType {
// Auto,
Mpd,
Mpris,
}
impl Default for PlayerType {
fn default() -> Self {
Self::Mpris
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct MusicModule {
/// Type of player to connect to
#[serde(default)]
player_type: PlayerType,
/// Format of current song info to display on the bar.
#[serde(default = "default_format")]
format: String,
/// Player state icons
#[serde(default)]
icons: Icons,
// -- MPD --
/// TCP or Unix socket address.
#[serde(default = "default_socket")]
host: String,
/// Path to root of music directory.
#[serde(default = "default_music_dir")]
music_dir: PathBuf,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
fn default_socket() -> String {
String::from("localhost:6600")
}
fn default_format() -> String {
String::from("{icon} {title} / {artist}")
}
fn default_icon_play() -> String {
String::from("")
}
fn default_icon_pause() -> String {
String::from("")
}
fn default_icon_volume() -> String {
String::from("")
}
fn default_music_dir() -> PathBuf {
audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
}
/// Formats a duration given in seconds
/// in hh:mm format
fn format_time(duration: Duration) -> String {
@@ -166,7 +81,6 @@ impl Module<Button> for MusicModule {
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let format = self.format.clone();
let icons = self.icons.clone();
let re = Regex::new(r"\{([\w-]+)}")?;
let tokens = get_tokens(&re, self.format.as_str());
@@ -188,13 +102,8 @@ impl Module<Button> for MusicModule {
match update {
PlayerUpdate::Update(track, status) => match *track {
Some(track) => {
let display_string = replace_tokens(
format.as_str(),
&tokens,
&track,
&status,
&icons,
);
let display_string =
replace_tokens(format.as_str(), &tokens, &track, &status);
let update = SongUpdate {
song: track,
@@ -202,14 +111,9 @@ impl Module<Button> for MusicModule {
display_string,
};
tx.send(ModuleUpdateEvent::Update(Some(update)))
.await
.expect(ERR_CHANNEL_SEND);
send_async!(tx, ModuleUpdateEvent::Update(Some(update)));
}
None => tx
.send(ModuleUpdateEvent::Update(None))
.await
.expect(ERR_CHANNEL_SEND),
None => send_async!(tx, ModuleUpdateEvent::Update(None)),
},
PlayerUpdate::Disconnect => break,
}
@@ -251,9 +155,22 @@ impl Module<Button> for MusicModule {
info: &ModuleInfo,
) -> Result<ModuleWidget<Button>> {
let button = Button::new();
let button_contents = gtk::Box::new(Orientation::Horizontal, 5);
button.append(&button_contents);
let icon_play = new_icon_label(&self.icons.play, info.icon_theme, self.icon_size);
let icon_pause = new_icon_label(&self.icons.pause, info.icon_theme, self.icon_size);
let label = Label::new(None);
label.set_angle(info.bar_position.get_angle());
button.add(&label);
if let Some(truncate) = self.truncate {
truncate.truncate_label(&label);
}
button_contents.append(&icon_pause);
button_contents.append(&icon_play);
button_contents.append(&label);
let orientation = info.bar_position.get_orientation();
@@ -263,7 +180,7 @@ impl Module<Button> for MusicModule {
button.connect_clicked(move |button| {
try_send!(
tx,
ModuleUpdateEvent::TogglePopup(Popup::button_pos(button, orientation,))
ModuleUpdateEvent::TogglePopup(Popup::widget_geometry(button, orientation,))
);
});
}
@@ -275,7 +192,28 @@ impl Module<Button> for MusicModule {
context.widget_rx.attach(None, move |mut event| {
if let Some(event) = event.take() {
label.set_label(&event.display_string);
button.show();
match event.status.state {
PlayerState::Playing if self.show_status_icon => {
icon_play.show();
icon_pause.hide();
}
PlayerState::Paused if self.show_status_icon => {
icon_pause.show();
icon_play.hide();
}
PlayerState::Stopped => {
button.hide();
}
_ => {}
}
if !self.show_status_icon {
icon_pause.hide();
icon_play.hide();
}
} else {
button.hide();
try_send!(tx, ModuleUpdateEvent::ClosePopup);
@@ -285,7 +223,7 @@ impl Module<Button> for MusicModule {
});
};
let popup = self.into_popup(context.controller_tx, context.popup_rx);
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
Ok(ModuleWidget {
widget: button,
@@ -297,42 +235,57 @@ impl Module<Button> for MusicModule {
self,
tx: Sender<Self::ReceiveMessage>,
rx: glib::Receiver<Self::SendMessage>,
info: &ModuleInfo,
) -> Option<gtk::Box> {
let icon_theme = info.icon_theme;
let container = gtk::Box::builder()
.orientation(Orientation::Horizontal)
.spacing(10)
.name("popup-music")
.build();
let album_image = Image::builder()
let album_image = gtk::Image::builder()
.width_request(128)
.height_request(128)
.name("album-art")
.build();
let icons = self.icons;
let info_box = gtk::Box::new(Orientation::Vertical, 10);
let title_label = IconLabel::new("\u{f886}", None);
let album_label = IconLabel::new("\u{f524}", None);
let artist_label = IconLabel::new("\u{fd01}", None);
let title_label = IconLabel::new(&icons.track, None, icon_theme);
let album_label = IconLabel::new(&icons.album, None, icon_theme);
let artist_label = IconLabel::new(&icons.artist, None, icon_theme);
title_label.container.set_widget_name("title");
album_label.container.set_widget_name("album");
artist_label.container.set_widget_name("artist");
info_box.add(&title_label.container);
info_box.add(&album_label.container);
info_box.add(&artist_label.container);
info_box.append(&title_label.container);
info_box.append(&album_label.container);
info_box.append(&artist_label.container);
let controls_box = gtk::Box::builder().name("controls").build();
let btn_prev = Button::builder().label("\u{f9ad}").name("btn-prev").build();
let btn_play_pause = Button::builder().label("").name("btn-play-pause").build();
let btn_next = Button::builder().label("\u{f9ac}").name("btn-next").build();
controls_box.add(&btn_prev);
controls_box.add(&btn_play_pause);
controls_box.add(&btn_next);
let btn_prev = new_icon_button(&icons.prev, icon_theme, self.icon_size);
btn_prev.set_widget_name("btn-prev");
info_box.add(&controls_box);
let btn_play = new_icon_button(&icons.play, icon_theme, self.icon_size);
btn_play.set_widget_name("btn-play");
let btn_pause = new_icon_button(&icons.pause, icon_theme, self.icon_size);
btn_pause.set_widget_name("btn-pause");
let btn_next = new_icon_button(&icons.next, icon_theme, self.icon_size);
btn_next.set_widget_name("btn-next");
controls_box.append(&btn_prev);
controls_box.append(&btn_play);
controls_box.append(&btn_pause);
controls_box.append(&btn_next);
info_box.append(&controls_box);
let volume_box = gtk::Box::builder()
.orientation(Orientation::Vertical)
@@ -344,28 +297,29 @@ impl Module<Button> for MusicModule {
volume_slider.set_inverted(true);
volume_slider.set_widget_name("slider");
let volume_icon = Label::new(Some(&self.icons.volume));
let volume_icon = new_icon_label(&icons.volume, icon_theme, self.icon_size);
volume_icon.style_context().add_class("icon");
volume_box.pack_start(&volume_slider, true, true, 0);
volume_box.pack_end(&volume_icon, false, false, 0);
container.add(&album_image);
container.add(&info_box);
container.add(&volume_box);
container.append(&album_image);
container.append(&info_box);
container.append(&volume_box);
let tx_prev = tx.clone();
btn_prev.connect_clicked(move |_| {
try_send!(tx_prev, PlayerCommand::Previous);
});
let tx_toggle = tx.clone();
btn_play_pause.connect_clicked(move |button| {
if button.style_context().has_class("playing") {
try_send!(tx_toggle, PlayerCommand::Pause);
} else {
try_send!(tx_toggle, PlayerCommand::Play);
}
let tx_play = tx.clone();
btn_play.connect_clicked(move |_| {
try_send!(tx_play, PlayerCommand::Play);
});
let tx_pause = tx.clone();
btn_pause.connect_clicked(move |_| {
try_send!(tx_pause, PlayerCommand::Pause);
});
let tx_next = tx.clone();
@@ -379,9 +333,10 @@ impl Module<Button> for MusicModule {
Inhibit(false)
});
container.show_all();
{
let icon_theme = icon_theme.clone();
let image_size = self.cover_image_size;
let mut prev_cover = None;
rx.attach(None, move |update| {
if let Some(update) = update {
@@ -389,16 +344,22 @@ impl Module<Button> for MusicModule {
let new_cover = update.song.cover_path;
if prev_cover != new_cover {
prev_cover = new_cover.clone();
match new_cover.map(|cover_path| {
Pixbuf::from_file_at_scale(cover_path, 128, 128, true)
let res = match new_cover.map(|cover_path| {
ImageProvider::parse(&cover_path, &icon_theme, image_size)
}) {
Some(Ok(pixbuf)) => album_image.set_from_pixbuf(Some(&pixbuf)),
Some(Ok(image)) => image.load_into_image(album_image.clone()),
Some(Err(err)) => {
error!("{:?}", err);
album_image.set_from_pixbuf(None);
Err(err)
}
None => {
album_image.set_from_pixbuf(None);
Ok(())
}
None => album_image.set_from_pixbuf(None),
};
if let Err(err) = res {
error!("{err:?}");
}
}
title_label
@@ -413,23 +374,23 @@ impl Module<Button> for MusicModule {
match update.status.state {
PlayerState::Stopped => {
btn_play_pause.set_sensitive(false);
btn_pause.hide();
btn_play.show();
btn_play.set_sensitive(false);
}
PlayerState::Playing => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label(&self.icons.pause);
btn_play.set_sensitive(false);
btn_play.hide();
let style_context = btn_play_pause.style_context();
style_context.add_class("playing");
style_context.remove_class("paused");
btn_pause.set_sensitive(true);
btn_pause.show();
}
PlayerState::Paused => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label(&self.icons.play);
btn_pause.set_sensitive(false);
btn_pause.hide();
let style_context = btn_play_pause.style_context();
style_context.add_class("paused");
style_context.remove_class("playing");
btn_play.set_sensitive(true);
btn_play.show();
}
}
@@ -459,11 +420,10 @@ fn replace_tokens(
tokens: &Vec<String>,
song: &Track,
status: &Status,
icons: &Icons,
) -> String {
let mut compiled_string = format_string.to_string();
for token in tokens {
let value = get_token_value(song, status, icons, token);
let value = get_token_value(song, status, token);
compiled_string = compiled_string.replace(format!("{{{token}}}").as_str(), value.as_str());
}
compiled_string
@@ -471,14 +431,8 @@ fn replace_tokens(
/// Converts a string format token value
/// into its respective value.
fn get_token_value(song: &Track, status: &Status, icons: &Icons, token: &str) -> String {
fn get_token_value(song: &Track, status: &Status, token: &str) -> String {
match token {
"icon" => match status.state {
PlayerState::Stopped => None,
PlayerState::Playing => Some(&icons.play),
PlayerState::Paused => Some(&icons.pause),
}
.map(std::string::ToString::to_string),
"title" => song.title.clone(),
"album" => song.album.clone(),
"artist" => song.artist.clone(),
@@ -493,24 +447,24 @@ fn get_token_value(song: &Track, status: &Status, icons: &Icons, token: &str) ->
.unwrap_or_default()
}
#[derive(Clone)]
#[derive(Clone, Debug)]
struct IconLabel {
label: Label,
container: gtk::Box,
}
impl IconLabel {
fn new(icon: &str, label: Option<&str>) -> Self {
fn new(icon_input: &str, label: Option<&str>, icon_theme: &IconTheme) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 5);
let icon = Label::new(Some(icon));
let icon = new_icon_label(icon_input, icon_theme, 24);
let label = Label::new(label);
icon.style_context().add_class("icon");
label.style_context().add_class("label");
container.add(&icon);
container.add(&label);
container.append(&icon);
container.append(&label);
Self { label, container }
}

View File

@@ -1,6 +1,7 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::script::{OutputStream, Script, ScriptMode};
use crate::try_send;
use color_eyre::{Help, Report, Result};
use gtk::prelude::*;
use gtk::Label;
@@ -61,10 +62,10 @@ impl Module<Label> for ScriptModule {
let script: Script = self.into();
spawn(async move {
script.run(move |(out, _)| match out {
script.run(None, move |out, _| match out {
OutputStream::Stdout(stdout) => {
tx.try_send(ModuleUpdateEvent::Update(stdout))
.expect("Failed to send stdout"); }
try_send!(tx, ModuleUpdateEvent::Update(stdout));
},
OutputStream::Stderr(stderr) => {
error!("{:?}", Report::msg(stderr)
.wrap_err("Watched script error:")

View File

@@ -199,7 +199,7 @@ impl Module<gtk::Box> for SysInfoModule {
.name("item")
.build();
label.set_angle(info.bar_position.get_angle());
container.add(&label);
container.append(&label);
labels.push(label);
}
@@ -361,16 +361,19 @@ fn refresh_system_tokens(format_info: &mut HashMap<String, String>, sys: &System
// no refresh required for these tokens
let load_average = sys.load_average();
format_info.insert(String::from("load_average:1"), load_average.one.to_string());
format_info.insert(
String::from("load_average:1"),
format!("{:.2}", load_average.one),
);
format_info.insert(
String::from("load_average:5"),
load_average.five.to_string(),
format!("{:.2}", load_average.five),
);
format_info.insert(
String::from("load_average:15"),
load_average.fifteen.to_string(),
format!("{:.2}", load_average.fifteen),
);
let uptime = Duration::from_secs(sys.uptime()).as_secs();

View File

@@ -3,8 +3,12 @@ use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, try_send};
use color_eyre::Result;
use gtk::gdk_pixbuf::{Colorspace, InterpType};
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem};
use gtk::{
gdk_pixbuf, IconLookupFlags, IconTheme, Image, Label, Menu, MenuBar, MenuItem,
SeparatorMenuItem,
};
use serde::Deserialize;
use std::collections::HashMap;
use stray::message::menu::{MenuItem as MenuItemInfo, MenuType};
@@ -20,9 +24,9 @@ pub struct TrayModule {
pub common: Option<CommonConfig>,
}
/// Gets a GTK `Image` component
/// Attempts to get a GTK `Image` component
/// for the status notifier item's icon.
fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
fn get_image_from_icon_name(item: &StatusNotifierItem) -> Option<Image> {
item.icon_theme_path.as_ref().and_then(|path| {
let theme = IconTheme::new();
theme.append_search_path(path);
@@ -34,6 +38,37 @@ fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
})
}
/// Attempts to get an image from the item pixmap.
///
/// The pixmap is supplied in ARGB32 format,
/// which has 8 bits per sample and a bit stride of `4*width`.
fn get_image_from_pixmap(item: &StatusNotifierItem) -> Option<Image> {
const BITS_PER_SAMPLE: i32 = 8; //
let pixmap = item
.icon_pixmap
.as_ref()
.and_then(|pixmap| pixmap.first())?;
let bytes = glib::Bytes::from(&pixmap.pixels);
let row_stride = pixmap.width * 4; //
let pixbuf = gdk_pixbuf::Pixbuf::from_bytes(
&bytes,
Colorspace::Rgb,
true,
BITS_PER_SAMPLE,
pixmap.width,
pixmap.height,
row_stride,
);
let pixbuf = pixbuf
.scale_simple(16, 16, InterpType::Bilinear)
.unwrap_or(pixbuf);
Some(Image::from_pixbuf(Some(&pixbuf)))
}
/// Recursively gets GTK `MenuItem` components
/// for the provided submenu array.
fn get_menu_items(
@@ -56,7 +91,7 @@ fn get_menu_items(
let menu = Menu::new();
get_menu_items(&item_info.submenu, &tx.clone(), id, path)
.iter()
.for_each(|item| menu.add(item));
.for_each(|item| menu.append(item));
builder = builder.submenu(&menu);
}
@@ -147,14 +182,26 @@ impl Module<MenuBar> for TrayModule {
address,
menu,
} => {
let addr = &address;
let menu_item = widgets.remove(address.as_str()).unwrap_or_else(|| {
let menu_item = MenuItem::new();
menu_item.style_context().add_class("item");
if let Some(image) = get_icon(&item) {
get_image_from_icon_name(&item)
.or_else(|| get_image_from_pixmap(&item))
.map_or_else(
|| {
let label =
Label::new(Some(item.title.as_ref().unwrap_or(addr)));
menu_item.add(&label);
},
|image| {
image.set_widget_name(address.as_str());
menu_item.add(&image);
}
container.add(&menu_item);
menu_item.append(&image);
},
);
container.append(&menu_item);
menu_item.show_all();
menu_item
});
@@ -169,7 +216,7 @@ impl Module<MenuBar> for TrayModule {
&menu_path,
)
.iter()
.for_each(|item| menu.add(item));
.for_each(|item| menu.append(item));
menu_item.set_submenu(Some(&menu));
}
}

281
src/modules/upower.rs Normal file
View File

@@ -0,0 +1,281 @@
use crate::clients::upower::get_display_proxy;
use crate::config::CommonConfig;
use crate::image::ImageProvider;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::{await_sync, error, send_async, try_send};
use color_eyre::Result;
use futures_lite::stream::StreamExt;
use gtk::{prelude::*, Button};
use gtk::{Label, Orientation};
use serde::Deserialize;
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use upower_dbus::BatteryState;
use zbus;
const DAY: i64 = 24 * 60 * 60;
const HOUR: i64 = 60 * 60;
const MINUTE: i64 = 60;
#[derive(Debug, Deserialize, Clone)]
pub struct UpowerModule {
#[serde(default = "default_format")]
format: String,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
fn default_format() -> String {
String::from("{percentage}%")
}
#[derive(Clone, Debug)]
pub struct UpowerProperties {
percentage: f64,
icon_name: String,
state: u32,
time_to_full: i64,
time_to_empty: i64,
}
impl Module<gtk::Box> for UpowerModule {
type SendMessage = UpowerProperties;
type ReceiveMessage = ();
fn name() -> &'static str {
"upower"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
spawn(async move {
// await_sync due to strange "higher-ranked lifetime error"
let display_proxy = await_sync(async move { get_display_proxy().await });
let mut prop_changed_stream = display_proxy.receive_properties_changed().await?;
let device_interface_name =
zbus::names::InterfaceName::from_static_str("org.freedesktop.UPower.Device")
.expect("failed to create zbus InterfaceName");
let properties = display_proxy.get_all(device_interface_name.clone()).await?;
let percentage = *properties["Percentage"]
.downcast_ref::<f64>()
.expect("expected percentage: f64 in HashMap of all properties");
let icon_name = properties["IconName"]
.downcast_ref::<str>()
.expect("expected IconName: str in HashMap of all properties")
.to_string();
let state = *properties["State"]
.downcast_ref::<u32>()
.expect("expected State: u32 in HashMap of all properties");
let time_to_full = *properties["TimeToFull"]
.downcast_ref::<i64>()
.expect("expected TimeToFull: i64 in HashMap of all properties");
let time_to_empty = *properties["TimeToEmpty"]
.downcast_ref::<i64>()
.expect("expected TimeToEmpty: i64 in HashMap of all properties");
let mut properties = UpowerProperties {
percentage,
icon_name: icon_name.clone(),
state,
time_to_full,
time_to_empty,
};
send_async!(tx, ModuleUpdateEvent::Update(properties.clone()));
while let Some(signal) = prop_changed_stream.next().await {
let args = signal.args().expect("Invalid signal arguments");
if args.interface_name != device_interface_name {
continue;
}
for (name, changed_value) in args.changed_properties {
match name {
"Percentage" => {
properties.percentage = changed_value
.downcast::<f64>()
.expect("expected Percentage to be f64");
}
"IconName" => {
properties.icon_name = changed_value
.downcast_ref::<str>()
.expect("expected IconName to be str")
.to_string();
}
"State" => {
properties.state = changed_value
.downcast::<u32>()
.expect("expected State to be u32");
}
"TimeToFull" => {
properties.time_to_full = changed_value
.downcast::<i64>()
.expect("expected TimeToFull to be i64");
}
"TimeToEmpty" => {
properties.time_to_empty = changed_value
.downcast::<i64>()
.expect("expected TimeToEmpty to be i64");
}
_ => {}
}
}
send_async!(tx, ModuleUpdateEvent::Update(properties.clone()));
}
Result::<()>::Ok(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let icon_theme = info.icon_theme.clone();
let icon = gtk::Image::builder().name("icon").build();
let label = Label::builder()
.label(&self.format)
.use_markup(true)
.name("label")
.build();
let container = gtk::Box::builder()
.orientation(Orientation::Horizontal)
.name("upower")
.build();
let button = Button::builder().name("button").build();
button.add(&label);
container.add(&button);
container.add(&icon);
let orientation = info.bar_position.get_orientation();
button.connect_clicked(move |button| {
try_send!(
context.tx,
ModuleUpdateEvent::TogglePopup(Popup::widget_geometry(button, orientation))
);
});
label.set_angle(info.bar_position.get_angle());
let format = self.format.clone();
context
.widget_rx
.attach(None, move |properties: UpowerProperties| {
let format = format.replace("{percentage}", &properties.percentage.to_string());
let icon_name = String::from("icon:") + &properties.icon_name;
if let Err(err) = ImageProvider::parse(&icon_name, &icon_theme, 32)
.and_then(|provider| provider.load_into_image(icon.clone()))
{
error!("{err:?}");
}
label.set_markup(format.as_ref());
Continue(true)
});
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()
.orientation(Orientation::Horizontal)
.name("popup-upower")
.build();
let label = Label::builder().name("upower-details").build();
container.add(&label);
rx.attach(None, move |properties| {
let mut format = String::new();
let state = u32_to_battery_state(properties.state);
match state {
Ok(BatteryState::Charging | BatteryState::PendingCharge) => {
let ttf = properties.time_to_full;
if ttf > 0 {
format = format!("Full in {}", seconds_to_string(ttf));
}
}
Ok(BatteryState::Discharging | BatteryState::PendingDischarge) => {
let tte = properties.time_to_empty;
if tte > 0 {
format = format!("Empty in {}", seconds_to_string(tte));
}
}
Err(state) => error!("Invalid battery state: {state}"),
_ => {}
}
label.set_markup(&format);
Continue(true)
});
container.show_all();
Some(container)
}
}
fn seconds_to_string(seconds: i64) -> String {
let mut time_string = String::new();
let days = seconds / (DAY);
if days > 0 {
time_string += &format!("{days}d");
}
let hours = (seconds % DAY) / HOUR;
if hours > 0 {
time_string += &format!(" {hours}h");
}
let minutes = (seconds % HOUR) / MINUTE;
if minutes > 0 {
time_string += &format!(" {minutes}m");
}
time_string.trim_start().to_string()
}
const fn u32_to_battery_state(number: u32) -> Result<BatteryState, u32> {
if number == (BatteryState::Unknown as u32) {
Ok(BatteryState::Unknown)
} else if number == (BatteryState::Charging as u32) {
Ok(BatteryState::Charging)
} else if number == (BatteryState::Discharging as u32) {
Ok(BatteryState::Discharging)
} else if number == (BatteryState::Empty as u32) {
Ok(BatteryState::Empty)
} else if number == (BatteryState::FullyCharged as u32) {
Ok(BatteryState::FullyCharged)
} else if number == (BatteryState::PendingCharge as u32) {
Ok(BatteryState::PendingCharge)
} else if number == (BatteryState::PendingDischarge as u32) {
Ok(BatteryState::PendingDischarge)
} else {
Err(number)
}
}

View File

@@ -1,10 +1,11 @@
use crate::clients::compositor::{Compositor, WorkspaceUpdate};
use crate::config::CommonConfig;
use crate::image::new_icon_button;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{send_async, try_send};
use color_eyre::{Report, Result};
use gtk::prelude::*;
use gtk::Button;
use gtk::{Button, IconTheme};
use serde::Deserialize;
use std::cmp::Ordering;
use std::collections::HashMap;
@@ -40,21 +41,30 @@ pub struct WorkspacesModule {
#[serde(default)]
sort: SortOrder,
#[serde(default = "default_icon_size")]
icon_size: i32,
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
32
}
/// Creates a button from a workspace
fn create_button(
name: &str,
focused: bool,
name_map: &HashMap<String, String>,
icon_theme: &IconTheme,
icon_size: i32,
tx: &Sender<String>,
) -> Button {
let button = Button::builder()
.label(name_map.get(name).map_or(name, String::as_str))
.name(name)
.build();
let label = name_map.get(name).map_or(name, String::as_str);
let button = new_icon_button(label, icon_theme, icon_size);
button.set_widget_name(name);
let style_context = button.style_context();
style_context.add_class("item");
@@ -154,6 +164,8 @@ impl Module<gtk::Box> for WorkspacesModule {
{
let container = container.clone();
let output_name = info.output_name.to_string();
let icon_theme = info.icon_theme.clone();
let icon_size = self.icon_size;
// keep track of whether init event has fired previously
// since it fires for every workspace subscriber
@@ -170,9 +182,11 @@ impl Module<gtk::Box> for WorkspacesModule {
&workspace.name,
workspace.focused,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
container.append(&item);
button_map.insert(workspace.name, item);
}
@@ -182,17 +196,16 @@ impl Module<gtk::Box> for WorkspacesModule {
reorder_workspaces(&container);
}
container.show_all();
has_initialized = true;
}
}
WorkspaceUpdate::Focus { old, new } => {
let old = button_map.get(&old.name);
let old = button_map.get(&old);
if let Some(old) = old {
old.style_context().remove_class("focused");
}
let new = button_map.get(&new.name);
let new = button_map.get(&new);
if let Some(new) = new {
new.style_context().add_class("focused");
}
@@ -204,10 +217,12 @@ impl Module<gtk::Box> for WorkspacesModule {
&name,
workspace.focused,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
container.append(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
@@ -227,10 +242,12 @@ impl Module<gtk::Box> for WorkspacesModule {
&name,
workspace.focused,
&name_map,
&icon_theme,
icon_size,
&context.controller_tx,
);
container.add(&item);
container.append(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
@@ -247,7 +264,7 @@ impl Module<gtk::Box> for WorkspacesModule {
}
}
WorkspaceUpdate::Remove(workspace) => {
let button = button_map.get(&workspace.name);
let button = button_map.get(&workspace);
if let Some(item) = button {
container.remove(item);
}

View File

@@ -1,10 +1,11 @@
use std::collections::HashMap;
use glib::signal::Inhibit;
use crate::config::BarPosition;
use crate::modules::ModuleInfo;
use gtk::gdk::Monitor;
use gtk::prelude::*;
use gtk::{ApplicationWindow, Button, Orientation};
use gtk::{ApplicationWindow, Orientation};
use tracing::debug;
#[derive(Debug, Clone)]
@@ -19,7 +20,7 @@ impl Popup {
/// Creates a new popup window.
/// This includes setting up gtk-layer-shell
/// and an empty `gtk::Box` container.
pub fn new(module_info: &ModuleInfo) -> Self {
pub fn new(module_info: &ModuleInfo, gap: i32) -> Self {
let pos = module_info.bar_position;
let orientation = pos.get_orientation();
@@ -29,26 +30,27 @@ impl Popup {
gtk_layer_shell::init_for_window(&win);
gtk_layer_shell::set_layer(&win, gtk_layer_shell::Layer::Overlay);
gtk_layer_shell::set_namespace(&win, env!("CARGO_PKG_NAME"));
gtk_layer_shell::set_margin(
&win,
gtk_layer_shell::Edge::Top,
if pos == BarPosition::Top { 5 } else { 0 },
if pos == BarPosition::Top { gap } else { 0 },
);
gtk_layer_shell::set_margin(
&win,
gtk_layer_shell::Edge::Bottom,
if pos == BarPosition::Bottom { 5 } else { 0 },
if pos == BarPosition::Bottom { gap } else { 0 },
);
gtk_layer_shell::set_margin(
&win,
gtk_layer_shell::Edge::Left,
if pos == BarPosition::Left { 5 } else { 0 },
if pos == BarPosition::Left { gap } else { 0 },
);
gtk_layer_shell::set_margin(
&win,
gtk_layer_shell::Edge::Right,
if pos == BarPosition::Right { 5 } else { 0 },
if pos == BarPosition::Right { gap } else { 0 },
);
gtk_layer_shell::set_anchor(
@@ -120,7 +122,7 @@ impl Popup {
if let Some(content) = self.cache.get(&key) {
content.style_context().add_class("popup");
self.window.add(content);
self.window.append(content);
}
}
@@ -132,8 +134,8 @@ impl Popup {
}
/// Shows the popup
pub fn show(&self, geometry: ButtonGeometry) {
self.window.show_all();
pub fn show(&self, geometry: WidgetGeometry) {
self.window.show();
self.set_pos(geometry);
}
@@ -149,7 +151,7 @@ impl Popup {
/// Sets the popup's X/Y position relative to the left or border of the screen
/// (depending on orientation).
fn set_pos(&self, geometry: ButtonGeometry) {
fn set_pos(&self, geometry: WidgetGeometry) {
let orientation = self.pos.get_orientation();
let mon_workarea = self.monitor.workarea();
@@ -189,14 +191,17 @@ impl Popup {
/// Gets the absolute X position of the button
/// and its width / height (depending on orientation).
pub fn button_pos(button: &Button, orientation: Orientation) -> ButtonGeometry {
let button_size = if orientation == Orientation::Horizontal {
button.allocation().width()
pub fn widget_geometry<W>(widget: &W, orientation: Orientation) -> WidgetGeometry
where
W: IsA<gtk::Widget>,
{
let widget_size = if orientation == Orientation::Horizontal {
widget.allocation().width()
} else {
button.allocation().height()
widget.allocation().height()
};
let top_level = button.toplevel().expect("Failed to get top-level widget");
let top_level = widget.toplevel().expect("Failed to get top-level widget");
let bar_size = if orientation == Orientation::Horizontal {
top_level.allocation().width()
@@ -204,26 +209,26 @@ impl Popup {
top_level.allocation().height()
};
let (button_x, button_y) = button
let (widget_x, widget_y) = widget
.translate_coordinates(&top_level, 0, 0)
.unwrap_or((0, 0));
let button_pos = if orientation == Orientation::Horizontal {
button_x
let widget_pos = if orientation == Orientation::Horizontal {
widget_x
} else {
button_y
widget_y
};
ButtonGeometry {
position: button_pos,
size: button_size,
WidgetGeometry {
position: widget_pos,
size: widget_size,
bar_size,
}
}
}
#[derive(Debug, Copy, Clone)]
pub struct ButtonGeometry {
pub struct WidgetGeometry {
position: i32,
size: i32,
bar_size: i32,

View File

@@ -2,6 +2,7 @@ use crate::send_async;
use color_eyre::eyre::WrapErr;
use color_eyre::{Report, Result};
use serde::Deserialize;
use std::cmp::min;
use std::fmt::{Display, Formatter};
use std::process::Stdio;
use tokio::io::{AsyncBufReadExt, BufReader};
@@ -9,7 +10,7 @@ use tokio::process::Command;
use tokio::sync::mpsc;
use tokio::time::sleep;
use tokio::{select, spawn};
use tracing::{error, warn};
use tracing::{debug, error, trace, warn};
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
@@ -110,7 +111,13 @@ enum ScriptInputToken {
Mode(ScriptMode),
Interval(u64),
Cmd(String),
Colon,
}
#[derive(Debug, Copy, Clone)]
enum CurrentToken {
Mode,
Interval,
Cmd,
}
impl From<&str> for Script {
@@ -118,46 +125,53 @@ impl From<&str> for Script {
let mut script = Self::default();
let mut tokens = vec![];
let mut current_state = CurrentToken::Mode;
let mut chars = str.chars().collect::<Vec<_>>();
while !chars.is_empty() {
let char = chars[0];
let (token, skip) = match char {
':' => (ScriptInputToken::Colon, 1),
// interval
'0'..='9' => {
let parse_res = match current_state {
CurrentToken::Mode => {
current_state = CurrentToken::Interval;
if matches!(char, 'p' | 'w') {
let mode_str = chars.iter().take_while(|&c| c != &':').collect::<String>();
let len = mode_str.len();
let token = ScriptMode::try_parse(&mode_str).ok();
token.map(|token| (ScriptInputToken::Mode(token), len))
} else {
None
}
}
CurrentToken::Interval => {
current_state = CurrentToken::Cmd;
if char.is_ascii_digit() {
let interval_str = chars
.iter()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
let len = interval_str.len();
let interval = interval_str.parse::<u64>().unwrap_or_else(|_| {
warn!("Received invalid interval in script string. Falling back to default `5000ms`.");
5000
});
(ScriptInputToken::Interval(interval), interval_str.len())
let token = interval_str.parse::<u64>().ok();
token.map(|token| (ScriptInputToken::Interval(token), len))
} else {
None
}
// watching or polling
'w' | 'p' => {
let mode_str = chars.iter().take_while(|&c| c != &':').collect::<String>();
let len = mode_str.len();
let token = ScriptMode::try_parse(&mode_str)
.map_or(ScriptInputToken::Cmd(mode_str), |mode| {
ScriptInputToken::Mode(mode)
});
(token, len)
}
_ => {
CurrentToken::Cmd => {
let cmd_str = chars.iter().take_while(|_| true).collect::<String>();
let len = cmd_str.len();
(ScriptInputToken::Cmd(cmd_str), len)
Some((ScriptInputToken::Cmd(cmd_str), len))
}
};
if let Some((token, skip)) = parse_res {
tokens.push(token);
chars.drain(..skip);
chars.drain(..min(skip + 1, chars.len())); // skip 1 extra for colon
}
}
for token in tokens {
@@ -165,7 +179,6 @@ impl From<&str> for Script {
ScriptInputToken::Mode(mode) => script.mode = mode,
ScriptInputToken::Interval(interval) => script.interval = interval,
ScriptInputToken::Cmd(cmd) => script.cmd = cmd,
ScriptInputToken::Colon => {}
}
}
@@ -180,20 +193,22 @@ impl Script {
script
}
pub async fn run<F>(&self, callback: F)
/// Runs the script, passing `args` if provided.
/// Runs `f`, passing the output stream and whether the command returned 0.
pub async fn run<F>(&self, args: Option<&[String]>, callback: F)
where
F: Fn((OutputStream, bool)),
F: Fn(OutputStream, bool),
{
loop {
match self.mode {
ScriptMode::Poll => match self.get_output().await {
Ok(output) => callback(output),
ScriptMode::Poll => match self.get_output(args).await {
Ok(output) => callback(output.0, output.1),
Err(err) => error!("{err:?}"),
},
ScriptMode::Watch => match self.spawn().await {
Ok(mut rx) => {
while let Some(msg) = rx.recv().await {
callback((msg, true));
callback(msg, true);
}
}
Err(err) => error!("{err:?}"),
@@ -210,28 +225,45 @@ impl Script {
/// the `stdout` is returned.
/// Otherwise, an `Err` variant
/// containing the `stderr` is returned.
pub async fn get_output(&self) -> Result<(OutputStream, bool)> {
pub async fn get_output(&self, args: Option<&[String]>) -> Result<(OutputStream, bool)> {
let mut args_list = vec!["-c", &self.cmd];
if let Some(args) = args {
args_list.extend(args.iter().map(String::as_str));
}
debug!("Running sh with args: {args_list:?}");
let output = Command::new("sh")
.args(["-c", &self.cmd])
.args(&args_list)
.output()
.await
.wrap_err("Failed to get script output")?;
trace!("Script output with args: {output:?}");
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.map(|output| output.trim().to_string())
.wrap_err("Script stdout not valid UTF-8")?;
debug!("sending stdout: '{stdout}'");
Ok((OutputStream::Stdout(stdout), true))
} else {
let stderr = String::from_utf8(output.stderr)
.map(|output| output.trim().to_string())
.wrap_err("Script stderr not valid UTF-8")?;
debug!("sending stderr: '{stderr}'");
Ok((OutputStream::Stderr(stderr), false))
}
}
/// Spawns a long-running process.
/// Returns a `mpsc::Receiver` that sends a message
/// every time a new line is written to `stdout` or `stderr`.
pub async fn spawn(&self) -> Result<mpsc::Receiver<OutputStream>> {
let mut handle = Command::new("sh")
.args(["-c", &self.cmd])
@@ -240,6 +272,9 @@ impl Script {
.stdin(Stdio::null())
.spawn()?;
debug!("Spawned a long-running process for '{}'", self.cmd);
trace!("Handle: {:?}", handle);
let mut stdout_lines = BufReader::new(
handle
.stdout
@@ -263,9 +298,11 @@ impl Script {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
debug!("sending stdout line: '{line}'");
send_async!(tx, OutputStream::Stdout(line));
}
Ok(Some(line)) = stderr_lines.next_line() => {
debug!("sending stderr line: '{line}'");
send_async!(tx, OutputStream::Stderr(line));
}
}
@@ -274,6 +311,27 @@ impl Script {
Ok(rx)
}
/// Executes the script in oneshot mode,
/// meaning it is not awaited and output cannot be captured.
///
/// If the script errors, this is logged.
///
/// This has some overhead,
/// as the script has to be cloned to the thread.
///
pub fn run_as_oneshot(&self, args: Option<&[String]>) {
let script = self.clone();
let args = args.map(<[String]>::to_vec);
spawn(async move {
match script.get_output(args.as_deref()).await {
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
Err(err) => error!("{err:?}"),
_ => {}
}
});
}
}
#[cfg(test)]