34 Commits

Author SHA1 Message Date
Jake Stanger
c906dd40fb chore(release): v0.9.0 2023-01-28 14:49:53 +00:00
Jake Stanger
eb30105fc2 style: fix 1.67 clippy warnings 2023-01-28 14:40:31 +00:00
Jake Stanger
90cd078973 fix(mpd): stops working if connection lost
The client will now attempt to reconnect when a connection loss is detected.

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

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

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

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

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

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

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

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/af50806f7c6ab40df3e6b239099e8f8385f6c78b' (2022-11-21)
  → 'github:nixos/nixpkgs/e76c78d20685a043d23f5f9e0ccd2203997f1fb1' (2022-11-30)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/9652ef34c7439eca9f86cee11e94dbef5c9adb09' (2022-11-22)
  → 'github:oxalica/rust-overlay/bfdf688742cf984c4837dbbe1c6cbca550365613' (2022-12-01)
2022-12-01 20:57:34 +00:00
Jake Stanger
d8c60d9d47 ci(nix flake lock): fix invalid action version
whoops
2022-12-01 20:56:41 +00:00
JakeStanger
b97f018e81 docs: update CHANGELOG.md for v0.8.0 [skip ci] 2022-11-30 23:01:46 +00:00
50 changed files with 2821 additions and 1491 deletions

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

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

View File

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

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

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

View File

@@ -19,7 +19,7 @@ jobs:
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
- name: Update flake.lock
uses: DeterminateSystems/update-flake-lock@vX
uses: DeterminateSystems/update-flake-lock@v15
with:
pr-title: "Update flake.lock" # Title of PR to be created
pr-labels: | # Labels to be set on the PR

View File

