Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c906dd40fb | ||
|
|
eb30105fc2 | ||
|
|
90cd078973 | ||
|
|
1cdfebf8db | ||
|
|
0cefcbd02b | ||
|
|
08cfbbc2ea | ||
|
|
e1f523cf2a | ||
|
|
c223892a57 | ||
|
|
9ba28fe7fa | ||
|
|
0d7ab54160 | ||
|
|
6e5d0c1e8c | ||
|
|
a79900d842 | ||
|
|
6d8e647f12 | ||
|
|
1949d07721 | ||
|
|
f779520545 | ||
|
|
df7c447e9c | ||
|
|
90b9d70941 | ||
|
|
da806d38c6 | ||
|
|
8076412bfc | ||
|
|
fa67d077b1 | ||
|
|
b2afe78c07 | ||
|
|
1dd5863431 | ||
|
|
0a341f6673 | ||
|
|
bb81f8e583 | ||
|
|
a45ebfc1f5 | ||
|
|
ea2c84d1bd | ||
|
|
5e21cbcca6 | ||
|
|
9d5049dde0 | ||
|
|
fd2d7e5c7a | ||
|
|
2c1b2924d4 | ||
|
|
490f3f3f65 | ||
|
|
843e40ef45 | ||
|
|
d8c60d9d47 | ||
|
|
b97f018e81 |
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Other
|
||||
about: Any other issue type
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
2
.github/workflows/update-nix-flake-lock.yml
vendored
2
.github/workflows/update-nix-flake-lock.yml
vendored
@@ -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
|
||||
|
||||
48
CHANGELOG.md
48
CHANGELOG.md
@@ -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
477
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
11
Cargo.toml
11
Cargo.toml
@@ -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"] }
|
||||
|
||||
@@ -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}}`. |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
- [Custom](custom)
|
||||
- [Focused](focused)
|
||||
- [Launcher](launcher)
|
||||
- [MPD](mpd)
|
||||
- [Music](music)
|
||||
- [Script](script)
|
||||
- [Sys_Info](sys-info)
|
||||
- [Tray](tray)
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
## 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
139
docs/modules/Music.md
Normal 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.
|
||||
|
||||

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

|
||||
|
||||
@@ -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
12
flake.lock
generated
@@ -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": {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
542
src/bar.rs
542
src/bar.rs
@@ -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(¢er));
|
||||
@@ -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: >k::Box,
|
||||
center: >k::Box,
|
||||
right: >k::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: >k::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: >k::Box,
|
||||
center: >k::Box,
|
||||
right: >k::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: >k::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)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
250
src/clients/compositor/hyprland.rs
Normal file
250
src/clients/compositor/hyprland.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
89
src/clients/compositor/mod.rs
Normal file
89
src/clients/compositor/mod.rs
Normal 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>;
|
||||
}
|
||||
148
src/clients/compositor/sway.rs
Normal file
148
src/clients/compositor/sway.rs
Normal 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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
pub mod mpd;
|
||||
pub mod sway;
|
||||
pub mod compositor;
|
||||
pub mod music;
|
||||
pub mod system_tray;
|
||||
pub mod wayland;
|
||||
|
||||
@@ -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
70
src/clients/music/mod.rs
Normal 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
307
src/clients/music/mpd.rs
Normal 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
285
src/clients/music/mpris.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
94
src/config/mod.rs
Normal 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
|
||||
}
|
||||
@@ -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
13
src/error.rs
Normal 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";
|
||||
@@ -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));
|
||||
|
||||
@@ -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
89
src/macros.rs
Normal 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)
|
||||
};
|
||||
}
|
||||
56
src/main.rs
56
src/main.rs
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: >k::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(_) => {}
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user