@@ -4,6 +4,51 @@ 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.8.0] - 2022-11-30
### :boom: BREAKING CHANGES
- due to [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This renames the module from `sys-info` to `sys_info`, and almost every formatting token from `kebab-case` to `snake_case`. Any use of this module will need to be updated.
- due to [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This changes the option in the `script` module. Any uses of the module must be updated to use the new option name.
- due to [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
This changes the option on buttons in the `custom` module. Any uses of the module must be updated to use the new custom widget attribute name.
### :sparkles: New Features
- [`73158c2`](https://github.com/JakeStanger/ironbar/commit/73158c2fce2880347b88d58541dea000534996c8) - **script**: new watch mode *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`a3f90ad`](https://github.com/JakeStanger/ironbar/commit/a3f90adaf19aebed7020eeb44b91250af080d313) - add nix flake support *(commit by [@yavko](https://github.com/yavko))*
- [`c9e66d4`](https://github.com/JakeStanger/ironbar/commit/c9e66d4664137c50aba4aecdc3a3ba43d3da11fe) - common module options (`show_if`, `on_click`, `tooltip`) *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`5d153a0`](https://github.com/JakeStanger/ironbar/commit/5d153a02fc9b113bb77a04596b806edd182fc5d3) - **custom**: ability to embed scripts in labels for dynamic content *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`d20972c`](https://github.com/JakeStanger/ironbar/commit/d20972cb32714627d0cca947021453979c76dd03) - dynamic tooltips *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :recycle: Refactors
- [`ff17ec1`](https://github.com/JakeStanger/ironbar/commit/ff17ec1996cf344663e84e79d11b08dc84b97635) - various changes based on rust 1.65 clippy *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`4662f60`](https://github.com/JakeStanger/ironbar/commit/4662f60ac54165be6fb7aea12c245309db0fe5d6) - move various clients to own folder *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`0fb5fa8`](https://github.com/JakeStanger/ironbar/commit/0fb5fa8c2a166c3d46b006ceb0d53af076824ff4) - use latest `libcorn` with serde support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - **sys_info**: use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - **script**: rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - **custom**: rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`64f5404`](https://github.com/JakeStanger/ironbar/commit/64f54040ef626157af6b6a9ce5258507a10a23fb) - move dynamic_label.rs to dynamic_string.rs and fix failing test *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :white_check_mark: Tests
- [`907a565`](https://github.com/JakeStanger/ironbar/commit/907a565f3d418a276dfb454e1189ddede1814291) - **dynamic label**: do not run if cannot initialise gtk *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :memo: Documentation Changes
- [`1c032ae`](https://github.com/JakeStanger/ironbar/commit/1c032ae8e3a38b82c286bab7d102842f14b708e1) - update CHANGELOG.md for v0.7.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`58d55db`](https://github.com/JakeStanger/ironbar/commit/58d55db6600fe2f9b23ae8ec6a50a686d2acaf65) - migrate wiki into main repo *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c480296`](https://github.com/JakeStanger/ironbar/commit/c48029664d5f58bf73faa2931f34b38b8b184d25) - **script**: improve doc comment *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`8c77410`](https://github.com/JakeStanger/ironbar/commit/8c774100f1c8ea051284c6950339a2c8ed59a52a) - **script**: add information on new mode options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`c4cdf4b`](https://github.com/JakeStanger/ironbar/commit/c4cdf4be8ba83f3669158a1552eab4a840085204) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`ec69649`](https://github.com/JakeStanger/ironbar/commit/ec69649a04f6199953836e51c2efe1fe2a19e320) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`1320639`](https://github.com/JakeStanger/ironbar/commit/1320639d4e6b7c8cd8f861b26b2b854504775ef0) - add custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`afedf02`](https://github.com/JakeStanger/ironbar/commit/afedf0214d3a71f6283c70bd3a110d24f68d2fdf) - add link to new custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.7.0] - 2022-11-05
### :sparkles: New Features
- [`fad90fd`](https://github.com/JakeStanger/ironbar/commit/fad90fdad683a612497ac7822a66a90f43fce0a2) - **sys-info**: add loads more formatting tokens *(commit by [@JakeStanger](https://github.com/JakeStanger))*
@@ -110,4 +155,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
[v0.5.1]: https://github.com/JakeStanger/ironbar/compare/v0.5.0...v0.5.1
[v0.5.2]: https://github.com/JakeStanger/ironbar/compare/v0.5.1...v0.5.2
[v0.6.0]: https://github.com/JakeStanger/ironbar/compare/v0.5.2...v0.6.0
[v0.7.0]: https://github.com/JakeStanger/ironbar/compare/v0.6.0...v0.7.0
[v0.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

477
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,11 @@
[package]
name = "ironbar"
version = "0.8.0"
version = "0.9.0"
edition = "2021"
license = "MIT"
description = "Customisable GTK Layer Shell wlroots/sway bar"
[dependencies]
derive_builder = "0.11.2"
gtk = "0.16.0"
gtk-layer-shell = "0.5.0"
glib = "0.16.2"
@@ -21,7 +20,7 @@ serde = { version = "1.0.141", features = ["derive"] }
serde_json = "1.0.82"
serde_yaml = "0.9.4"
toml = "0.5.9"
libcorn = "0.6.0"
libcorn = "0.6.1"
lazy_static = "1.4.0"
async_once = "0.2.6"
indexmap = "1.9.1"
@@ -33,8 +32,10 @@ dirs = "4.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" }
sysinfo = "0.26.4"
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"] }
smithay-client-toolkit = { version = "0.16.0", default-features = false, features = ["calloop"] }

View File

@@ -1,10 +1,12 @@
By default, you get a single bar at the bottom of all your screens.
To change that, you'll unsurprisingly need a config file.
This page details putting together the skeleton for your config to get you to a stage where you can start configuring modules.
It may look long and overwhelming, but that is just because the bar supports a lot of scenarios!
This page details putting together the skeleton for your config to get you to a stage where you can start configuring
modules.
It may look long and overwhelming, but that is just because the bar supports a lot of scenarios!
If you want to see some ready-to-go config files check the [examples folder](https://github.com/JakeStanger/ironbar/tree/master/examples)
If you want to see some ready-to-go config files check
the [examples folder](https://github.com/JakeStanger/ironbar/tree/master/examples)
and the example pages in the sidebar.
## 1. Create config file
@@ -239,7 +241,7 @@ monitors:
<details>
<summary>Corn</summary>
```
```corn
{
monitors.DP-1 = [
{ start = [] }
@@ -281,8 +283,12 @@ 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).
| 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` | `Script [polling]` | `null` | Runs the script when the module is clicked. |
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |
| Name | Type | Default | Description |
|-------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
| `show_if` | `Script [polling]` | `null` | Polls the script to check its exit code. If exit code is zero, the module is shown. For other codes, it is hidden. |
| `on_click_left` | `Script [oneshot]` | `null` | Runs the script when the module is left clicked. |
| `on_click_middle` | `Script [oneshot]` | `null` | Runs the script when the module is middle clicked. |
| `on_click_right` | `Script [oneshot]` | `null` | Runs the script when the module is right clicked. |
| `on_scroll_up` | `Script [oneshot]` | `null` | Runs the script when the module is scroll up on. |
| `on_scroll_down` | `Script [oneshot]` | `null` | Runs the script when the module is scrolled down on. |
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |

View File

@@ -3,13 +3,16 @@ that allow script input to dynamically set values.
Scripts are passed to `sh -c`.
Two types of scripts exist: polling and watching:
Three types of scripts exist: polling, oneshot and watching:
- Polling scripts will run and wait for exit.
- **Polling** scripts will run and wait for exit.
Normally they will repeat this at an interval, hence the name, although in some cases they may only run on a user
event.
If the script exited code 0, the `stdout` will be used. Otherwise, `stderr` will be printed to the log.
- Watching scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
- **Oneshot** scripts are a variant of polling scripts.
They wait for script to exit, and may do something with the output, but are only fired by user events instead of the interval.
Generally options that accept oneshot scripts do not support the other types.
- **Watching** scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
and used.
One should prefer to use watch-mode where possible, as it removes the overhead of regularly spawning processes.
@@ -28,6 +31,8 @@ spawning the script.
Both `mode` and `interval` are optional and can be excluded to fall back to their defaults of `poll` and `5000`
respectively.
For oneshot scripts, both the mode and interval are ignored.
### Shorthand (string)
Shorthand scripts should be written in the format:

View File

@@ -19,7 +19,7 @@
- [Custom](custom)
- [Focused](focused)
- [Launcher](launcher)
- [MPD](mpd)
- [Music](music)
- [Script](script)
- [Sys_Info](sys-info)
- [Tray](tray)

View File

@@ -1,131 +0,0 @@
Displays currently playing song from MPD.
Clicking on the widget opens a popout displaying info about the current song, album art
and playback controls.
![Screenshot showing MPD widget with track playing with popout open](https://user-images.githubusercontent.com/5057870/184539664-a8f3ad5b-69c0-492d-a27d-82303c09a347.png)
## Configuration
> Type: `mpd`
| | Type | Default | Description |
|----------------|----------|-----------------------------|-----------------------------------------------------------------------|
| `host` | `string` | `localhost:6600` | TCP or Unix socket for the 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. |
| `music_dir` | `string` | `$HOME/Music` | Path to MPD server's music directory on disc. Required for album art. |
<details>
<summary>JSON</summary>
```json
{
"start": [
{
"type": "mpd",
"format": "{icon} {title} / {artist}",
"icons": {
"play": "",
"pause": ""
},
"music_dir": "/home/jake/Music"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[start]]
type = "mpd"
format = "{icon} {title} / {artist}"
music_dir = "/home/jake/Music"
[[start.icons]]
play = ""
pause = ""
```
</details>
<details>
<summary>YAML</summary>
```yaml
start:
- type: "mpd"
format: "{icon} {title} / {artist}"
icons:
play: ""
pause: ""
music_dir: "/home/jake/Music"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
start = [
{
type = "mpd"
format = "{icon} {title} / {artist}"
icons.play = ""
icons.pause = ""
music_dir = "/home/jake/Music"
}
]
}
```
</details>
### Formatting Tokens
The following tokens can be used in the `format` config option,
and will be replaced with values from the currently playing track:
| Token | Description |
|--------------|--------------------------------------|
| `{icon}` | Either `icons.play` or `icons.pause` |
| `{title}` | Title |
| `{album}` | Album name |
| `{artist}` | Artist name |
| `{date}` | Release date |
| `{track}` | Track number |
| `{disc}` | Disc number |
| `{genre}` | Genre |
| `{duration}` | Duration in `mm:ss` |
| `{elapsed}` | Time elapsed in `mm:ss` |
## Styling
| Selector | Description |
|----------------------------------------|------------------------------------------|
| `#mpd` | Tray widget button |
| `#popup-mpd` | Popup box |
| `#popup-mpd #album-art` | Album art image inside popup box |
| `#popup-mpd #title` | Track title container inside popup box |
| `#popup-mpd #title .icon` | Track title icon label inside popup box |
| `#popup-mpd #title .label` | Track title label inside popup box |
| `#popup-mpd #album` | Track album container inside popup box |
| `#popup-mpd #album .icon` | Track album icon label inside popup box |
| `#popup-mpd #album .label` | Track album label inside popup box |
| `#popup-mpd #artist` | Track artist container inside popup box |
| `#popup-mpd #artist .icon` | Track artist icon label inside popup box |
| `#popup-mpd #artist .label` | Track artist label inside popup box |
| `#popup-mpd #controls` | Controls container inside popup box |
| `#popup-mpd #controls #btn-prev` | Previous button inside popup box |
| `#popup-mpd #controls #btn-play-pause` | Play/pause button inside popup box |
| `#popup-mpd #controls #btn-next` | Next button inside popup box |
| `#popup-mpd #volume` | Volume container inside popup box |
| `#popup-mpd #volume #slider` | Volume slider popup box |
| `#popup-mpd #volume .icon` | Volume icon label inside popup box |

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

@@ -0,0 +1,139 @@
Displays currently playing song from your music player.
This module supports both MPRIS players and MPD servers.
Clicking on the widget opens a popout displaying info about the current song, album art
and playback controls.
in MPRIS mode, the widget will listen to all players and automatically detect/display the active one.
![Screenshot showing MPD widget with track playing with popout open](https://user-images.githubusercontent.com/5057870/184539664-a8f3ad5b-69c0-492d-a27d-82303c09a347.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. |
<details>
<summary>JSON</summary>
```json
{
"start": [
{
"type": "music",
"player_type": "mpd",
"format": "{icon} {title} / {artist}",
"icons": {
"play": "",
"pause": ""
},
"music_dir": "/home/jake/Music"
}
]
}
```
</details>
<details>
<summary>TOML</summary>
```toml
[[start]]
type = "music"
player_type = "mpd"
format = "{icon} {title} / {artist}"
music_dir = "/home/jake/Music"
[[start.icons]]
play = ""
pause = ""
```
</details>
<details>
<summary>YAML</summary>
```yaml
start:
- type: "music"
player_type: "mpd"
format: "{icon} {title} / {artist}"
icons:
play: ""
pause: ""
music_dir: "/home/jake/Music"
```
</details>
<details>
<summary>Corn</summary>
```corn
{
start = [
{
type = "music"
player_type = "mpd"
format = "{icon} {title} / {artist}"
icons.play = ""
icons.pause = ""
music_dir = "/home/jake/Music"
}
]
}
```
</details>
### Formatting Tokens
The following tokens can be used in the `format` config option,
and will be replaced with values from the currently playing track:
| Token | Description |
|--------------|--------------------------------------|
| `{icon}` | Either `icons.play` or `icons.pause` |
| `{title}` | Title |
| `{album}` | Album name |
| `{artist}` | Artist name |
| `{date}` | Release date |
| `{track}` | Track number |
| `{disc}` | Disc number |
| `{genre}` | Genre |
| `{duration}` | Duration in `mm:ss` |
| `{elapsed}` | Time elapsed in `mm:ss` |
## Styling
| Selector | Description |
|------------------------------------------|------------------------------------------|
| `#music` | Tray widget button |
| `#popup-music` | Popup box |
| `#popup-music #album-art` | Album art image inside popup box |
| `#popup-music #title` | Track title container inside popup box |
| `#popup-music #title .icon` | Track title icon label inside popup box |
| `#popup-music #title .label` | Track title label inside popup box |
| `#popup-music #album` | Track album container inside popup box |
| `#popup-music #album .icon` | Track album icon label inside popup box |
| `#popup-music #album .label` | Track album label inside popup box |
| `#popup-music #artist` | Track artist container inside popup box |
| `#popup-music #artist .icon` | Track artist icon label inside popup box |
| `#popup-music #artist .label` | Track artist label inside popup box |
| `#popup-music #controls` | Controls container inside popup box |
| `#popup-music #controls #btn-prev` | Previous button inside popup box |
| `#popup-music #controls #btn-play-pause` | Play/pause button inside popup box |
| `#popup-music #controls #btn-next` | Next button inside popup box |
| `#popup-music #volume` | Volume container inside popup box |
| `#popup-music #volume #slider` | Volume slider popup box |
| `#popup-music #volume .icon` | Volume icon label inside popup box |

View File

@@ -1,6 +1,6 @@
> ⚠ **This module is currently only supported on Sway**
> ⚠ **This module is currently only supported on Sway and Hyprland**
Shows all current Sway workspaces. Clicking a workspace changes focus to it.
Shows all current workspaces. Clicking a workspace changes focus to it.
![Screenshot showing workspaces widget using custom icons with browser workspace focused](https://user-images.githubusercontent.com/5057870/184540156-26cfe4ec-ab8d-4e0f-a883-8b641025366b.png)
@@ -8,10 +8,11 @@ Shows all current Sway 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. |
| `all_monitors` | `boolean` | `false` | Whether to display workspaces from all monitors. When `false`, only shows workspaces on the current monitor. |
| 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. |
| `all_monitors` | `boolean` | `false` | Whether to display workspaces from all monitors. When `false`, only shows workspaces on the current monitor. |
| `sort` | `added` or `alphanumeric` | `alphanumeric` | The method used for sorting workspaces. `added` always appends to the end, `alphanumeric` sorts by number/name. |
<details>
<summary>JSON</summary>

12
flake.lock generated
View File

@@ -17,11 +17,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1668994630,
"narHash": "sha256-1lqx6HLyw6fMNX/hXrrETG1vMvZRGm2XVC9O/Jt0T6c=",
"lastModified": 1672350804,
"narHash": "sha256-jo6zkiCabUBn3ObuKXHGqqORUMH27gYDIFFfLq5P4wg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "af50806f7c6ab40df3e6b239099e8f8385f6c78b",
"rev": "677ed08a50931e38382dbef01cba08a8f7eac8f6",
"type": "github"
},
"original": {
@@ -45,11 +45,11 @@
]
},
"locked": {
"lastModified": 1669084742,
"narHash": "sha256-aLYwYVnrmEE1LVqd17v99CuqVmAZQrlgi2DVTAs4wFg=",
"lastModified": 1672453260,
"narHash": "sha256-ruR2xo30Vn7kY2hAgg2Z2xrCvNePxck6mgR5a8u+zow=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "9652ef34c7439eca9f86cee11e94dbef5c9adb09",
"rev": "176b6fd3dd3d7cea8d22ab1131364a050228d94c",
"type": "github"
},
"original": {

View File

@@ -86,7 +86,7 @@
...
}: let
cfg = config.programs.ironbar;
defaultIronbarPackage = self.packages.${pkgs.system}.default;
defaultIronbarPackage = self.packages.${pkgs.hostPlatform.system}.default;
jsonFormat = pkgs.formats.json {};
in {
options.programs.ironbar = {

View File

@@ -1,23 +1,15 @@
use crate::bridge_channel::BridgeChannel;
use crate::config::{BarPosition, ModuleConfig};
use crate::config::{BarPosition, CommonConfig, ModuleConfig};
use crate::dynamic_string::DynamicString;
use crate::modules::custom::ExecEvent;
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
use crate::modules::mpd::{PlayerCommand, SongUpdate};
use crate::modules::workspaces::WorkspaceUpdate;
use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::modules::{Module, ModuleInfo, ModuleLocation, ModuleUpdateEvent, WidgetContext};
use crate::popup::Popup;
use crate::script::{OutputStream, Script};
use crate::{await_sync, Config};
use chrono::{DateTime, Local};
use crate::{await_sync, read_lock, send, write_lock, Config};
use color_eyre::Result;
use gtk::gdk::Monitor;
use gtk::gdk::{EventMask, Monitor, ScrollDirection};
use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Orientation};
use std::collections::HashMap;
use gtk::{Application, ApplicationWindow, EventBox, Orientation, Widget};
use std::sync::{Arc, RwLock};
use stray::message::NotifierItemCommand;
use stray::NotifierItemMessage;
use tokio::spawn;
use tokio::sync::mpsc;
use tracing::{debug, error, info, trace};
@@ -49,26 +41,11 @@ pub fn create_bar(
}
.build();
let start = gtk::Box::builder()
.orientation(orientation)
.spacing(0)
.name("start")
.build();
let center = gtk::Box::builder()
.orientation(orientation)
.spacing(0)
.name("center")
.build();
let end = gtk::Box::builder()
.orientation(orientation)
.spacing(0)
.name("end")
.build();
content.style_context().add_class("container");
start.style_context().add_class("container");
center.style_context().add_class("container");
end.style_context().add_class("container");
let start = create_container("start", orientation);
let center = create_container("center", orientation);
let end = create_container("end", orientation);
content.add(&start);
content.set_center_widget(Some(&center));
@@ -84,6 +61,9 @@ pub fn create_bar(
});
debug!("Showing bar");
// show each box but do not use `show_all`.
// this ensures `show_if` option works as intended.
start.show();
center.show();
end.show();
@@ -93,218 +73,6 @@ pub fn create_bar(
Ok(())
}
/// Loads the configured modules onto a bar.
fn load_modules(
left: &gtk::Box,
center: &gtk::Box,
right: &gtk::Box,
app: &Application,
config: Config,
monitor: &Monitor,
output_name: &str,
) -> Result<()> {
let mut info_builder = ModuleInfoBuilder::default();
let info_builder = info_builder
.app(app)
.bar_position(config.position)
.monitor(monitor)
.output_name(output_name);
if let Some(modules) = config.start {
let info_builder = info_builder.location(ModuleLocation::Left);
add_modules(left, modules, info_builder)?;
}
if let Some(modules) = config.center {
let info_builder = info_builder.location(ModuleLocation::Center);
add_modules(center, modules, info_builder)?;
}
if let Some(modules) = config.end {
let info_builder = info_builder.location(ModuleLocation::Right);
add_modules(right, modules, info_builder)?;
}
Ok(())
}
/// 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_builder: &mut ModuleInfoBuilder,
) -> Result<()> {
let base_popup_info = info_builder.module_name("").build()?;
let popup = Popup::new(&base_popup_info);
let popup = Arc::new(RwLock::new(popup));
macro_rules! add_module {
($module:expr, $id:expr, $name:literal, $send_message:ty, $receive_message:ty) => {
let info = info_builder.module_name($name).build()?;
let (w_tx, w_rx) = glib::MainContext::channel::<$send_message>(glib::PRIORITY_DEFAULT);
let (p_tx, p_rx) = glib::MainContext::channel::<$send_message>(glib::PRIORITY_DEFAULT);
let channel = BridgeChannel::<ModuleUpdateEvent<$send_message>>::new();
let (ui_tx, ui_rx) = mpsc::channel::<$receive_message>(16);
$module.spawn_controller(&info, channel.create_sender(), ui_rx)?;
let context = WidgetContext {
id: $id,
widget_rx: w_rx,
popup_rx: p_rx,
tx: channel.create_sender(),
controller_tx: ui_tx,
};
let common = $module.common.clone();
let widget = $module.into_widget(context, &info)?;
let container = gtk::EventBox::new();
container.add(&widget.widget);
content.add(&container);
widget.widget.set_widget_name(info.module_name);
if let Some(show_if) = common.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)| {
tx.send(success)
.expect("Failed to send widget visibility toggle message");
})
.await;
});
rx.attach(None, move |success| {
if success {
container.show_all()
} else {
container.hide()
};
Continue(true)
});
} else {
container.show_all();
}
if let Some(on_click) = common.on_click {
let script = Script::new_polling(on_click);
container.connect_button_press_event(move |_, _| {
trace!("Running on-click script");
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)
});
}
let has_popup = widget.popup.is_some();
if let Some(popup_content) = widget.popup {
popup
.write()
.expect("Failed to get write lock on popup")
.register_content($id, popup_content);
}
let popup2 = Arc::clone(&popup);
channel.recv(move |ev| {
let popup = popup2.clone();
match ev {
ModuleUpdateEvent::Update(update) => {
if has_popup {
p_tx.send(update.clone())
.expect("Failed to send update to popup");
}
w_tx.send(update).expect("Failed to send update to module");
}
ModuleUpdateEvent::TogglePopup(geometry) => {
debug!("Toggling popup for {} [#{}]", $name, $id);
let popup = popup.read().expect("Failed to get read lock on 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 = popup.read().expect("Failed to get read lock on popup");
popup.hide();
popup.show_content($id);
popup.show(geometry);
}
ModuleUpdateEvent::ClosePopup => {
debug!("Closing popup for {} [#{}]", $name, $id);
let popup = popup.read().expect("Failed to get read lock on popup");
popup.hide();
}
}
Continue(true)
});
};
}
for (id, config) in modules.into_iter().enumerate() {
match config {
ModuleConfig::Clock(module) => {
add_module!(module, id, "clock", DateTime<Local>, ());
}
ModuleConfig::Script(module) => {
add_module!(module, id, "script", String, ());
}
ModuleConfig::SysInfo(module) => {
add_module!(module, id, "sysinfo", HashMap<String, String>, ());
}
ModuleConfig::Focused(module) => {
add_module!(module, id, "focused", (String, String), ());
}
ModuleConfig::Workspaces(module) => {
add_module!(module, id, "workspaces", WorkspaceUpdate, String);
}
ModuleConfig::Tray(module) => {
add_module!(module, id, "tray", NotifierItemMessage, NotifierItemCommand);
}
ModuleConfig::Mpd(module) => {
add_module!(module, id, "mpd", Option<SongUpdate>, PlayerCommand);
}
ModuleConfig::Launcher(module) => {
add_module!(module, id, "launcher", LauncherUpdate, ItemEvent);
}
ModuleConfig::Custom(module) => {
add_module!(module, id, "custom", (), ExecEvent);
}
}
}
Ok(())
}
/// Sets up GTK layer shell for a provided application window.
fn setup_layer_shell(
win: &ApplicationWindow,
@@ -349,3 +117,289 @@ fn setup_layer_shell(
|| (bar_orientation == Orientation::Horizontal && anchor_to_edges),
);
}
/// Creates a `gtk::Box` container to place widgets inside.
fn create_container(name: &str, orientation: Orientation) -> gtk::Box {
let container = gtk::Box::builder()
.orientation(orientation)
.spacing(0)
.name(name)
.build();
container.style_context().add_class("container");
container
}
/// Loads the configured modules onto a bar.
fn load_modules(
left: &gtk::Box,
center: &gtk::Box,
right: &gtk::Box,
app: &Application,
config: Config,
monitor: &Monitor,
output_name: &str,
) -> Result<()> {
macro_rules! info {
($location:expr) => {
ModuleInfo {
app,
bar_position: config.position,
monitor,
output_name,
location: $location,
}
};
}
if let Some(modules) = config.start {
let info = info!(ModuleLocation::Left);
add_modules(left, modules, &info)?;
}
if let Some(modules) = config.center {
let info = info!(ModuleLocation::Center);
add_modules(center, modules, &info)?;
}
if let Some(modules) = config.end {
let info = info!(ModuleLocation::Right);
add_modules(right, modules, &info)?;
}
Ok(())
}
/// Adds modules into a provided GTK box,
/// which should be one of its left, center or right containers.
fn add_modules(content: &gtk::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) -> Result<()> {
let popup = Popup::new(info);
let popup = Arc::new(RwLock::new(popup));
macro_rules! add_module {
($module:expr, $id:expr) => {{
let common = $module.common.take().expect("Common config did not exist");
let widget = create_module($module, $id, &info, &Arc::clone(&popup))?;
let container = wrap_widget(&widget);
content.add(&container);
setup_module_common_options(container, common);
}};
}
for (id, config) in modules.into_iter().enumerate() {
match config {
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),
}
}
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)
});
}
}

View File

@@ -1,3 +1,4 @@
use crate::send;
use tokio::spawn;
use tokio::sync::mpsc;
@@ -21,7 +22,7 @@ impl<T: Send + 'static> BridgeChannel<T> {
spawn(async move {
while let Some(val) = async_rx.recv().await {
sync_tx.send(val).expect("Failed to send message");
send!(sync_tx, val);
}
});

View File

@@ -0,0 +1,250 @@
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
use crate::error::{ERR_CHANNEL_SEND, ERR_MUTEX_LOCK};
use hyprland::data::{Workspace as HWorkspace, Workspaces};
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
use hyprland::event_listener::EventListenerMutable as EventListener;
use hyprland::prelude::*;
use hyprland::shared::WorkspaceType;
use lazy_static::lazy_static;
use std::sync::{Arc, Mutex};
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::task::spawn_blocking;
use tracing::{error, info};
pub struct EventClient {
workspaces: Arc<Mutex<Vec<Workspace>>>,
workspace_tx: Sender<WorkspaceUpdate>,
_workspace_rx: Receiver<WorkspaceUpdate>,
}
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,
}
}
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();
{
let workspaces = workspaces.clone();
let tx = tx.clone();
event_listener.add_workspace_added_handler(move |workspace_type, _state| {
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::Add(workspace))
.expect(ERR_CHANNEL_SEND);
},
);
});
}
{
let workspaces = workspaces.clone();
let tx = tx.clone();
event_listener.add_workspace_change_handler(move |workspace_type, _state| {
let prev_workspace = Self::get_focused_workspace(&workspaces);
Self::refresh_workspaces(&workspaces);
let workspace = Self::get_workspace(&workspaces, workspace_type);
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);
}
// there may be another type of update so dispatch that regardless of focus change
tx.send(WorkspaceUpdate::Update(workspace))
.expect(ERR_CHANNEL_SEND);
} 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);
},
);
});
}
{
let workspaces = workspaces.clone();
event_listener.add_active_monitor_change_handler(move |event_data, _state| {
let workspace_type = event_data.1;
let prev_workspace = Self::get_focused_workspace(&workspaces);
Self::refresh_workspaces(&workspaces);
let workspace = Self::get_workspace(&workspaces, workspace_type);
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);
}
} else {
error!("Unable to locate workspace");
}
});
}
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);
let active = HWorkspace::get_active().expect("Failed to get active workspace");
let new_workspaces = 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);
}
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()
}
}
impl WorkspaceClient for EventClient {
fn focus(&self, id: String) -> color_eyre::Result<()> {
Dispatch::call(DispatchType::Workspace(
WorkspaceIdentifierWithSpecial::Name(&id),
))?;
Ok(())
}
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
let rx = self.workspace_tx.subscribe();
{
let tx = self.workspace_tx.clone();
let workspaces = self.workspaces.clone();
Self::refresh_workspaces(&workspaces);
let workspaces = workspaces.lock().expect(ERR_MUTEX_LOCK);
tx.send(WorkspaceUpdate::Init(workspaces.clone()))
.expect(ERR_CHANNEL_SEND);
}
rx
}
}
lazy_static! {
static ref CLIENT: EventClient = {
let client = EventClient::new();
client.listen_workspace_events();
client
};
}
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,
WorkspaceType::Special(name) => name.unwrap_or_default(),
}
}
impl From<(bool, hyprland::data::Workspace)> for Workspace {
fn from((focused, workspace): (bool, hyprland::data::Workspace)) -> Self {
Self {
id: id_to_string(workspace.id),
name: workspace.name,
monitor: workspace.monitor,
focused,
}
}
}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
pub mod mpd;
pub mod sway;
pub mod compositor;
pub mod music;
pub mod system_tray;
pub mod wayland;

View File

@@ -1,167 +0,0 @@
use lazy_static::lazy_static;
use mpd_client::client::{CommandError, Connection, ConnectionEvent, Subsystem};
use mpd_client::commands::Command;
use mpd_client::protocol::MpdProtocolError;
use mpd_client::responses::Status;
use mpd_client::Client;
use std::collections::HashMap;
use std::fmt::{Display, Formatter};
use std::os::unix::fs::FileTypeExt;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpStream, UnixStream};
use tokio::spawn;
use tokio::sync::broadcast::{channel, error::SendError, Receiver, Sender};
use tokio::sync::Mutex;
use tokio::time::sleep;
use tracing::debug;
lazy_static! {
static ref CONNECTIONS: Arc<Mutex<HashMap<String, Arc<MpdClient>>>> =
Arc::new(Mutex::new(HashMap::new()));
}
pub struct MpdClient {
client: Client,
tx: Sender<()>,
_rx: Receiver<()>,
}
#[derive(Debug)]
pub enum MpdConnectionError {
MaxRetries,
ProtocolError(MpdProtocolError),
}
impl Display for MpdConnectionError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match self {
Self::MaxRetries => write!(f, "Reached max retries"),
Self::ProtocolError(e) => write!(f, "{:?}", e),
}
}
}
impl std::error::Error for MpdConnectionError {}
impl MpdClient {
async fn new(host: &str) -> Result<Self, MpdConnectionError> {
debug!("Creating new MPD connection to {}", host);
let (client, mut state_changes) =
wait_for_connection(host, Duration::from_secs(5), None).await?;
let (tx, rx) = channel(16);
let tx2 = tx.clone();
spawn(async move {
while let Some(change) = state_changes.next().await {
debug!("Received state change: {:?}", change);
if let ConnectionEvent::SubsystemChange(
Subsystem::Player | Subsystem::Queue | Subsystem::Mixer,
) = change
{
tx2.send(())?;
}
}
Ok::<(), SendError<()>>(())
});
Ok(Self {
client,
tx,
_rx: rx,
})
}
pub fn subscribe(&self) -> Receiver<()> {
self.tx.subscribe()
}
pub async fn command<C: Command>(&self, command: C) -> Result<C::Response, CommandError> {
self.client.command(command).await
}
}
pub async fn get_client(host: &str) -> Result<Arc<MpdClient>, MpdConnectionError> {
let mut connections = CONNECTIONS.lock().await;
match connections.get(host) {
None => {
let client = MpdClient::new(host).await?;
let client = Arc::new(client);
connections.insert(host.to_string(), Arc::clone(&client));
Ok(client)
}
Some(client) => Ok(Arc::clone(client)),
}
}
async fn wait_for_connection(
host: &str,
interval: Duration,
max_retries: Option<usize>,
) -> Result<Connection, MpdConnectionError> {
let mut retries = 0;
let max_retries = max_retries.unwrap_or(usize::MAX);
loop {
if retries == max_retries {
break Err(MpdConnectionError::MaxRetries);
}
retries += 1;
match try_get_mpd_conn(host).await {
Ok(conn) => break Ok(conn),
Err(err) => {
if retries == max_retries {
break Err(MpdConnectionError::ProtocolError(err));
}
}
}
sleep(interval).await;
}
}
/// Cycles through each MPD host and
/// returns the first one which connects,
/// or none if there are none
async fn try_get_mpd_conn(host: &str) -> Result<Connection, MpdProtocolError> {
if is_unix_socket(host) {
connect_unix(host).await
} else {
connect_tcp(host).await
}
}
fn is_unix_socket(host: &str) -> bool {
let path = PathBuf::from(host);
path.exists()
&& path
.metadata()
.map_or(false, |metadata| metadata.file_type().is_socket())
}
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = UnixStream::connect(host).await?;
Client::connect(connection).await
}
async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = TcpStream::connect(host).await?;
Client::connect(connection).await
}
/// Gets the duration of the current song
pub fn get_duration(status: &Status) -> Option<u64> {
status.duration.map(|duration| duration.as_secs())
}
/// Gets the elapsed time of the current song
pub fn get_elapsed(status: &Status) -> Option<u64> {
status.elapsed.map(|duration| duration.as_secs())
}

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

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

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

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

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

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

View File

@@ -1,74 +0,0 @@
use async_once::AsyncOnce;
use color_eyre::Report;
use futures_util::StreamExt;
use lazy_static::lazy_static;
use std::sync::Arc;
use swayipc_async::{Connection, Event, EventType, WorkspaceEvent};
use tokio::spawn;
use tokio::sync::broadcast::{channel, Receiver, Sender};
use tokio::sync::Mutex;
use tracing::{info, trace};
pub struct SwayEventClient {
workspace_tx: Sender<Box<WorkspaceEvent>>,
_workspace_rx: Receiver<Box<WorkspaceEvent>>,
}
impl SwayEventClient {
fn new() -> Self {
let (workspace_tx, workspace_rx) = channel(16);
let workspace_tx2 = workspace_tx.clone();
spawn(async move {
let workspace_tx = workspace_tx2;
let client = Connection::new().await?;
info!("Sway IPC subscription client connected");
let event_types = [EventType::Workspace];
let mut events = client.subscribe(event_types).await?;
while let Some(event) = events.next().await {
trace!("event: {:?}", event);
if let Event::Workspace(ev) = event? {
workspace_tx.send(ev)?;
};
}
Ok::<(), Report>(())
});
Self {
workspace_tx,
_workspace_rx: workspace_rx,
}
}
/// Gets an event receiver for workspace events
pub fn subscribe_workspace(&self) -> Receiver<Box<WorkspaceEvent>> {
self.workspace_tx.subscribe()
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<Arc<Mutex<Connection>>> = AsyncOnce::new(async {
let client = Connection::new()
.await
.expect("Failed to connect to Sway socket");
Arc::new(Mutex::new(client))
});
static ref SUB_CLIENT: SwayEventClient = SwayEventClient::new();
}
/// Gets the sway IPC client
pub async fn get_client() -> Arc<Mutex<Connection>> {
let client = CLIENT.get().await;
Arc::clone(client)
}
/// Gets the sway IPC event subscription client
pub fn get_sub_client() -> &'static SwayEventClient {
&SUB_CLIENT
}

View File

@@ -2,6 +2,7 @@ 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 color_eyre::Report;
use indexmap::IndexMap;
use smithay_client_toolkit::environment::Environment;
@@ -46,19 +47,16 @@ impl WaylandClient {
.expect("Failed to connect to Wayland compositor");
let outputs = Self::get_outputs(&env);
output_tx
.send(outputs)
.expect("Failed to send outputs out of task");
send!(output_tx, outputs);
let seats = env.get_all_seats();
seat_tx
.send(
seats
.into_iter()
.map(|seat| seat.detach())
.collect::<Vec<WlSeat>>(),
)
.expect("Failed to send seats out of task");
send!(
seat_tx,
seats
.into_iter()
.map(|seat| seat.detach())
.collect::<Vec<WlSeat>>()
);
let _toplevel_manager = env.require_global::<ZwlrForeignToplevelManagerV1>();
@@ -66,20 +64,13 @@ impl WaylandClient {
trace!("Received toplevel event: {:?}", event);
if event.change == ToplevelChange::Close {
toplevels2
.write()
.expect("Failed to get write lock on toplevels")
.remove(&event.toplevel.id);
write_lock!(toplevels2).remove(&event.toplevel.id);
} else {
toplevels2
.write()
.expect("Failed to get write lock on toplevels")
write_lock!(toplevels2)
.insert(event.toplevel.id, (event.toplevel.clone(), handle));
}
toplevel_tx2
.send(event)
.expect("Failed to send toplevel event");
send!(toplevel_tx2, event);
});
let mut event_loop =
@@ -99,11 +90,9 @@ impl WaylandClient {
}
});
let outputs = output_rx
.await
.expect("Failed to receive outputs from task");
let outputs = output_rx.await.expect(err::ERR_CHANNEL_RECV);
let seats = seat_rx.await.expect("Failed to receive seats from task");
let seats = seat_rx.await.expect(err::ERR_CHANNEL_RECV);
Self {
outputs,

View File

@@ -4,11 +4,13 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use tracing::trace;
use wayland_client::{DispatchData, Main};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::{Event, ZwlrForeignToplevelHandleV1};
use crate::write_lock;
const STATE_ACTIVE: u32 = 2;
const STATE_FULLSCREEN: u32 = 3;
static COUNTER: AtomicUsize = AtomicUsize::new(1);
fn get_id() -> usize {
COUNTER.fetch_add(1, Ordering::Relaxed)
}
@@ -108,9 +110,9 @@ where
None
}
}
Event::OutputEnter { output: _ } => None,
Event::OutputLeave { output: _ } => None,
Event::Parent { parent: _ } => None,
Event::OutputEnter { output: _ }
| Event::OutputLeave { output: _ }
| Event::Parent { parent: _ } => None,
Event::Done => {
if info.ready || info.app_id.is_empty() {
None
@@ -119,6 +121,7 @@ where
Some(ToplevelChange::New)
}
}
_ => unreachable!(),
};
@@ -140,9 +143,7 @@ impl Toplevel {
let inner = Arc::new(RwLock::new(ToplevelInfo::new()));
handle.quick_assign(move |_handle, event, ddata| {
let mut inner = inner
.write()
.expect("Failed to get write lock on toplevel inner state");
let mut inner = write_lock!(inner);
toplevel_implem(event, &mut inner, &mut callback, ddata);
});

View File

@@ -58,7 +58,7 @@ impl GlobalHandler<ZwlrForeignToplevelManagerV1> for ToplevelHandler {
if inner.registry.is_none() {
inner.registry = Some(registry);
}
if let LazyGlobal::Unknown = inner.manager {
if matches!(inner.manager, LazyGlobal::Unknown) {
inner.manager = LazyGlobal::Seen { id, version }
} else {
warn!(

View File

@@ -1,68 +1,49 @@
use crate::modules::clock::ClockModule;
use crate::modules::custom::CustomModule;
use crate::modules::focused::FocusedModule;
use crate::modules::launcher::LauncherModule;
use crate::modules::mpd::MpdModule;
use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule;
use crate::modules::workspaces::WorkspacesModule;
use crate::script::ScriptInput;
use color_eyre::eyre::{Context, ContextCompat};
use color_eyre::{eyre, Help, Report};
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 eyre::Result;
use gtk::Orientation;
use serde::Deserialize;
use std::collections::HashMap;
use serde::{Deserialize, Deserializer};
use std::path::{Path, PathBuf};
use std::{env, fs};
use tracing::instrument;
#[derive(Debug, Deserialize, Clone)]
pub struct CommonConfig {
pub show_if: Option<ScriptInput>,
pub on_click: Option<ScriptInput>,
pub tooltip: Option<String>,
}
// 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>
where
D: Deserializer<'de>,
{
let content =
<serde::__private::de::Content as serde::Deserialize>::deserialize(deserializer)?;
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ModuleConfig {
Clock(ClockModule),
Mpd(MpdModule),
Tray(TrayModule),
Workspaces(WorkspacesModule),
SysInfo(SysInfoModule),
Launcher(LauncherModule),
Script(ScriptModule),
Focused(FocusedModule),
Custom(CustomModule),
}
match <Config as serde::Deserialize>::deserialize(
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
) {
Ok(config) => Ok(Self::Single(config)),
Err(outer) => match <Vec<Config> as serde::Deserialize>::deserialize(
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
) {
Ok(config) => Ok(Self::Multiple(config)),
Err(inner) => {
let report = Report::msg(format!(" multi-bar (c): {inner}").replace("An error occurred when deserializing: ", ""))
.wrap_err(format!("single-bar (b): {outer}").replace("An error occurred when deserializing: ", ""))
.wrap_err("An invalid config was found. The following errors were encountered:")
.note("Both the single-bar (type b / error 1) and multi-bar (type c / error 2) config variants were tried. You can likely ignore whichever of these is not relevant to you.")
.suggestion("Please see https://github.com/JakeStanger/ironbar/wiki/configuration-guide#2-pick-your-use-case for more info on the above");
#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum MonitorConfig {
Single(Config),
Multiple(Vec<Config>),
}
#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BarPosition {
Top,
Bottom,
Left,
Right,
}
impl Default for BarPosition {
fn default() -> Self {
Self::Bottom
Err(serde::de::Error::custom(format!("{report:?}")))
}
},
}
}
}
impl BarPosition {
/// Gets the orientation the bar and widgets should use
/// based on this position.
pub fn get_orientation(self) -> Orientation {
if self == Self::Top || self == Self::Bottom {
Orientation::Horizontal
@@ -71,6 +52,8 @@ impl BarPosition {
}
}
/// Gets the angle that label text should be displayed at
/// based on this position.
pub const fn get_angle(self) -> f64 {
match self {
Self::Top | Self::Bottom => 0.0,
@@ -80,34 +63,9 @@ impl BarPosition {
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default = "default_bar_position")]
pub position: BarPosition,
#[serde(default = "default_true")]
pub anchor_to_edges: bool,
#[serde(default = "default_bar_height")]
pub height: i32,
pub start: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>,
pub end: Option<Vec<ModuleConfig>>,
pub monitors: Option<HashMap<String, MonitorConfig>>,
}
const fn default_bar_position() -> BarPosition {
BarPosition::Bottom
}
const fn default_bar_height() -> i32 {
42
}
impl Config {
/// Attempts to load the config file from file,
/// parse it and return a new instance of `Self`.
#[instrument]
pub fn load() -> Result<Self> {
let config_path = env::var("IRONBAR_CONFIG").map_or_else(
|_| Self::try_find_config(),
@@ -180,10 +138,3 @@ impl Config {
}
}
}
pub const fn default_false() -> bool {
false
}
pub const fn default_true() -> bool {
true
}

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

@@ -0,0 +1,94 @@
mod r#impl;
use crate::modules::clock::ClockModule;
use crate::modules::custom::CustomModule;
use crate::modules::focused::FocusedModule;
use crate::modules::launcher::LauncherModule;
use crate::modules::music::MusicModule;
use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule;
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>,
}
#[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),
}
#[derive(Debug, Clone)]
pub enum MonitorConfig {
Single(Config),
Multiple(Vec<Config>),
}
#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum BarPosition {
Top,
Bottom,
Left,
Right,
}
impl Default for BarPosition {
fn default() -> Self {
Self::Bottom
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct Config {
#[serde(default = "default_bar_position")]
pub position: BarPosition,
#[serde(default = "default_true")]
pub anchor_to_edges: bool,
#[serde(default = "default_bar_height")]
pub height: i32,
pub start: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>,
pub end: Option<Vec<ModuleConfig>>,
pub monitors: Option<HashMap<String, MonitorConfig>>,
}
const fn default_bar_position() -> BarPosition {
BarPosition::Bottom
}
const fn default_bar_height() -> i32 {
42
}
pub const fn default_false() -> bool {
false
}
pub const fn default_true() -> bool {
true
}

View File

@@ -1,4 +1,5 @@
use crate::script::{OutputStream, Script};
use crate::{lock, send};
use gtk::prelude::*;
use indexmap::IndexMap;
use std::sync::{Arc, Mutex};
@@ -10,9 +11,7 @@ enum DynamicStringSegment {
Dynamic(Script),
}
pub struct DynamicString {
// pub label: gtk::Label,
}
pub struct DynamicString;
impl DynamicString {
pub fn new<F>(input: &str, f: F) -> Self
@@ -67,10 +66,7 @@ impl DynamicString {
for (i, segment) in segments.into_iter().enumerate() {
match segment {
DynamicStringSegment::Static(str) => {
label_parts
.lock()
.expect("Failed to get lock on label parts")
.insert(i, str);
lock!(label_parts).insert(i, str);
}
DynamicStringSegment::Dynamic(script) => {
let tx = tx.clone();
@@ -80,21 +76,16 @@ impl DynamicString {
script
.run(|(out, _)| {
if let OutputStream::Stdout(out) = out {
let mut label_parts = label_parts
.lock()
.expect("Failed to get lock on label parts");
let mut label_parts = lock!(label_parts);
label_parts
// .lock()
// .expect("Failed to get lock on label parts")
.insert(i, out);
label_parts.insert(i, out);
let string = label_parts
.iter()
.map(|(_, part)| part.as_str())
.collect::<String>();
tx.send(string).expect("Failed to send update");
send!(tx, string);
}
})
.await;
@@ -105,20 +96,17 @@ impl DynamicString {
// initialize
{
let label_parts = label_parts
.lock()
.expect("Failed to get lock on label parts")
let label_parts = lock!(label_parts)
.iter()
.map(|(_, part)| part.as_str())
.collect::<String>();
tx.send(label_parts).expect("Failed to send update");
send!(tx, label_parts);
}
rx.attach(None, f);
// Self { label }
Self {}
Self
}
}

13
src/error.rs Normal file
View File

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

View File

@@ -101,8 +101,7 @@ fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconL
return match dirs::data_dir() {
Some(dir) => {
let path = dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{}.png",
steam_id
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
));
return Some(IconLocation::File(path));

View File

@@ -1,7 +1,8 @@
use color_eyre::Result;
use dirs::data_dir;
use std::env;
use std::{env, panic};
use strip_ansi_escapes::Writer;
use tracing::error;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::{Layer, MakeWriter};
@@ -26,11 +27,34 @@ impl<'a> MakeWriter<'a> for MakeFileWriter {
}
}
pub fn install_logging() -> Result<WorkerGuard> {
// Disable backtraces by default
if env::var("RUST_LIB_BACKTRACE").is_err() {
env::set_var("RUST_LIB_BACKTRACE", "0");
}
// keep guard in scope
// otherwise file logging drops
let guard = install_tracing()?;
let hook_builder = color_eyre::config::HookBuilder::default();
let (panic_hook, eyre_hook) = hook_builder.into_hooks();
eyre_hook.install()?;
// custom hook allows tracing_appender to capture panics
panic::set_hook(Box::new(move |panic_info| {
error!("{}", panic_hook.panic_report(panic_info));
}));
Ok(guard)
}
/// Installs tracing into the current application.
///
/// The returned `WorkerGuard` must remain in scope
/// for the lifetime of the application for logging to file to work.
pub fn install_tracing() -> Result<WorkerGuard> {
fn install_tracing() -> Result<WorkerGuard> {
const DEFAULT_LOG: &str = "info";
const DEFAULT_FILE_LOG: &str = "warn";

89
src/macros.rs Normal file
View File

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

View File

@@ -3,8 +3,10 @@ mod bridge_channel;
mod clients;
mod config;
mod dynamic_string;
mod error;
mod icon;
mod logging;
mod macros;
mod modules;
mod popup;
mod script;
@@ -19,62 +21,37 @@ use dirs::config_dir;
use gtk::gdk::Display;
use gtk::prelude::*;
use gtk::Application;
use std::env;
use std::future::Future;
use std::path::PathBuf;
use std::process::exit;
use std::{env, panic};
use tokio::runtime::Handle;
use tokio::task::block_in_place;
use crate::logging::install_tracing;
use crate::error::ExitCode;
use clients::wayland::{self, WaylandClient};
use tracing::{debug, error, info};
const GTK_APP_ID: &str = "dev.jstanger.ironbar";
const VERSION: &str = env!("CARGO_PKG_VERSION");
#[repr(i32)]
enum ErrorCode {
GtkDisplay = 1,
CreateBars = 2,
Config = 3,
}
#[tokio::main]
async fn main() -> Result<()> {
// Disable backtraces by default
if env::var("RUST_LIB_BACKTRACE").is_err() {
env::set_var("RUST_LIB_BACKTRACE", "0");
}
// keep guard in scope
// otherwise file logging drops
let _guard = install_tracing()?;
let hook_builder = color_eyre::config::HookBuilder::default();
let (panic_hook, eyre_hook) = hook_builder.into_hooks();
eyre_hook.install()?;
// custom hook allows tracing_appender to capture panics
panic::set_hook(Box::new(move |panic_info| {
error!("{}", panic_hook.panic_report(panic_info));
}));
let _guard = logging::install_logging();
info!("Ironbar version {}", VERSION);
info!("Starting application");
let wayland_client = wayland::get_client().await;
let app = Application::builder()
.application_id("dev.jstanger.ironbar")
.build();
let app = Application::builder().application_id(GTK_APP_ID).build();
app.connect_activate(move |app| {
let display = Display::default().map_or_else(
|| {
let report = Report::msg("Failed to get default GTK display");
error!("{:?}", report);
exit(ErrorCode::GtkDisplay as i32)
exit(ExitCode::GtkDisplay as i32)
},
|display| display,
);
@@ -83,14 +60,14 @@ async fn main() -> Result<()> {
Ok(config) => config,
Err(err) => {
error!("{:?}", err);
exit(ErrorCode::Config as i32)
exit(ExitCode::Config as i32)
}
};
debug!("Loaded config file");
if let Err(err) = create_bars(app, &display, wayland_client, &config) {
error!("{:?}", err);
exit(ErrorCode::CreateBars as i32);
exit(ExitCode::CreateBars as i32);
}
debug!("Created bars");
@@ -101,7 +78,7 @@ async fn main() -> Result<()> {
|| {
let report = Report::msg("Failed to locate user config dir");
error!("{:?}", report);
exit(ErrorCode::CreateBars as i32);
exit(ExitCode::CreateBars as i32);
},
|dir| dir.join("ironbar").join("style.css"),
)
@@ -136,11 +113,14 @@ fn create_bars(
let num_monitors = display.n_monitors();
for i in 0..num_monitors {
let monitor = display.monitor(i).ok_or_else(|| Report::msg("GTK and Sway are reporting a different set of outputs - this is a severe bug and should never happen"))?;
let output = outputs.get(i as usize).ok_or_else(|| Report::msg("GTK and Sway are reporting a different set of outputs - this is a severe bug and should never happen"))?;
let monitor = display
.monitor(i)
.ok_or_else(|| Report::msg(error::ERR_OUTPUTS))?;
let output = outputs
.get(i as usize)
.ok_or_else(|| Report::msg(error::ERR_OUTPUTS))?;
let monitor_name = &output.name;
// TODO: Could we use an Arc<Config> or `Cow<Config>` here to avoid cloning?
config.monitors.as_ref().map_or_else(
|| {
info!("Creating bar on '{}'", monitor_name);
@@ -178,6 +158,8 @@ fn create_bars(
/// Do note it must be called from within a Tokio runtime still.
///
/// Use sparingly! Prefer async functions wherever possible.
///
/// TODO: remove all instances of this once async trait funcs are stable
pub fn await_sync<F: Future>(f: F) -> F::Output {
block_in_place(|| Handle::current().block_on(f))
}

View File

@@ -1,6 +1,7 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::{send_async, try_send};
use chrono::{DateTime, Local};
use color_eyre::Result;
use glib::Continue;
@@ -22,7 +23,7 @@ pub struct ClockModule {
format: String,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
fn default_format() -> String {
@@ -33,6 +34,10 @@ impl Module<Button> for ClockModule {
type SendMessage = DateTime<Local>;
type ReceiveMessage = ();
fn name() -> &'static str {
"clock"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
@@ -42,9 +47,7 @@ impl Module<Button> for ClockModule {
spawn(async move {
loop {
let date = Local::now();
tx.send(ModuleUpdateEvent::Update(date))
.await
.expect("Failed to send date");
send_async!(tx, ModuleUpdateEvent::Update(date));
sleep(tokio::time::Duration::from_millis(500)).await;
}
});
@@ -64,13 +67,10 @@ impl Module<Button> for ClockModule {
let orientation = info.bar_position.get_orientation();
button.connect_clicked(move |button| {
context
.tx
.try_send(ModuleUpdateEvent::TogglePopup(Popup::button_pos(
button,
orientation,
)))
.expect("Failed to toggle popup");
try_send!(
context.tx,
ModuleUpdateEvent::TogglePopup(Popup::button_pos(button, orientation))
);
});
let format = self.format.clone();

View File

@@ -3,6 +3,7 @@ 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};
@@ -21,7 +22,7 @@ pub struct CustomModule {
popup: Option<Vec<Widget>>,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
/// Attempts to parse an `Orientation` from `String`
@@ -119,8 +120,6 @@ impl Widget {
}
label
// DynamicString::new(label, &text)
}
/// Creates a `gtk::Button` from this widget
@@ -146,11 +145,13 @@ impl Widget {
if let Some(exec) = self.on_click {
button.connect_clicked(move |button| {
tx.try_send(ExecEvent {
cmd: exec.clone(),
geometry: Popup::button_pos(button, bar_orientation),
})
.expect("Failed to send exec message");
try_send!(
tx,
ExecEvent {
cmd: exec.clone(),
geometry: Popup::button_pos(button, bar_orientation),
}
);
});
}
@@ -168,6 +169,10 @@ impl Module<gtk::Box> for CustomModule {
type SendMessage = ();
type ReceiveMessage = ExecEvent;
fn name() -> &'static str {
"custom"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
@@ -185,17 +190,11 @@ impl Module<gtk::Box> for CustomModule {
error!("{err:?}");
}
} else if event.cmd == "popup:toggle" {
tx.send(ModuleUpdateEvent::TogglePopup(event.geometry))
.await
.expect("Failed to send open popup event");
send_async!(tx, ModuleUpdateEvent::TogglePopup(event.geometry));
} else if event.cmd == "popup:open" {
tx.send(ModuleUpdateEvent::OpenPopup(event.geometry))
.await
.expect("Failed to send open popup event");
send_async!(tx, ModuleUpdateEvent::OpenPopup(event.geometry));
} else if event.cmd == "popup:close" {
tx.send(ModuleUpdateEvent::ClosePopup)
.await
.expect("Failed to send open popup event");
send_async!(tx, ModuleUpdateEvent::ClosePopup);
} else {
error!("Received invalid command: '{}'", event.cmd);
}

View File

@@ -1,7 +1,7 @@
use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, icon};
use crate::{await_sync, icon, read_lock, send_async};
use color_eyre::Result;
use glib::Continue;
use gtk::prelude::*;
@@ -26,7 +26,7 @@ pub struct FocusedModule {
icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
const fn default_icon_size() -> i32 {
@@ -37,6 +37,10 @@ impl Module<gtk::Box> for FocusedModule {
type SendMessage = (String, String);
type ReceiveMessage = ();
fn name() -> &'static str {
"focused"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
@@ -45,16 +49,15 @@ impl Module<gtk::Box> for FocusedModule {
) -> Result<()> {
let focused = await_sync(async {
let wl = wayland::get_client().await;
let toplevels = wl
.toplevels
.read()
.expect("Failed to get read lock on toplevels")
.clone();
let toplevels = read_lock!(wl.toplevels);
toplevels.into_iter().find(|(_, (top, _))| top.active)
toplevels
.iter()
.find(|(_, (top, _))| top.active)
.map(|(_, (top, _))| top.clone())
});
if let Some((_, (top, _))) = focused {
if let Some(top) = focused {
tx.try_send(ModuleUpdateEvent::Update((top.title.clone(), top.app_id)))?;
}
@@ -72,12 +75,10 @@ impl Module<gtk::Box> for FocusedModule {
};
if update {
tx.send(ModuleUpdateEvent::Update((
event.toplevel.title,
event.toplevel.app_id,
)))
.await
.expect("Failed to send focus update");
send_async!(
tx,
ModuleUpdateEvent::Update((event.toplevel.title, event.toplevel.app_id))
);
}
}
});

View File

@@ -4,6 +4,7 @@ use crate::icon::get_icon;
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 indexmap::IndexMap;
@@ -177,14 +178,12 @@ impl ItemButton {
let app_id = item.app_id.clone();
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
// lazy check :|
// lazy check :| TODO: Improve this
let style_context = button.style_context();
if style_context.has_class("open") {
tx.try_send(ItemEvent::FocusItem(app_id.clone()))
.expect("Failed to send item focus event");
try_send!(tx, ItemEvent::FocusItem(app_id.clone()));
} else {
tx.try_send(ItemEvent::OpenItem(app_id.clone()))
.expect("Failed to send item open event");
try_send!(tx, ItemEvent::OpenItem(app_id.clone()));
}
});
}
@@ -199,24 +198,20 @@ impl ItemButton {
let menu_state = menu_state.clone();
button.connect_enter_notify_event(move |button, _| {
let menu_state = menu_state
.read()
.expect("Failed to get read lock on item menu state");
let menu_state = read_lock!(menu_state);
if menu_state.num_windows > 1 {
tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::Hover(
app_id.clone(),
)))
.expect("Failed to send item open popup event");
try_send!(
tx,
ModuleUpdateEvent::Update(LauncherUpdate::Hover(app_id.clone(),))
);
tx.try_send(ModuleUpdateEvent::OpenPopup(Popup::button_pos(
button,
orientation,
)))
.expect("Failed to send item open popup event");
try_send!(
tx,
ModuleUpdateEvent::OpenPopup(Popup::button_pos(button, orientation,))
);
} else {
tx.try_send(ModuleUpdateEvent::ClosePopup)
.expect("Failed to send item close popup event");
try_send!(tx, ModuleUpdateEvent::ClosePopup);
}
Inhibit(false)

View File

@@ -7,6 +7,7 @@ use crate::clients::wayland::{self, ToplevelChange};
use crate::config::CommonConfig;
use crate::icon::find_desktop_file;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{lock, read_lock, try_send, write_lock};
use color_eyre::{Help, Report};
use glib::Continue;
use gtk::prelude::*;
@@ -36,7 +37,7 @@ pub struct LauncherModule {
icon_theme: Option<String>,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
#[derive(Debug, Clone)]
@@ -78,6 +79,10 @@ impl Module<gtk::Box> for LauncherModule {
type SendMessage = LauncherUpdate;
type ReceiveMessage = ItemEvent;
fn name() -> &'static str {
"launcher"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
@@ -106,12 +111,9 @@ impl Module<gtk::Box> for LauncherModule {
let tx = tx.clone();
spawn(async move {
let wl = wayland::get_client().await;
let open_windows = wl
.toplevels
.read()
.expect("Failed to get read lock on toplevels");
let open_windows = read_lock!(wl.toplevels);
let mut items = items.lock().expect("Failed to get lock on items");
let mut items = lock!(items);
for (_, (window, _)) in open_windows.clone() {
let item = items.get_mut(&window.app_id);
@@ -153,12 +155,10 @@ impl Module<gtk::Box> for LauncherModule {
let window = event.toplevel;
let app_id = window.app_id.clone();
let items = || items.lock().expect("Failed to get lock on items");
match event.change {
ToplevelChange::New => {
let new_item = {
let mut items = items();
let mut items = lock!(items);
let item = items.get_mut(&app_id);
match item {
None => {
@@ -185,7 +185,7 @@ impl Module<gtk::Box> for LauncherModule {
}
ToplevelChange::Close => {
let remove_item = {
let mut items = items();
let mut items = lock!(items);
let item = items.get_mut(&app_id);
match item {
Some(item) => {
@@ -214,23 +214,19 @@ impl Module<gtk::Box> for LauncherModule {
};
}
ToplevelChange::Focus(focused) => {
let update_title = if focused {
if let Some(item) = items().get_mut(&app_id) {
let mut update_title = false;
if focused {
if let Some(item) = lock!(items).get_mut(&app_id) {
item.set_window_focused(window.id, true);
// might be switching focus between windows of same app
if item.windows.len() > 1 {
item.set_window_name(window.id, window.title.clone());
true
} else {
false
update_title = true;
}
} else {
false
}
} else {
false
};
}
send_update(LauncherUpdate::Focus(app_id.clone(), focused)).await?;
@@ -240,7 +236,7 @@ impl Module<gtk::Box> for LauncherModule {
}
}
ToplevelChange::Title(title) => {
if let Some(item) = items().get_mut(&app_id) {
if let Some(item) = lock!(items).get_mut(&app_id) {
item.set_window_name(window.id, title.clone());
}
@@ -282,7 +278,7 @@ impl Module<gtk::Box> for LauncherModule {
);
} else {
let wl = wayland::get_client().await;
let items = items.lock().expect("Failed to get lock on items");
let items = lock!(items);
let id = match event {
ItemEvent::FocusItem(app_id) => items
@@ -293,10 +289,7 @@ impl Module<gtk::Box> for LauncherModule {
};
if let Some(id) = id {
let toplevels = wl
.toplevels
.read()
.expect("Failed to get read lock on toplevels");
let toplevels = read_lock!(wl.toplevels);
let seat = wl.seats.first().expect("Failed to get Wayland seat");
if let Some((_top, handle)) = toplevels.get(&id) {
handle.activate(seat);
@@ -359,10 +352,7 @@ impl Module<gtk::Box> for LauncherModule {
if let Some(button) = buttons.get(&app_id) {
button.set_open(true);
let mut menu_state = button
.menu_state
.write()
.expect("Failed to get write lock on item menu state");
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows += 1;
}
}
@@ -383,10 +373,7 @@ impl Module<gtk::Box> for LauncherModule {
}
LauncherUpdate::RemoveWindow(app_id, _) => {
if let Some(button) = buttons.get(&app_id) {
let mut menu_state = button
.menu_state
.write()
.expect("Failed to get write lock on item menu state");
let mut menu_state = write_lock!(button.menu_state);
menu_state.num_windows -= 1;
}
}
@@ -459,8 +446,7 @@ impl Module<gtk::Box> for LauncherModule {
{
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
tx.try_send(ItemEvent::FocusWindow(win.id))
.expect("Failed to send window click event");
try_send!(tx, ItemEvent::FocusWindow(win.id));
if let Some(win) = button.window() {
win.hide();
@@ -489,8 +475,7 @@ impl Module<gtk::Box> for LauncherModule {
{
let tx = controller_tx.clone();
button.connect_clicked(move |button| {
tx.try_send(ItemEvent::FocusWindow(win.id))
.expect("Failed to send window click event");
try_send!(tx, ItemEvent::FocusWindow(win.id));
if let Some(win) = button.window() {
win.hide();

View File

@@ -8,7 +8,7 @@ pub mod clock;
pub mod custom;
pub mod focused;
pub mod launcher;
pub mod mpd;
pub mod music;
pub mod script;
pub mod sysinfo;
pub mod tray;
@@ -17,7 +17,6 @@ pub mod workspaces;
use crate::config::BarPosition;
use crate::popup::ButtonGeometry;
use color_eyre::Result;
use derive_builder::Builder;
use glib::IsA;
use gtk::gdk::Monitor;
use gtk::{Application, Widget};
@@ -29,15 +28,12 @@ pub enum ModuleLocation {
Center,
Right,
}
#[derive(Builder)]
pub struct ModuleInfo<'a> {
pub app: &'a Application,
pub location: ModuleLocation,
pub bar_position: BarPosition,
pub monitor: &'a Monitor,
pub output_name: &'a str,
pub module_name: &'a str,
}
#[derive(Debug)]
@@ -73,6 +69,8 @@ where
type SendMessage;
type ReceiveMessage;
fn name() -> &'static str;
fn spawn_controller(
&self,
info: &ModuleInfo,

View File

@@ -1,28 +1,29 @@
use crate::clients::mpd::{get_client, get_duration, get_elapsed, MpdConnectionError};
use crate::clients::music::{self, MusicClient, PlayerState, PlayerUpdate, Status, Track};
use crate::config::CommonConfig;
use crate::error::ERR_CHANNEL_SEND;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::popup::Popup;
use crate::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 mpd_client::commands;
use mpd_client::responses::{PlayState, Song, Status};
use mpd_client::tag::Tag;
use regex::Regex;
use serde::Deserialize;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::spawn;
use tokio::sync::mpsc;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::error;
#[derive(Debug)]
pub enum PlayerCommand {
Previous,
Toggle,
Play,
Pause,
Next,
Volume(u8),
}
@@ -50,11 +51,26 @@ impl Default for Icons {
}
}
#[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 MpdModule {
/// TCP or Unix socket address.
#[serde(default = "default_socket")]
host: String,
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,
@@ -63,12 +79,16 @@ pub struct MpdModule {
#[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: CommonConfig,
pub common: Option<CommonConfig>,
}
fn default_socket() -> String {
@@ -95,19 +115,14 @@ fn default_music_dir() -> PathBuf {
audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
}
/// Attempts to read the first value for a tag
/// (since the MPD client returns a vector of tags, or None)
pub fn try_get_first_tag(vec: Option<&Vec<String>>) -> Option<&str> {
vec.and_then(|vec| vec.first().map(String::as_str))
}
/// Formats a duration given in seconds
/// in hh:mm format
fn format_time(time: u64) -> String {
fn format_time(duration: Duration) -> String {
let time = duration.as_secs();
let minutes = (time / 60) % 60;
let seconds = time % 60;
format!("{:0>2}:{:0>2}", minutes, seconds)
format!("{minutes:0>2}:{seconds:0>2}")
}
/// Extracts the formatting tokens from a formatting string
@@ -119,88 +134,113 @@ fn get_tokens(re: &Regex, format_string: &str) -> Vec<String> {
#[derive(Clone, Debug)]
pub struct SongUpdate {
song: Song,
song: Track,
status: Status,
display_string: String,
}
impl Module<Button> for MpdModule {
async fn get_client(
player_type: PlayerType,
host: &str,
music_dir: PathBuf,
) -> Box<Arc<dyn MusicClient>> {
match player_type {
PlayerType::Mpd => music::get_client(music::ClientType::Mpd { host, music_dir }),
PlayerType::Mpris => music::get_client(music::ClientType::Mpris {}),
}
.await
}
impl Module<Button> for MusicModule {
type SendMessage = Option<SongUpdate>;
type ReceiveMessage = PlayerCommand;
fn name() -> &'static str {
"music"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let host1 = self.host.clone();
let host2 = self.host.clone();
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());
// poll mpd server
spawn(async move {
let client = get_client(&host1).await.expect("Failed to connect to MPD");
let mut mpd_rx = client.subscribe();
// receive player updates
{
let player_type = self.player_type;
let host = self.host.clone();
let music_dir = self.music_dir.clone();
loop {
let current_song = client.command(commands::CurrentSong).await;
let status = client.command(commands::Status).await;
if let (Ok(Some(song)), Ok(status)) = (current_song, status) {
let display_string =
replace_tokens(format.as_str(), &tokens, &song.song, &status, &icons);
let update = SongUpdate {
song: song.song,
status,
display_string,
spawn(async move {
loop {
let mut rx = {
let client = get_client(player_type, &host, music_dir.clone()).await;
client.subscribe_change()
};
tx.send(ModuleUpdateEvent::Update(Some(update))).await?;
} else {
tx.send(ModuleUpdateEvent::Update(None)).await?;
}
while let Ok(update) = rx.recv().await {
match update {
PlayerUpdate::Update(track, status) => match *track {
Some(track) => {
let display_string = replace_tokens(
format.as_str(),
&tokens,
&track,
&status,
&icons,
);
// wait for player state change
if mpd_rx.recv().await.is_err() {
break;
}
}
let update = SongUpdate {
song: track,
status,
display_string,
};
Ok::<(), mpsc::error::SendError<ModuleUpdateEvent<Self::SendMessage>>>(())
});
tx.send(ModuleUpdateEvent::Update(Some(update)))
.await
.expect(ERR_CHANNEL_SEND);
}
None => tx
.send(ModuleUpdateEvent::Update(None))
.await
.expect(ERR_CHANNEL_SEND),
},
PlayerUpdate::Disconnect => break,
}
}
}
});
}
// listen to ui events
spawn(async move {
let client = get_client(&host2).await?;
{
let player_type = self.player_type;
let host = self.host.clone();
let music_dir = self.music_dir.clone();
while let Some(event) = rx.recv().await {
let res = match event {
PlayerCommand::Previous => client.command(commands::Previous).await,
PlayerCommand::Toggle => match client.command(commands::Status).await {
Ok(status) => match status.state {
PlayState::Playing => client.command(commands::SetPause(true)).await,
PlayState::Paused => client.command(commands::SetPause(false)).await,
PlayState::Stopped => Ok(()),
},
Err(err) => Err(err),
},
PlayerCommand::Next => client.command(commands::Next).await,
PlayerCommand::Volume(vol) => client.command(commands::SetVolume(vol)).await,
};
spawn(async move {
while let Some(event) = rx.recv().await {
let client = get_client(player_type, &host, music_dir.clone()).await;
let res = match event {
PlayerCommand::Previous => client.prev(),
PlayerCommand::Play => client.play(),
PlayerCommand::Pause => client.pause(),
PlayerCommand::Next => client.next(),
PlayerCommand::Volume(vol) => client.set_volume_percent(vol), // .unwrap_or_else(|_| error!("Failed to update player volume")),
};
if let Err(err) = res {
error!("Failed to send command to MPD server: {:?}", err);
if let Err(err) = res {
error!("Failed to send command to server: {:?}", err);
}
}
}
Ok::<(), MpdConnectionError>(())
});
});
}
Ok(())
}
@@ -221,11 +261,10 @@ impl Module<Button> for MpdModule {
let tx = context.tx.clone();
button.connect_clicked(move |button| {
tx.try_send(ModuleUpdateEvent::TogglePopup(Popup::button_pos(
button,
orientation,
)))
.expect("Failed to send MPD popup open event");
try_send!(
tx,
ModuleUpdateEvent::TogglePopup(Popup::button_pos(button, orientation,))
);
});
}
@@ -239,8 +278,7 @@ impl Module<Button> for MpdModule {
button.show();
} else {
button.hide();
tx.try_send(ModuleUpdateEvent::ClosePopup)
.expect("Failed to send close popup message");
try_send!(tx, ModuleUpdateEvent::ClosePopup);
}
Continue(true)
@@ -263,7 +301,7 @@ impl Module<Button> for MpdModule {
let container = gtk::Box::builder()
.orientation(Orientation::Horizontal)
.spacing(10)
.name("popup-mpd")
.name("popup-music")
.build();
let album_image = Image::builder()
@@ -279,7 +317,7 @@ impl Module<Button> for MpdModule {
title_label.container.set_widget_name("title");
album_label.container.set_widget_name("album");
artist_label.container.set_widget_name("label");
artist_label.container.set_widget_name("artist");
info_box.add(&title_label.container);
info_box.add(&album_label.container);
@@ -304,7 +342,7 @@ impl Module<Button> for MpdModule {
let volume_slider = Scale::with_range(Orientation::Vertical, 0.0, 100.0, 5.0);
volume_slider.set_inverted(true);
volume_slider.set_widget_name("scale");
volume_slider.set_widget_name("slider");
let volume_icon = Label::new(Some(&self.icons.volume));
volume_icon.style_context().add_class("icon");
@@ -318,101 +356,92 @@ impl Module<Button> for MpdModule {
let tx_prev = tx.clone();
btn_prev.connect_clicked(move |_| {
tx_prev
.try_send(PlayerCommand::Previous)
.expect("Failed to send prev track message");
try_send!(tx_prev, PlayerCommand::Previous);
});
let tx_toggle = tx.clone();
btn_play_pause.connect_clicked(move |_| {
tx_toggle
.try_send(PlayerCommand::Toggle)
.expect("Failed to send play/pause track message");
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_next = tx.clone();
btn_next.connect_clicked(move |_| {
tx_next
.try_send(PlayerCommand::Next)
.expect("Failed to send next track message");
try_send!(tx_next, PlayerCommand::Next);
});
let tx_vol = tx;
volume_slider.connect_change_value(move |_, _, val| {
tx_vol
.try_send(PlayerCommand::Volume(val as u8))
.expect("Failed to send volume message");
try_send!(tx_vol, PlayerCommand::Volume(val as u8));
Inhibit(false)
});
container.show_all();
{
let music_dir = self.music_dir;
let mut prev_cover = None;
rx.attach(None, move |update| {
if let Some(update) = update {
let prev_album = album_label.label.text();
let curr_album = update.song.album().unwrap_or_default();
// only update art when album changes
if prev_album != curr_album {
let cover_path = music_dir.join(
update
.song
.file_path()
.parent()
.expect("Song path should not be root")
.join("cover.jpg"),
);
Pixbuf::from_file_at_scale(cover_path, 128, 128, true).map_or_else(
|_| {
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)
}) {
Some(Ok(pixbuf)) => album_image.set_from_pixbuf(Some(&pixbuf)),
Some(Err(err)) => {
error!("{:?}", err);
album_image.set_from_pixbuf(None);
},
|pixbuf| {
album_image.set_from_pixbuf(Some(&pixbuf));
},
);
}
None => album_image.set_from_pixbuf(None),
};
}
title_label
.label
.set_text(update.song.title().unwrap_or_default());
album_label.label.set_text(curr_album);
.set_text(&update.song.title.unwrap_or_default());
album_label
.label
.set_text(&update.song.album.unwrap_or_default());
artist_label
.label
.set_text(update.song.artists().first().unwrap_or(&String::new()));
.set_text(&update.song.artist.unwrap_or_default());
match update.status.state {
PlayState::Stopped => {
PlayerState::Stopped => {
btn_play_pause.set_sensitive(false);
}
PlayState::Playing => {
PlayerState::Playing => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label("");
btn_play_pause.set_label(&self.icons.pause);
let style_context = btn_play_pause.style_context();
style_context.add_class("playing");
style_context.remove_class("paused");
}
PlayState::Paused => {
PlayerState::Paused => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label("");
btn_play_pause.set_label(&self.icons.play);
let style_context = btn_play_pause.style_context();
style_context.add_class("paused");
style_context.remove_class("playing");
}
}
let enable_prev = match update.status.current_song {
Some((pos, _)) => pos.0 > 0,
None => false,
};
let enable_prev = update.status.playlist_position > 0;
let enable_next = match update.status.current_song {
Some((pos, _)) => pos.0 < update.status.playlist_length,
None => false,
};
let enable_next =
update.status.playlist_position < update.status.playlist_length;
btn_prev.set_sensitive(enable_prev);
btn_next.set_sensitive(enable_next);
volume_slider.set_value(update.status.volume as f64);
volume_slider.set_value(update.status.volume_percent as f64);
}
Continue(true)
@@ -424,48 +453,44 @@ impl Module<Button> for MpdModule {
}
/// Replaces each of the formatting tokens in the formatting string
/// with actual data pulled from MPD
/// with actual data pulled from the music player
fn replace_tokens(
format_string: &str,
tokens: &Vec<String>,
song: &Song,
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);
compiled_string =
compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str());
compiled_string = compiled_string.replace(format!("{{{token}}}").as_str(), value.as_str());
}
compiled_string
}
/// Converts a string format token value
/// into its respective MPD value.
fn get_token_value(song: &Song, status: &Status, icons: &Icons, token: &str) -> String {
let s = match token {
"icon" => {
let icon = match status.state {
PlayState::Stopped => None,
PlayState::Playing => Some(&icons.play),
PlayState::Paused => Some(&icons.pause),
};
icon.map(String::as_str)
/// into its respective value.
fn get_token_value(song: &Track, status: &Status, icons: &Icons, token: &str) -> String {
match token {
"icon" => match status.state {
PlayerState::Stopped => None,
PlayerState::Playing => Some(&icons.play),
PlayerState::Paused => Some(&icons.pause),
}
"title" => song.title(),
"album" => try_get_first_tag(song.tags.get(&Tag::Album)),
"artist" => try_get_first_tag(song.tags.get(&Tag::Artist)),
"date" => try_get_first_tag(song.tags.get(&Tag::Date)),
"disc" => try_get_first_tag(song.tags.get(&Tag::Disc)),
"genre" => try_get_first_tag(song.tags.get(&Tag::Genre)),
"track" => try_get_first_tag(song.tags.get(&Tag::Track)),
"duration" => return get_duration(status).map(format_time).unwrap_or_default(),
"elapsed" => return get_elapsed(status).map(format_time).unwrap_or_default(),
_ => Some(token),
};
s.unwrap_or_default().to_string()
.map(std::string::ToString::to_string),
"title" => song.title.clone(),
"album" => song.album.clone(),
"artist" => song.artist.clone(),
"date" => song.date.clone(),
"disc" => song.disc.map(|x| x.to_string()),
"genre" => song.genre.clone(),
"track" => song.track.map(|x| x.to_string()),
"duration" => status.duration.map(format_time),
"elapsed" => status.elapsed.map(format_time),
_ => Some(token.to_string()),
}
.unwrap_or_default()
}
#[derive(Clone)]

View File

@@ -21,7 +21,7 @@ pub struct ScriptModule {
interval: u64,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
/// `Mode::Poll`
@@ -48,6 +48,10 @@ impl Module<Label> for ScriptModule {
type SendMessage = String;
type ReceiveMessage = ();
fn name() -> &'static str {
"script"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,

View File

@@ -1,5 +1,6 @@
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::send_async;
use color_eyre::Result;
use gtk::prelude::*;
use gtk::Label;
@@ -22,7 +23,7 @@ pub struct SysInfoModule {
interval: Interval,
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
#[derive(Debug, Deserialize, Copy, Clone)]
@@ -116,6 +117,10 @@ impl Module<gtk::Box> for SysInfoModule {
type SendMessage = HashMap<String, String>;
type ReceiveMessage = ();
fn name() -> &'static str {
"sysinfo"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,
@@ -135,83 +140,24 @@ impl Module<gtk::Box> for SysInfoModule {
let (refresh_tx, mut refresh_rx) = mpsc::channel(16);
// memory refresh
{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send(RefreshType::Memory)
.await
.expect("Failed to send memory refresh");
sleep(Duration::from_secs(interval.memory())).await;
}
});
macro_rules! spawn_refresh {
($refresh_type:expr, $func:ident) => {{
let tx = refresh_tx.clone();
spawn(async move {
loop {
send_async!(tx, $refresh_type);
sleep(Duration::from_secs(interval.$func())).await;
}
});
}};
}
// cpu refresh
{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send(RefreshType::Cpu)
.await
.expect("Failed to send cpu refresh");
sleep(Duration::from_secs(interval.cpu())).await;
}
});
}
// temp refresh
{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send(RefreshType::Temps)
.await
.expect("Failed to send temperature refresh");
sleep(Duration::from_secs(interval.temps())).await;
}
});
}
// disk refresh
{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send(RefreshType::Disks)
.await
.expect("Failed to send disk refresh");
sleep(Duration::from_secs(interval.disks())).await;
}
});
}
// network refresh
{
let tx = refresh_tx.clone();
spawn(async move {
loop {
tx.send(RefreshType::Network)
.await
.expect("Failed to send network refresh");
sleep(Duration::from_secs(interval.networks())).await;
}
});
}
// system refresh
{
let tx = refresh_tx;
spawn(async move {
loop {
tx.send(RefreshType::System)
.await
.expect("Failed to send system refresh");
sleep(Duration::from_secs(interval.system())).await;
}
});
}
spawn_refresh!(RefreshType::Memory, memory);
spawn_refresh!(RefreshType::Cpu, cpu);
spawn_refresh!(RefreshType::Temps, temps);
spawn_refresh!(RefreshType::Disks, disks);
spawn_refresh!(RefreshType::Network, networks);
spawn_refresh!(RefreshType::System, system);
spawn(async move {
let mut format_info = HashMap::new();
@@ -228,9 +174,7 @@ impl Module<gtk::Box> for SysInfoModule {
RefreshType::System => refresh_system_tokens(&mut format_info, &sys),
};
tx.send(ModuleUpdateEvent::Update(format_info.clone()))
.await
.expect("Failed to send system info map");
send_async!(tx, ModuleUpdateEvent::Update(format_info.clone()));
}
});
@@ -306,7 +250,7 @@ fn refresh_memory_tokens(format_info: &mut HashMap<String, String>, sys: &mut Sy
);
format_info.insert(
String::from("memory_percent"),
format!("{:0>2.0}", memory_percent),
format!("{memory_percent:0>2.0}"),
);
let used_swap = sys.used_swap();
@@ -336,10 +280,7 @@ fn refresh_cpu_tokens(format_info: &mut HashMap<String, String>, sys: &mut Syste
let cpu_info = sys.global_cpu_info();
let cpu_percent = cpu_info.cpu_usage();
format_info.insert(
String::from("cpu_percent"),
format!("{:0>2.0}", cpu_percent),
);
format_info.insert(String::from("cpu_percent"), format!("{cpu_percent:0>2.0}"));
}
fn refresh_temp_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {

View File

@@ -1,7 +1,7 @@
use crate::await_sync;
use crate::clients::system_tray::get_tray_event_client;
use crate::config::CommonConfig;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::{await_sync, try_send};
use color_eyre::Result;
use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem};
@@ -17,7 +17,7 @@ use tokio::sync::mpsc::{Receiver, Sender};
#[derive(Debug, Deserialize, Clone)]
pub struct TrayModule {
#[serde(flatten)]
pub common: CommonConfig,
pub common: Option<CommonConfig>,
}
/// Gets a GTK `Image` component
@@ -70,12 +70,14 @@ fn get_menu_items(
{
let tx = tx.clone();
item.connect_activate(move |_item| {
tx.try_send(NotifierItemCommand::MenuItemClicked {
submenu_id: info.id,
menu_path: path.clone(),
notifier_address: id.clone(),
})
.expect("Failed to send menu item clicked event");
try_send!(
tx,
NotifierItemCommand::MenuItemClicked {
submenu_id: info.id,
menu_path: path.clone(),
notifier_address: id.clone(),
}
);
});
}
@@ -92,6 +94,10 @@ impl Module<MenuBar> for TrayModule {
type SendMessage = NotifierItemMessage;
type ReceiveMessage = NotifierItemCommand;
fn name() -> &'static str {
"tray"
}
fn spawn_controller(
&self,
_info: &ModuleInfo,

View File

@@ -1,17 +1,33 @@
use crate::await_sync;
use crate::clients::sway::{get_client, get_sub_client};
use crate::clients::compositor::{Compositor, WorkspaceUpdate};
use crate::config::CommonConfig;
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 serde::Deserialize;
use std::cmp::Ordering;
use std::collections::HashMap;
use swayipc_async::{Workspace, WorkspaceChange, WorkspaceEvent};
use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tracing::trace;
#[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
/// Shows workspaces in the order they're added
Added,
/// Shows workspaces in numeric order.
/// Named workspaces are added to the end in alphabetical order.
Alphanumeric,
}
impl Default for SortOrder {
fn default() -> Self {
Self::Alphanumeric
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct WorkspacesModule {
/// Map of actual workspace names to custom names.
@@ -21,14 +37,11 @@ pub struct WorkspacesModule {
#[serde(default = "crate::config::default_false")]
all_monitors: bool,
#[serde(flatten)]
pub common: CommonConfig,
}
#[serde(default)]
sort: SortOrder,
#[derive(Clone, Debug)]
pub enum WorkspaceUpdate {
Init(Vec<Workspace>),
Update(Box<WorkspaceEvent>),
#[serde(flatten)]
pub common: Option<CommonConfig>,
}
/// Creates a button from a workspace
@@ -40,6 +53,7 @@ fn create_button(
) -> Button {
let button = Button::builder()
.label(name_map.get(name).map_or(name, String::as_str))
.name(name)
.build();
let style_context = button.style_context();
@@ -53,69 +67,71 @@ fn create_button(
let tx = tx.clone();
let name = name.to_string();
button.connect_clicked(move |_item| {
tx.try_send(name.clone())
.expect("Failed to send workspace click event");
try_send!(tx, name.clone());
});
}
button
}
fn reorder_workspaces(container: &gtk::Box) {
let mut buttons = container
.children()
.into_iter()
.map(|child| (child.widget_name().to_string(), child))
.collect::<Vec<_>>();
buttons.sort_by(|(label_a, _), (label_b, _a)| {
match (label_a.parse::<i32>(), label_b.parse::<i32>()) {
(Ok(a), Ok(b)) => a.cmp(&b),
(Ok(_), Err(_)) => Ordering::Less,
(Err(_), Ok(_)) => Ordering::Greater,
(Err(_), Err(_)) => label_a.cmp(label_b),
}
});
for (i, (_, button)) in buttons.into_iter().enumerate() {
container.reorder_child(&button, i as i32);
}
}
impl Module<gtk::Box> for WorkspacesModule {
type SendMessage = WorkspaceUpdate;
type ReceiveMessage = String;
fn name() -> &'static str {
"workspaces"
}
fn spawn_controller(
&self,
info: &ModuleInfo,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let workspaces = {
trace!("Getting current workspaces");
let workspaces = await_sync(async {
let sway = get_client().await;
let mut sway = sway.lock().await;
sway.get_workspaces().await
})?;
if self.all_monitors {
workspaces
} else {
trace!("Filtering workspaces to current monitor only");
workspaces
.into_iter()
.filter(|workspace| workspace.output == info.output_name)
.collect()
}
};
tx.try_send(ModuleUpdateEvent::Update(WorkspaceUpdate::Init(workspaces)))
.expect("Failed to send initial workspace list");
// Subscribe & send events
spawn(async move {
let mut srx = {
let sway = get_sub_client();
sway.subscribe_workspace()
let client =
Compositor::get_workspace_client().expect("Failed to get workspace client");
client.subscribe_workspace_change()
};
trace!("Set up Sway workspace subscription");
while let Ok(payload) = srx.recv().await {
tx.send(ModuleUpdateEvent::Update(WorkspaceUpdate::Update(payload)))
.await
.expect("Failed to send workspace update");
send_async!(tx, ModuleUpdateEvent::Update(payload));
}
});
// Change workspace focus
spawn(async move {
trace!("Setting up UI event handler");
let sway = get_client().await;
while let Some(name) = rx.recv().await {
let mut sway = sway.lock().await;
sway.run_command(format!("workspace {}", name)).await?;
let client =
Compositor::get_workspace_client().expect("Failed to get workspace client");
client.focus(name)?;
}
Ok::<(), Report>(())
@@ -139,45 +155,74 @@ impl Module<gtk::Box> for WorkspacesModule {
let container = container.clone();
let output_name = info.output_name.to_string();
// keep track of whether init event has fired previously
// since it fires for every workspace subscriber
let mut has_initialized = false;
context.widget_rx.attach(None, move |event| {
match event {
WorkspaceUpdate::Init(workspaces) => {
trace!("Creating workspace buttons");
for workspace in workspaces {
let item = create_button(
&workspace.name,
workspace.focused,
&name_map,
&context.controller_tx,
);
container.add(&item);
button_map.insert(workspace.name, item);
if !has_initialized {
trace!("Creating workspace buttons");
for workspace in workspaces {
if self.all_monitors || workspace.monitor == output_name {
let item = create_button(
&workspace.name,
workspace.focused,
&name_map,
&context.controller_tx,
);
container.add(&item);
button_map.insert(workspace.name, item);
}
}
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
container.show_all();
has_initialized = true;
}
container.show_all();
}
WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Focus => {
let old = event
.old
.and_then(|old| old.name)
.and_then(|name| button_map.get(&name));
WorkspaceUpdate::Focus { old, new } => {
let old = button_map.get(&old.name);
if let Some(old) = old {
old.style_context().remove_class("focused");
}
let new = event
.current
.and_then(|old| old.name)
.and_then(|new| button_map.get(&new));
let new = button_map.get(&new.name);
if let Some(new) = new {
new.style_context().add_class("focused");
}
}
WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Init => {
if let Some(workspace) = event.current {
if self.all_monitors
|| workspace.output.unwrap_or_default() == output_name
{
let name = workspace.name.unwrap_or_default();
WorkspaceUpdate::Add(workspace) => {
if self.all_monitors || workspace.monitor == output_name {
let name = workspace.name;
let item = create_button(
&name,
workspace.focused,
&name_map,
&context.controller_tx,
);
container.add(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
item.show();
if !name.is_empty() {
button_map.insert(name, item);
}
}
}
WorkspaceUpdate::Move(workspace) => {
if !self.all_monitors {
if workspace.monitor == output_name {
let name = workspace.name;
let item = create_button(
&name,
workspace.focused,
@@ -185,49 +230,28 @@ impl Module<gtk::Box> for WorkspacesModule {
&context.controller_tx,
);
item.show();
container.add(&item);
if self.sort == SortOrder::Alphanumeric {
reorder_workspaces(&container);
}
item.show();
if !name.is_empty() {
button_map.insert(name, item);
}
}
}
}
WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Move => {
if let Some(workspace) = event.current {
if !self.all_monitors {
if workspace.output.unwrap_or_default() == output_name {
let name = workspace.name.unwrap_or_default();
let item = create_button(
&name,
workspace.focused,
&name_map,
&context.controller_tx,
);
item.show();
container.add(&item);
if !name.is_empty() {
button_map.insert(name, item);
}
} else if let Some(item) =
button_map.get(&workspace.name.unwrap_or_default())
{
container.remove(item);
}
}
}
}
WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Empty => {
if let Some(workspace) = event.current {
if let Some(item) = button_map.get(&workspace.name.unwrap_or_default())
{
} else if let Some(item) = button_map.get(&workspace.name) {
container.remove(item);
}
}
}
WorkspaceUpdate::Remove(workspace) => {
let button = button_map.get(&workspace.name);
if let Some(item) = button {
container.remove(item);
}
}
WorkspaceUpdate::Update(_) => {}
};

View File

@@ -1,3 +1,4 @@
use crate::send_async;
use color_eyre::eyre::WrapErr;
use color_eyre::{Report, Result};
use serde::Deserialize;
@@ -37,7 +38,7 @@ impl From<&str> for ScriptMode {
"watch" | "w" => Self::Watch,
_ => {
warn!("Invalid script mode: '{str}', falling back to polling");
ScriptMode::Poll
Self::Poll
}
}
}
@@ -55,8 +56,8 @@ impl Display for ScriptMode {
f,
"{}",
match self {
ScriptMode::Poll => "poll",
ScriptMode::Watch => "watch",
Self::Poll => "poll",
Self::Watch => "watch",
}
)
}
@@ -129,12 +130,12 @@ impl From<&str> for Script {
.iter()
.take_while(|c| c.is_ascii_digit())
.collect::<String>();
(
ScriptInputToken::Interval(
interval_str.parse::<u64>().expect("Invalid interval"),
),
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())
}
// watching or polling
'w' | 'p' => {
@@ -262,10 +263,10 @@ impl Script {
select! {
_ = handle.wait() => break,
Ok(Some(line)) = stdout_lines.next_line() => {
tx.send(OutputStream::Stdout(line)).await.expect("Failed to send stdout");
send_async!(tx, OutputStream::Stdout(line));
}
Ok(Some(line)) = stderr_lines.next_line() => {
tx.send(OutputStream::Stderr(line)).await.expect("Failed to send stderr");
send_async!(tx, OutputStream::Stderr(line));
}
}
}

View File

@@ -1,3 +1,4 @@
use crate::send;
use color_eyre::{Help, Report};
use glib::Continue;
use gtk::prelude::CssProviderExt;
@@ -37,8 +38,7 @@ pub fn load_css(style_path: PathBuf) {
Ok(event) if event.kind == EventKind::Modify(ModifyKind::Data(DataChange::Any)) => {
debug!("{event:?}");
if let Some(path) = event.paths.first() {
tx.send(path.clone())
.expect("Failed to send style changed message");
send!(tx, path.clone());
}
}
Err(e) => error!("Error occurred when watching stylesheet: {:?}", e),