50 Commits

Author SHA1 Message Date
Jake Stanger
0669504519 chore(release): v0.6.0 2022-10-15 18:26:51 +01:00
Jake Stanger
eb5170ff6a style: run fmt 2022-10-15 18:08:56 +01:00
Jake Stanger
b7b64886e3 fix: sometimes panicking on startup 2022-10-15 18:01:09 +01:00
Jake Stanger
75339f07ed fix: vertical bars ignoring height config option 2022-10-15 16:35:31 +01:00
Jake Stanger
06cfad62e2 feat: more positioning options (#23)
* feat: more positioning options

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

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

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

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

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

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

View File

@@ -1,4 +1,4 @@
name: Rust name: Build
on: on:
push: push:

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

48
CHANGELOG.md Normal file
View File

@@ -0,0 +1,48 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v0.5.2] - 2022-09-07
### :wrench: Chores
- [`b801751`](https://github.com/JakeStanger/ironbar/commit/b801751bdabd8416084f46e6b6d803ea28a259ec) - **release**: v0.5.2 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.5.1] - 2022-09-06
### :bug: Bug Fixes
- [`b81927e`](https://github.com/JakeStanger/ironbar/commit/b81927e3a57808188e31419695a36aa4ea3f2830) - **launcher**: opening new instances when focused/urgent *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`a35d255`](https://github.com/JakeStanger/ironbar/commit/a35d25520cd3fd235cdc77ec6209d88499ca3639) - **launcher**: item state changes not handled correctly *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`481adfc`](https://github.com/JakeStanger/ironbar/commit/481adfcaa41c0d3a1ba7d61edb68db49d959c78f) - **intellij**: update run configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6871126`](https://github.com/JakeStanger/ironbar/commit/6871126bd8def89ccbf2934180d615e781ec32c7) - **release**: v0.5.1 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.5.0] - 2022-08-25
### :sparkles: New Features
- [`1e38719`](https://github.com/JakeStanger/ironbar/commit/1e387199962b81caeb40ffbd99a956f24abdf4e3) - introduce logging in some areas *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`023c2fb`](https://github.com/JakeStanger/ironbar/commit/023c2fb118f46f3592f1dfe1a6704014c062ab3f) - **workspaces**: not listening to move event *(commit by [@JakeStanger](https://github.com/JakeStanger))*
- [`6dcae66`](https://github.com/JakeStanger/ironbar/commit/6dcae66570cf5434e077ec823cded33771b4239c) - avoid creating loads of sway/mpd clients *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`015dcd3`](https://github.com/JakeStanger/ironbar/commit/015dcd3204dfa6a1ebcef1b4f3b345ed733fee2f) - **release**: v0.5.0 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
## [v0.4.0] - 2022-08-22
### :sparkles: New Features
- [`ab8f7ec`](https://github.com/JakeStanger/ironbar/commit/ab8f7ecfc8fa4b96fce78518af75794641950140) - logging support and proper error handling *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :bug: Bug Fixes
- [`f2ee2df`](https://github.com/JakeStanger/ironbar/commit/f2ee2dfe7a0f5575d0c3ec09644ca990b088cd85) - error when using with `swaybar_command` *(commit by [@JakeStanger](https://github.com/JakeStanger))*
### :wrench: Chores
- [`1d7c377`](https://github.com/JakeStanger/ironbar/commit/1d7c3772e4b97c7198043cb55fe9c71695a211ab) - **release**: v0.4.0 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
[v0.4.0]: https://github.com/JakeStanger/ironbar/compare/v0.3.0...v0.4.0
[v0.5.0]: https://github.com/JakeStanger/ironbar/compare/v0.4.0...v0.5.0
[v0.5.1]: https://github.com/JakeStanger/ironbar/compare/v0.5.0...v0.5.1
[v0.5.2]: https://github.com/JakeStanger/ironbar/compare/v0.5.1...v0.5.2

1579
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "ironbar" name = "ironbar"
version = "0.3.0" version = "0.6.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
description = "Customisable wlroots/sway bar" description = "Customisable wlroots/sway bar"
@@ -8,22 +8,34 @@ description = "Customisable wlroots/sway bar"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
derive_builder = "0.11.2"
gtk = "0.15.5" gtk = "0.15.5"
gtk-layer-shell = "0.4.1" gtk-layer-shell = "0.4.1"
glib = "0.15.12" glib = "0.15.12"
stray = "0.1.1" tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time"] }
tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread", "time"] } tracing = "0.1.36"
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
tracing-error = "0.2.0"
tracing-appender = "0.2.2"
strip-ansi-escapes = "0.1.1"
color-eyre = "0.6.2"
futures-util = "0.3.21" futures-util = "0.3.21"
chrono = "0.4.19" chrono = "0.4.19"
serde = { version = "1.0.141", features = ["derive"] } serde = { version = "1.0.141", features = ["derive"] }
serde_json = "1.0.82" serde_json = "1.0.82"
serde_yaml = "0.9.4" serde_yaml = "0.9.4"
toml = "0.5.9" toml = "0.5.9"
cornfig = "0.2.0" cornfig = "0.3.0"
mpd_client = "0.7.5" lazy_static = "1.4.0"
async_once = "0.2.6"
regex = "1.6.0" regex = "1.6.0"
ksway = "0.1.0" stray = { git = "https://github.com/JakeStanger/stray.git", branch = "fix/tracing" }
sysinfo = "0.25.1"
dirs = "4.0.0" dirs = "4.0.0"
walkdir = "2.3.2" walkdir = "2.3.2"
notify = "4.0.17" notify = "5.0.0"
mpd_client = "1.0.0"
swayipc-async = { git = "https://github.com/JakeStanger/swayipc-rs.git", branch = "feat/derive-clone" }
sysinfo = "0.26.2"
wayland-client = "0.29.5"
wayland-protocols = { version = "0.29.5", features=["unstable_protocols", "client"] }
smithay-client-toolkit = "0.16.0"

View File

@@ -1,9 +1,9 @@
# Ironbar # Ironbar
Ironbar is a customisable and feature-rich bar targeting the Sway compositor, written in Rust. Ironbar is a customisable and feature-rich bar targeting wlroots compositors, written in Rust.
It uses GTK3 and gtk-layer-shell. It uses GTK3 and gtk-layer-shell.
The bar can be styled to your liking using CSS and hot-loads style changes. The bar can be styled to your liking using CSS and hot-loads style changes.
For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki). For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki).
![Screenshot of fully configured bar with MPD widget open](https://user-images.githubusercontent.com/5057870/184539623-92d56a44-a659-49a9-91f9-5cdc453e5dfb.png) ![Screenshot of fully configured bar with MPD widget open](https://user-images.githubusercontent.com/5057870/184539623-92d56a44-a659-49a9-91f9-5cdc453e5dfb.png)
@@ -28,55 +28,54 @@ yay -S ironbar-git
[aur package](https://aur.archlinux.org/packages/ironbar-git) [aur package](https://aur.archlinux.org/packages/ironbar-git)
### Source
```sh
git clone https://github.com/jakestanger/ironbar.git
cd ironbar
cargo build --release
# change path to wherever you want to install
install target/release/ironbar ~/.local/bin/ironbar
```
[repo](https://github.com/jakestanger/ironbar)
## Configuration ## Configuration
Ironbar gives a lot of flexibility when configuring, including multiple file formats Ironbar gives a lot of flexibility when configuring, including multiple file formats
and options for scaling complexity: you can use a single config across all monitors, and options for scaling complexity: you can use a single config across all monitors,
or configure different/multiple bars per monitor. or configure different/multiple bars per monitor.
A full configuration guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/configuration-guide). A full configuration guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/configuration-guide).
## Styling ## Styling
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the file. To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the
file.
A full styling guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/styling-guide). A full styling guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/styling-guide).
## Project Status ## Project Status
This project is in very early stages: This project is in alpha, but should be usable.
Everything that is implemented works and should be documented.
Proper error handling is in place so things should either fail gracefully with detail, or not fail at all.
- Error handling is barely implemented - expect crashes There is currently room for lots more modules, and lots more configuration options for the existing modules.
- There will be bugs! The current configuration schema is not set in stone and breaking changes could come along at any point;
- Lots of modules need more configuration options until the project matures I am more interested in ease of use than backwards compatibility.
- There's room for lots of modules
- The code is messy and quite prototypal in places
- Config options aren't set in stone - expect breaking changes
- Documentation is probably missing in lots of places
That said, it will be *actively developed* as I am using it on my daily driver. A few bugs do exist, and I am sure there are plenty more to be found.
The project will be *actively developed* as I am using it on my daily driver.
Bugs will be fixed, features will be added, code will be refactored. Bugs will be fixed, features will be added, code will be refactored.
## Contribution Guidelines ## Contribution Guidelines
I welcome contributions of any kind with open arms. That said, please do stick to some basics: Please check [here](https://github.com/JakeStanger/ironbar/blob/master/CONTRIBUTING.md).
- For code contributions:
- Fix any `cargo clippy` warnings, using at least the default configuration.
- Make sure your code is formatted using `cargo fmt`.
- Keep any documentation up to date.
- I won't enforce it, but preferably stick to [conventional commit](https://www.conventionalcommits.org/en/v1.0.0/) messages.
- For PRs:
- Please open an issue or discussion beforehand.
I'll accept most contributions, but it's best to make sure you're not working on something that won't get accepted :)
- For issues:
- Please provide as much information as you can - share your config, any logs, steps to reproduce...
## Acknowledgements ## Acknowledgements
- [Waybar](https://github.com/Alexays/Waybar) - A lot of the initial inspiration, and a pretty great bar. - [Waybar](https://github.com/Alexays/Waybar) - A lot of the initial inspiration, and a pretty great bar.
- [Rustbar](https://github.com/zeroeightysix/rustbar) - Served as a good demo for writing a basic GTK bar in Rust - [Rustbar](https://github.com/zeroeightysix/rustbar) - Served as a good demo for writing a basic GTK bar in Rust
- [Smithay Client Toolkit](https://github.com/Smithay/client-toolkit) - Essential in being able to communicate to Wayland

View File

@@ -39,5 +39,7 @@ let {
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ] $right = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
} }
in { in {
left = $left right = $right anchor_to_edges = true
position = "top"
start = $left end = $right
} }

View File

@@ -1,18 +1,43 @@
{ {
"monitors": { "start": [
"DP-1": [ {
{ "type": "workspaces"
"left": [{"type": "clock"}] },
}, {
{ "type": "launcher",
"position": "top", "icon_theme": "Paper",
"left": [] "favorites": [
} "firefox",
], "discord",
"DP-2": { "Steam"
"position": "bottom", ],
"height": 30, "show_names": false
"left": []
} }
} ],
"end": [
{
"type": "mpd"
},
{
"type": "mpd",
"host": "chloe:6600"
},
{
"path": "/home/jake/bin/phone-battery",
"type": "script"
},
{
"format": [
"{cpu-percent}% ",
"{memory-percent}% "
],
"type": "sys-info"
},
{
"type": "tray"
},
{
"type": "clock"
}
]
} }

View File

@@ -1,47 +1,91 @@
use crate::bridge_channel::BridgeChannel;
use crate::config::{BarPosition, ModuleConfig}; use crate::config::{BarPosition, ModuleConfig};
use crate::modules::{Module, ModuleInfo, ModuleLocation}; 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::popup::Popup;
use crate::Config; use crate::Config;
use chrono::{DateTime, Local};
use color_eyre::Result;
use gtk::gdk::Monitor; use gtk::gdk::Monitor;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Orientation}; use gtk::{Application, ApplicationWindow, Orientation};
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
use stray::message::NotifierItemCommand;
use stray::NotifierItemMessage;
use tokio::sync::mpsc;
use tracing::{debug, info};
pub fn create_bar(app: &Application, monitor: &Monitor, monitor_name: &str, config: Config) { /// Creates a new window for a bar,
/// sets it up and adds its widgets.
pub fn create_bar(
app: &Application,
monitor: &Monitor,
monitor_name: &str,
config: Config,
) -> Result<()> {
let win = ApplicationWindow::builder().application(app).build(); let win = ApplicationWindow::builder().application(app).build();
setup_layer_shell(&win, monitor, &config.position); setup_layer_shell(&win, monitor, config.position, config.anchor_to_edges);
let orientation = config.position.get_orientation();
let content = gtk::Box::builder() let content = gtk::Box::builder()
.orientation(Orientation::Horizontal) .orientation(orientation)
.spacing(0) .spacing(0)
.hexpand(false) .hexpand(false)
.height_request(config.height) .name("bar");
.name("bar")
let content = if orientation == Orientation::Horizontal {
content.height_request(config.height)
} else {
content.width_request(config.height)
}
.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(); .build();
let left = gtk::Box::builder().spacing(0).name("left").build();
let center = gtk::Box::builder().spacing(0).name("center").build();
let right = gtk::Box::builder().spacing(0).name("right").build();
content.style_context().add_class("container"); content.style_context().add_class("container");
left.style_context().add_class("container"); start.style_context().add_class("container");
center.style_context().add_class("container"); center.style_context().add_class("container");
right.style_context().add_class("container"); end.style_context().add_class("container");
content.add(&left); content.add(&start);
content.set_center_widget(Some(&center)); content.set_center_widget(Some(&center));
content.pack_end(&right, false, false, 0); content.pack_end(&end, false, false, 0);
load_modules(&left, &center, &right, app, config, monitor, monitor_name); load_modules(&start, &center, &end, app, config, monitor, monitor_name)?;
win.add(&content); win.add(&content);
win.connect_destroy_event(|_, _| { win.connect_destroy_event(|_, _| {
info!("Shutting down");
gtk::main_quit(); gtk::main_quit();
Inhibit(false) Inhibit(false)
}); });
debug!("Showing bar");
win.show_all(); win.show_all();
Ok(())
} }
/// Loads the configured modules onto a bar.
fn load_modules( fn load_modules(
left: &gtk::Box, left: &gtk::Box,
center: &gtk::Box, center: &gtk::Box,
@@ -50,68 +94,161 @@ fn load_modules(
config: Config, config: Config,
monitor: &Monitor, monitor: &Monitor,
output_name: &str, output_name: &str,
) { ) -> Result<()> {
if let Some(modules) = config.left { let mut info_builder = ModuleInfoBuilder::default();
let info = ModuleInfo { let info_builder = info_builder
app, .app(app)
location: ModuleLocation::Left, .bar_position(config.position)
bar_position: &config.position, .monitor(monitor)
monitor, .output_name(output_name);
output_name,
};
add_modules(left, modules, &info); 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 { if let Some(modules) = config.center {
let info = ModuleInfo { let info_builder = info_builder.location(ModuleLocation::Center);
app,
location: ModuleLocation::Center,
bar_position: &config.position,
monitor,
output_name,
};
add_modules(center, modules, &info); add_modules(center, modules, info_builder)?;
} }
if let Some(modules) = config.right { if let Some(modules) = config.end {
let info = ModuleInfo { let info_builder = info_builder.location(ModuleLocation::Right);
app,
location: ModuleLocation::Right,
bar_position: &config.position,
monitor,
output_name,
};
add_modules(right, modules, &info); add_modules(right, modules, info_builder)?;
} }
Ok(())
} }
fn add_modules(content: &gtk::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) { /// Adds modules into a provided GTK box,
/// which should be one of its left, center or right containers.
fn add_modules(
content: &gtk::Box,
modules: Vec<ModuleConfig>,
info_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 { macro_rules! add_module {
($module:expr, $name:literal) => {{ ($module:expr, $id:expr, $name:literal, $send_message:ty, $receive_message:ty) => {
let widget = $module.into_widget(&info); let info = info_builder.module_name($name).build()?;
widget.set_widget_name($name);
content.add(&widget); 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 widget = $module.into_widget(context, &info)?;
content.add(&widget.widget);
widget.widget.set_widget_name(info.module_name);
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 config in modules { for (id, config) in modules.into_iter().enumerate() {
match config { match config {
ModuleConfig::Clock(module) => add_module!(module, "clock"), ModuleConfig::Clock(module) => {
ModuleConfig::Mpd(module) => add_module!(module, "mpd"), add_module!(module, id, "clock", DateTime<Local>, ());
ModuleConfig::Tray(module) => add_module!(module, "tray"), }
ModuleConfig::Workspaces(module) => add_module!(module, "workspaces"), ModuleConfig::Script(module) => {
ModuleConfig::SysInfo(module) => add_module!(module, "sysinfo"), add_module!(module, id, "script", String, ());
ModuleConfig::Launcher(module) => add_module!(module, "launcher"), }
ModuleConfig::Script(module) => add_module!(module, "script"), ModuleConfig::SysInfo(module) => {
ModuleConfig::Focused(module) => add_module!(module, "focused"), 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);
}
} }
} }
Ok(())
} }
fn setup_layer_shell(win: &ApplicationWindow, monitor: &Monitor, position: &BarPosition) { /// Sets up GTK layer shell for a provided application window.
fn setup_layer_shell(
win: &ApplicationWindow,
monitor: &Monitor,
position: BarPosition,
anchor_to_edges: bool,
) {
gtk_layer_shell::init_for_window(win); gtk_layer_shell::init_for_window(win);
gtk_layer_shell::set_monitor(win, monitor); gtk_layer_shell::set_monitor(win, monitor);
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top); gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
@@ -122,16 +259,30 @@ fn setup_layer_shell(win: &ApplicationWindow, monitor: &Monitor, position: &BarP
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, 0); gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, 0);
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, 0); gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, 0);
let bar_orientation = position.get_orientation();
gtk_layer_shell::set_anchor( gtk_layer_shell::set_anchor(
win, win,
gtk_layer_shell::Edge::Top, gtk_layer_shell::Edge::Top,
position == &BarPosition::Top, position == BarPosition::Top
|| (bar_orientation == Orientation::Vertical && anchor_to_edges),
); );
gtk_layer_shell::set_anchor( gtk_layer_shell::set_anchor(
win, win,
gtk_layer_shell::Edge::Bottom, gtk_layer_shell::Edge::Bottom,
position == &BarPosition::Bottom, position == BarPosition::Bottom
|| (bar_orientation == Orientation::Vertical && anchor_to_edges),
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Left,
position == BarPosition::Left
|| (bar_orientation == Orientation::Horizontal && anchor_to_edges),
);
gtk_layer_shell::set_anchor(
win,
gtk_layer_shell::Edge::Right,
position == BarPosition::Right
|| (bar_orientation == Orientation::Horizontal && anchor_to_edges),
); );
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Left, true);
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Right, true);
} }

43
src/bridge_channel.rs Normal file
View File

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

View File

@@ -1,5 +1,6 @@
use serde::Serialize; use serde::Serialize;
use std::slice::{Iter, IterMut}; use std::slice::{Iter, IterMut};
use std::vec;
/// An ordered map. /// An ordered map.
/// Internally this is just two vectors - /// Internally this is just two vectors -
@@ -11,6 +12,7 @@ pub struct Collection<TKey, TData> {
} }
impl<TKey: PartialEq, TData> Collection<TKey, TData> { impl<TKey: PartialEq, TData> Collection<TKey, TData> {
/// Creates a new empty collection.
pub const fn new() -> Self { pub const fn new() -> Self {
Self { Self {
keys: vec![], keys: vec![],
@@ -18,6 +20,7 @@ impl<TKey: PartialEq, TData> Collection<TKey, TData> {
} }
} }
/// Inserts a new key/value pair at the end of the collection.
pub fn insert(&mut self, key: TKey, value: TData) { pub fn insert(&mut self, key: TKey, value: TData) {
self.keys.push(key); self.keys.push(key);
self.values.push(value); self.values.push(value);
@@ -25,6 +28,8 @@ impl<TKey: PartialEq, TData> Collection<TKey, TData> {
assert_eq!(self.keys.len(), self.values.len()); assert_eq!(self.keys.len(), self.values.len());
} }
/// Gets a reference of the value for the specified key
/// if it exists in the collection.
pub fn get(&self, key: &TKey) -> Option<&TData> { pub fn get(&self, key: &TKey) -> Option<&TData> {
let index = self.keys.iter().position(|k| k == key); let index = self.keys.iter().position(|k| k == key);
match index { match index {
@@ -33,6 +38,8 @@ impl<TKey: PartialEq, TData> Collection<TKey, TData> {
} }
} }
/// Gets a mutable reference for the value with the specified key
/// if it exists in the collection.
pub fn get_mut(&mut self, key: &TKey) -> Option<&mut TData> { pub fn get_mut(&mut self, key: &TKey) -> Option<&mut TData> {
let index = self.keys.iter().position(|k| k == key); let index = self.keys.iter().position(|k| k == key);
match index { match index {
@@ -41,6 +48,14 @@ impl<TKey: PartialEq, TData> Collection<TKey, TData> {
} }
} }
/// Checks if a value for the given key exists inside the collection
pub fn contains(&self, key: &TKey) -> bool {
self.keys.contains(key)
}
/// Removes the key/value from the collection
/// if it exists
/// and returns the removed value.
pub fn remove(&mut self, key: &TKey) -> Option<TData> { pub fn remove(&mut self, key: &TKey) -> Option<TData> {
assert_eq!(self.keys.len(), self.values.len()); assert_eq!(self.keys.len(), self.values.len());
@@ -53,26 +68,32 @@ impl<TKey: PartialEq, TData> Collection<TKey, TData> {
} }
} }
/// Gets the length of the collection.
pub fn len(&self) -> usize { pub fn len(&self) -> usize {
self.keys.len() self.keys.len()
} }
/// Gets a reference to the first value in the collection.
pub fn first(&self) -> Option<&TData> { pub fn first(&self) -> Option<&TData> {
self.values.first() self.values.first()
} }
/// Gets the values as a slice.
pub fn as_slice(&self) -> &[TData] { pub fn as_slice(&self) -> &[TData] {
self.values.as_slice() self.values.as_slice()
} }
/// Checks whether the collection is empty.
pub fn is_empty(&self) -> bool { pub fn is_empty(&self) -> bool {
self.keys.is_empty() self.keys.is_empty()
} }
/// Gets an iterator for the collection.
pub fn iter(&self) -> Iter<'_, TData> { pub fn iter(&self) -> Iter<'_, TData> {
self.values.iter() self.values.iter()
} }
/// Gets a mutable iterator for the collection
pub fn iter_mut(&mut self) -> IterMut<'_, TData> { pub fn iter_mut(&mut self) -> IterMut<'_, TData> {
self.values.iter_mut() self.values.iter_mut()
} }
@@ -129,3 +150,12 @@ impl<TKey: PartialEq, TData> Default for Collection<TKey, TData> {
Self::new() Self::new()
} }
} }
impl<TKey: PartialEq, TData> IntoIterator for Collection<TKey, TData> {
type Item = TData;
type IntoIter = vec::IntoIter<TData>;
fn into_iter(self) -> Self::IntoIter {
self.values.into_iter()
}
}

View File

@@ -6,7 +6,11 @@ use crate::modules::script::ScriptModule;
use crate::modules::sysinfo::SysInfoModule; use crate::modules::sysinfo::SysInfoModule;
use crate::modules::tray::TrayModule; use crate::modules::tray::TrayModule;
use crate::modules::workspaces::WorkspacesModule; use crate::modules::workspaces::WorkspacesModule;
use color_eyre::eyre::{Context, ContextCompat};
use color_eyre::{eyre, Help, Report};
use dirs::config_dir; use dirs::config_dir;
use eyre::Result;
use gtk::Orientation;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -32,11 +36,13 @@ pub enum MonitorConfig {
Multiple(Vec<Config>), Multiple(Vec<Config>),
} }
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] #[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum BarPosition { pub enum BarPosition {
Top, Top,
Bottom, Bottom,
Left,
Right,
} }
impl Default for BarPosition { impl Default for BarPosition {
@@ -45,16 +51,36 @@ impl Default for BarPosition {
} }
} }
impl BarPosition {
pub fn get_orientation(self) -> Orientation {
if self == Self::Top || self == Self::Bottom {
Orientation::Horizontal
} else {
Orientation::Vertical
}
}
pub const fn get_angle(self) -> f64 {
match self {
Self::Top | Self::Bottom => 0.0,
Self::Left => 90.0,
Self::Right => 270.0,
}
}
}
#[derive(Debug, Deserialize, Clone, Default)] #[derive(Debug, Deserialize, Clone, Default)]
pub struct Config { pub struct Config {
#[serde(default = "default_bar_position")] #[serde(default = "default_bar_position")]
pub position: BarPosition, pub position: BarPosition,
#[serde(default = "default_true")]
pub anchor_to_edges: bool,
#[serde(default = "default_bar_height")] #[serde(default = "default_bar_height")]
pub height: i32, pub height: i32,
pub left: Option<Vec<ModuleConfig>>, pub start: Option<Vec<ModuleConfig>>,
pub center: Option<Vec<ModuleConfig>>, pub center: Option<Vec<ModuleConfig>>,
pub right: Option<Vec<ModuleConfig>>, pub end: Option<Vec<ModuleConfig>>,
pub monitors: Option<HashMap<String, MonitorConfig>>, pub monitors: Option<HashMap<String, MonitorConfig>>,
} }
@@ -68,49 +94,74 @@ const fn default_bar_height() -> i32 {
} }
impl Config { impl Config {
pub fn load() -> Option<Self> { /// Attempts to load the config file from file,
if let Ok(config_path) = env::var("IRONBAR_CONFIG") { /// parse it and return a new instance of `Self`.
pub fn load() -> Result<Self> {
let config_path = if let Ok(config_path) = env::var("IRONBAR_CONFIG") {
let path = PathBuf::from(config_path); let path = PathBuf::from(config_path);
Self::load_file( if path.exists() {
&path, Ok(path)
path.extension() } else {
.unwrap_or_default() Err(Report::msg("Specified config file does not exist")
.to_str() .note("Config file was specified using `IRONBAR_CONFIG` environment variable"))
.unwrap_or_default(), }
)
} else { } else {
let config_dir = config_dir().expect("Failed to locate user config dir"); Self::try_find_config()
}?;
let extensions = vec!["json", "toml", "yaml", "yml", "corn"]; Self::load_file(&config_path)
}
extensions.into_iter().find_map(|extension| { /// Attempts to discover the location of the config file
let full_path = config_dir /// by checking each valid format's extension.
.join("ironbar") ///
.join(format!("config.{extension}")); /// Returns the path of the first valid match, if any.
fn try_find_config() -> Result<PathBuf> {
let config_dir = config_dir().wrap_err("Failed to locate user config dir")?;
Self::load_file(&full_path, extension) let extensions = vec!["json", "toml", "yaml", "yml", "corn"];
})
let file = extensions.into_iter().find_map(|extension| {
let full_path = config_dir
.join("ironbar")
.join(format!("config.{extension}"));
if Path::exists(&full_path) {
Some(full_path)
} else {
None
}
});
match file {
Some(file) => Ok(file),
None => Err(Report::msg("Could not find config file")),
} }
} }
fn load_file(path: &Path, extension: &str) -> Option<Self> { /// Loads the config file at the specified path
if path.exists() { /// and parses it into `Self` based on its extension.
let file = fs::read(path).expect("Failed to read config file"); fn load_file(path: &Path) -> Result<Self> {
Some(match extension { let file = fs::read(path).wrap_err("Failed to read config file")?;
"json" => serde_json::from_slice(&file).expect("Invalid JSON config"), let extension = path
"toml" => toml::from_slice(&file).expect("Invalid TOML config"), .extension()
"yaml" | "yml" => serde_yaml::from_slice(&file).expect("Invalid YAML config"), .unwrap_or_default()
"corn" => { .to_str()
// corn doesn't support deserialization yet .unwrap_or_default();
// so serialize the interpreted result then deserialize that
let file = String::from_utf8(file).expect("Config file contains invalid UTF-8"); match extension {
let config = cornfig::parse(&file).expect("Invalid corn config").value; "json" => serde_json::from_slice(&file).wrap_err("Invalid JSON config"),
serde_json::from_str(&serde_json::to_string(&config).unwrap()).unwrap() "toml" => toml::from_slice(&file).wrap_err("Invalid TOML config"),
} "yaml" | "yml" => serde_yaml::from_slice(&file).wrap_err("Invalid YAML config"),
_ => unreachable!(), "corn" => {
}) // corn doesn't support deserialization yet
} else { // so serialize the interpreted result then deserialize that
None let file =
String::from_utf8(file).wrap_err("Config file contains invalid UTF-8")?;
let config = cornfig::parse(&file).wrap_err("Invalid corn config")?.value;
Ok(serde_json::from_str(&serde_json::to_string(&config)?)?)
}
_ => unreachable!(),
} }
} }
} }

View File

@@ -58,9 +58,7 @@ fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
let mut map = HashMap::new(); let mut map = HashMap::new();
for line in lines.flatten() { for line in lines.flatten() {
let is_pair = line.contains('='); if let Some((key, value)) = line.split_once('=') {
if is_pair {
let (key, value) = line.split_once('=').unwrap();
map.insert(key.to_string(), value.to_string()); map.insert(key.to_string(), value.to_string());
} }
} }
@@ -88,6 +86,10 @@ enum IconLocation {
File(PathBuf), File(PathBuf),
} }
/// Attempts to get the location of an icon.
///
/// Handles icons that are part of a GTK theme, icons specified as path
/// and icons for steam games.
fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconLocation> { fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconLocation> {
let has_icon = theme let has_icon = theme
.lookup_icon(app_id, size, IconLookupFlags::empty()) .lookup_icon(app_id, size, IconLookupFlags::empty())
@@ -100,13 +102,18 @@ fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconL
let is_steam_game = app_id.starts_with("steam_app_"); let is_steam_game = app_id.starts_with("steam_app_");
if is_steam_game { if is_steam_game {
let steam_id: String = app_id.chars().skip("steam_app_".len()).collect(); let steam_id: String = app_id.chars().skip("steam_app_".len()).collect();
let home_dir = dirs::data_dir().unwrap();
let path = home_dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{}.png",
steam_id
));
return Some(IconLocation::File(path)); return match dirs::data_dir() {
Some(dir) => {
let path = dir.join(format!(
"icons/hicolor/32x32/apps/steam_icon_{}.png",
steam_id
));
return Some(IconLocation::File(path));
}
None => None,
};
} }
let icon_name = get_desktop_icon_name(app_id); let icon_name = get_desktop_icon_name(app_id);

57
src/logging.rs Normal file
View File

@@ -0,0 +1,57 @@
use color_eyre::Result;
use dirs::data_dir;
use std::env;
use strip_ansi_escapes::Writer;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_error::ErrorLayer;
use tracing_subscriber::fmt::{Layer, MakeWriter};
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, EnvFilter};
struct MakeFileWriter {
file_writer: NonBlocking,
}
impl MakeFileWriter {
const fn new(file_writer: NonBlocking) -> Self {
Self { file_writer }
}
}
impl<'a> MakeWriter<'a> for MakeFileWriter {
type Writer = Writer<NonBlocking>;
fn make_writer(&'a self) -> Self::Writer {
Writer::new(self.file_writer.clone())
}
}
/// 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> {
let fmt_layer = fmt::layer().with_target(true);
let filter_layer = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
let file_filter_layer =
EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("warn"))?;
let log_path = data_dir().unwrap_or(env::current_dir()?).join("ironbar");
let appender = tracing_appender::rolling::never(log_path, "error.log");
let (file_writer, guard) = tracing_appender::non_blocking(appender);
tracing_subscriber::registry()
.with(filter_layer)
.with(fmt_layer)
.with(ErrorLayer::default())
.with(
Layer::default()
.with_writer(MakeFileWriter::new(file_writer))
.with_ansi(false)
.with_filter(file_filter_layer),
)
.init();
Ok(guard)
}

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,13 +14,13 @@ pub mod tray;
pub mod workspaces; pub mod workspaces;
use crate::config::BarPosition; use crate::config::BarPosition;
/// Shamelessly stolen from here: use crate::popup::ButtonGeometry;
/// <https://github.com/zeroeightysix/rustbar/blob/master/src/modules/module.rs> use color_eyre::Result;
use derive_builder::Builder;
use glib::IsA; use glib::IsA;
use gtk::gdk::Monitor; use gtk::gdk::Monitor;
use gtk::{Application, Widget}; use gtk::{Application, Widget};
use serde::de::DeserializeOwned; use tokio::sync::mpsc;
use serde_json::Value;
#[derive(Clone)] #[derive(Clone)]
pub enum ModuleLocation { pub enum ModuleLocation {
@@ -29,26 +29,70 @@ pub enum ModuleLocation {
Right, Right,
} }
#[derive(Builder)]
pub struct ModuleInfo<'a> { pub struct ModuleInfo<'a> {
pub app: &'a Application, pub app: &'a Application,
pub location: ModuleLocation, pub location: ModuleLocation,
pub bar_position: &'a BarPosition, pub bar_position: BarPosition,
pub monitor: &'a Monitor, pub monitor: &'a Monitor,
pub output_name: &'a str, pub output_name: &'a str,
pub module_name: &'a str,
}
#[derive(Debug)]
pub enum ModuleUpdateEvent<T> {
/// Sends an update to the module UI
Update(T),
/// Toggles the open state of the popup.
TogglePopup(ButtonGeometry),
/// Force sets the popup open.
/// Takes the button X position and width.
OpenPopup(ButtonGeometry),
/// Force sets the popup closed.
ClosePopup,
}
pub struct WidgetContext<TSend, TReceive> {
pub id: usize,
pub tx: mpsc::Sender<ModuleUpdateEvent<TSend>>,
pub controller_tx: mpsc::Sender<TReceive>,
pub widget_rx: glib::Receiver<TSend>,
pub popup_rx: glib::Receiver<TSend>,
}
pub struct ModuleWidget<W: IsA<Widget>> {
pub widget: W,
pub popup: Option<gtk::Box>,
} }
pub trait Module<W> pub trait Module<W>
where where
W: IsA<Widget>, W: IsA<Widget>,
{ {
/// Consumes the module config type SendMessage;
/// and produces a GTK widget of type `W` type ReceiveMessage;
fn into_widget(self, info: &ModuleInfo) -> W;
fn from_value(v: &Value) -> Box<Self> fn spawn_controller(
&self,
info: &ModuleInfo,
tx: mpsc::Sender<ModuleUpdateEvent<Self::SendMessage>>,
rx: mpsc::Receiver<Self::ReceiveMessage>,
) -> Result<()>;
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<W>>;
fn into_popup(
self,
_tx: mpsc::Sender<Self::ReceiveMessage>,
_rx: glib::Receiver<Self::SendMessage>,
) -> Option<gtk::Box>
where where
Self: DeserializeOwned, Self: Sized,
{ {
serde_json::from_value(v.clone()).unwrap() None
} }
} }

View File

@@ -1,14 +1,135 @@
use mpd_client::commands::responses::Status; use lazy_static::lazy_static;
use mpd_client::raw::MpdProtocolError; use mpd_client::client::{CommandError, Connection, ConnectionEvent, Subsystem};
use mpd_client::{Client, Connection}; 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::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use tokio::net::{TcpStream, UnixStream}; 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;
fn is_unix_socket(host: &String) -> bool { lazy_static! {
PathBuf::from(host).is_file() static ref CONNECTIONS: Arc<Mutex<HashMap<String, Arc<MpdClient>>>> =
Arc::new(Mutex::new(HashMap::new()));
} }
pub async fn get_connection(host: &String) -> Result<Connection, MpdProtocolError> { 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) =
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) { if is_unix_socket(host) {
connect_unix(host).await connect_unix(host).await
} else { } else {
@@ -16,43 +137,31 @@ pub async fn get_connection(host: &String) -> Result<Connection, MpdProtocolErro
} }
} }
async fn connect_unix(host: &String) -> Result<Connection, MpdProtocolError> { fn is_unix_socket(host: &str) -> bool {
let connection = UnixStream::connect(host) let path = PathBuf::from(host);
.await path.exists()
.unwrap_or_else(|_| panic!("Error connecting to unix socket: {}", host)); && match path.metadata() {
Ok(metadata) => metadata.file_type().is_socket(),
Err(_) => false,
}
}
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = UnixStream::connect(host).await?;
Client::connect(connection).await Client::connect(connection).await
} }
async fn connect_tcp(host: &String) -> Result<Connection, MpdProtocolError> { async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
let connection = TcpStream::connect(host) let connection = TcpStream::connect(host).await?;
.await
.unwrap_or_else(|_| panic!("Error connecting to unix socket: {}", host));
Client::connect(connection).await Client::connect(connection).await
} }
// /// Gets MPD server status.
// /// Panics on error.
// pub async fn get_status(client: &Client) -> Status {
// client
// .command(commands::Status)
// .await
// .expect("Failed to get MPD server status")
// }
/// Gets the duration of the current song /// Gets the duration of the current song
pub fn get_duration(status: &Status) -> u64 { pub fn get_duration(status: &Status) -> Option<u64> {
status status.duration.map(|duration| duration.as_secs())
.duration
.expect("Failed to get duration from MPD status")
.as_secs()
} }
/// Gets the elapsed time of the current song /// Gets the elapsed time of the current song
pub fn get_elapsed(status: &Status) -> u64 { pub fn get_elapsed(status: &Status) -> Option<u64> {
status status.elapsed.map(|duration| duration.as_secs())
.elapsed
.expect("Failed to get elapsed time from MPD status")
.as_secs()
} }

View File

@@ -1,34 +1,66 @@
mod client; mod client;
mod popup;
use self::popup::Popup; use crate::modules::mpd::client::MpdConnectionError;
use crate::modules::mpd::client::{get_connection, get_duration, get_elapsed}; use crate::modules::mpd::client::{get_client, get_duration, get_elapsed};
use crate::modules::mpd::popup::{MpdPopup, PopupEvent}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::modules::{Module, ModuleInfo}; use crate::popup::Popup;
use dirs::home_dir; use color_eyre::Result;
use dirs::{audio_dir, home_dir};
use glib::Continue; use glib::Continue;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Button, Orientation}; use gtk::{Button, Image, Label, Orientation};
use mpd_client::commands::responses::{PlayState, Song, Status}; use mpd_client::commands;
use mpd_client::{commands, Tag}; use mpd_client::responses::{PlayState, Song, Status};
use mpd_client::tag::Tag;
use regex::Regex; use regex::Regex;
use serde::Deserialize; use serde::Deserialize;
use std::path::PathBuf; use std::path::PathBuf;
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::sleep; use tokio::sync::mpsc::{Receiver, Sender};
use tracing::error;
#[derive(Debug)]
pub enum PlayerCommand {
Previous,
Toggle,
Next,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Icons {
/// Icon to display when playing.
#[serde(default = "default_icon_play")]
play: Option<String>,
/// Icon to display when paused.
#[serde(default = "default_icon_pause")]
pause: Option<String>,
}
impl Default for Icons {
fn default() -> Self {
Self {
pause: default_icon_pause(),
play: default_icon_play(),
}
}
}
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct MpdModule { pub struct MpdModule {
/// TCP or Unix socket address.
#[serde(default = "default_socket")] #[serde(default = "default_socket")]
host: String, host: String,
/// Format of current song info to display on the bar.
#[serde(default = "default_format")] #[serde(default = "default_format")]
format: String, format: String,
#[serde(default = "default_icon_play")]
icon_play: Option<String>,
#[serde(default = "default_icon_pause")]
icon_pause: Option<String>,
/// Player state icons
#[serde(default)]
icons: Icons,
/// Path to root of music directory.
#[serde(default = "default_music_dir")] #[serde(default = "default_music_dir")]
music_dir: PathBuf, music_dir: PathBuf,
} }
@@ -41,16 +73,18 @@ fn default_format() -> String {
String::from("{icon} {title} / {artist}") String::from("{icon} {title} / {artist}")
} }
#[allow(clippy::unnecessary_wraps)]
fn default_icon_play() -> Option<String> { fn default_icon_play() -> Option<String> {
Some(String::from("")) Some(String::from(""))
} }
#[allow(clippy::unnecessary_wraps)]
fn default_icon_pause() -> Option<String> { fn default_icon_pause() -> Option<String> {
Some(String::from("")) Some(String::from(""))
} }
fn default_music_dir() -> PathBuf { fn default_music_dir() -> PathBuf {
home_dir().unwrap().join("Music") audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
} }
/// Attempts to read the first value for a tag /// Attempts to read the first value for a tag
@@ -78,152 +112,339 @@ fn get_tokens(re: &Regex, format_string: &str) -> Vec<String> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
} }
enum Event { #[derive(Clone, Debug)]
Open, pub struct SongUpdate {
Update(Box<Option<(Song, Status, String)>>), song: Song,
status: Status,
display_string: String,
} }
impl Module<Button> for MpdModule { impl Module<Button> for MpdModule {
fn into_widget(self, info: &ModuleInfo) -> Button { type SendMessage = Option<SongUpdate>;
let re = Regex::new(r"\{([\w-]+)}").unwrap(); type ReceiveMessage = PlayerCommand;
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()); let tokens = get_tokens(&re, self.format.as_str());
let button = Button::new(); // poll mpd server
let (ui_tx, mut ui_rx) = mpsc::channel(32);
let popup = Popup::new(
"popup-mpd",
info.app,
info.monitor,
Orientation::Horizontal,
info.bar_position,
);
let mpd_popup = MpdPopup::new(popup, ui_tx);
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
let click_tx = tx.clone();
let music_dir = self.music_dir.clone();
button.connect_clicked(move |_| {
click_tx.send(Event::Open).unwrap();
});
let host = self.host.clone();
let host2 = self.host.clone();
spawn(async move { spawn(async move {
let (client, _) = get_connection(&host).await.unwrap(); // TODO: Handle connecting properly let client = get_client(&host1).await.expect("Failed to connect to MPD");
let mut mpd_rx = client.subscribe();
loop { loop {
let current_song = client.command(commands::CurrentSong).await; let current_song = client.command(commands::CurrentSong).await;
let status = client.command(commands::Status).await; let status = client.command(commands::Status).await;
if let (Ok(Some(song)), Ok(status)) = (current_song, status) { if let (Ok(Some(song)), Ok(status)) = (current_song, status) {
let string = self let display_string =
.replace_tokens(self.format.as_str(), &tokens, &song.song, &status) replace_tokens(format.as_str(), &tokens, &song.song, &status, &icons);
.await;
tx.send(Event::Update(Box::new(Some((song.song, status, string))))) let update = SongUpdate {
.unwrap(); song: song.song,
status,
display_string,
};
tx.send(ModuleUpdateEvent::Update(Some(update))).await?;
} else { } else {
tx.send(Event::Update(Box::new(None))).unwrap(); tx.send(ModuleUpdateEvent::Update(None)).await?;
} }
sleep(tokio::time::Duration::from_secs(1)).await; // wait for player state change
if mpd_rx.recv().await.is_err() {
break;
}
} }
Ok::<(), mpsc::error::SendError<ModuleUpdateEvent<Self::SendMessage>>>(())
}); });
// listen to ui events
spawn(async move { spawn(async move {
let (client, _) = get_connection(&host2).await.unwrap(); // TODO: Handle connecting properly let client = get_client(&host2).await?;
while let Some(event) = ui_rx.recv().await { while let Some(event) = rx.recv().await {
match event { let res = match event {
PopupEvent::Previous => client.command(commands::Previous).await, PlayerCommand::Previous => client.command(commands::Previous).await,
PopupEvent::Toggle => { PlayerCommand::Toggle => match client.command(commands::Status).await {
let status = client.command(commands::Status).await.unwrap(); Ok(status) => match status.state {
match status.state {
PlayState::Playing => client.command(commands::SetPause(true)).await, PlayState::Playing => client.command(commands::SetPause(true)).await,
PlayState::Paused => client.command(commands::SetPause(false)).await, PlayState::Paused => client.command(commands::SetPause(false)).await,
PlayState::Stopped => Ok(()), PlayState::Stopped => Ok(()),
} },
} Err(err) => Err(err),
PopupEvent::Next => client.command(commands::Next).await, },
PlayerCommand::Next => client.command(commands::Next).await,
};
if let Err(err) = res {
error!("Failed to send command to MPD server: {:?}", err);
} }
.unwrap();
} }
Ok::<(), MpdConnectionError>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<Button>> {
let button = Button::new();
let label = Label::new(None);
label.set_angle(info.bar_position.get_angle());
button.add(&label);
let orientation = info.bar_position.get_orientation();
button.connect_clicked(move |button| {
context
.tx
.try_send(ModuleUpdateEvent::TogglePopup(Popup::button_pos(
button,
orientation,
)))
.expect("Failed to send MPD popup open event");
}); });
{ {
let button = button.clone(); let button = button.clone();
rx.attach(None, move |event| { context.widget_rx.attach(None, move |mut event| {
match event { if let Some(event) = event.take() {
Event::Open => { label.set_label(&event.display_string);
mpd_popup.popup.show(&button); button.show();
} } else {
Event::Update(mut msg) => { button.hide();
if let Some((song, status, string)) = msg.take() {
mpd_popup.update(&song, &status, music_dir.as_path());
button.set_label(&string);
button.show();
} else {
button.hide();
}
}
} }
Continue(true) Continue(true)
}); });
}; };
button let popup = self.into_popup(context.controller_tx, context.popup_rx);
}
}
impl MpdModule { Ok(ModuleWidget {
/// Replaces each of the formatting tokens in the formatting string widget: button,
/// with actual data pulled from MPD popup,
async fn replace_tokens( })
&self, }
format_string: &str,
tokens: &Vec<String>, fn into_popup(
song: &Song, self,
status: &Status, tx: Sender<Self::ReceiveMessage>,
) -> String { rx: glib::Receiver<Self::SendMessage>,
let mut compiled_string = format_string.to_string(); ) -> Option<gtk::Box> {
for token in tokens { let container = gtk::Box::builder()
let value = self.get_token_value(song, status, token).await; .orientation(Orientation::Horizontal)
compiled_string = .spacing(10)
compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str()); .name("popup-mpd")
.build();
let album_image = Image::builder()
.width_request(128)
.height_request(128)
.name("album-art")
.build();
let info_box = gtk::Box::new(Orientation::Vertical, 10);
let title_label = IconLabel::new("\u{f886}", None);
let album_label = IconLabel::new("\u{f524}", None);
let artist_label = IconLabel::new("\u{fd01}", None);
title_label.container.set_widget_name("title");
album_label.container.set_widget_name("album");
artist_label.container.set_widget_name("label");
info_box.add(&title_label.container);
info_box.add(&album_label.container);
info_box.add(&artist_label.container);
let controls_box = gtk::Box::builder().name("controls").build();
let btn_prev = Button::builder().label("\u{f9ad}").name("btn-prev").build();
let btn_play_pause = Button::builder().label("").name("btn-play-pause").build();
let btn_next = Button::builder().label("\u{f9ac}").name("btn-next").build();
controls_box.add(&btn_prev);
controls_box.add(&btn_play_pause);
controls_box.add(&btn_next);
info_box.add(&controls_box);
container.add(&album_image);
container.add(&info_box);
let tx_prev = tx.clone();
btn_prev.connect_clicked(move |_| {
tx_prev
.try_send(PlayerCommand::Previous)
.expect("Failed to send prev track message");
});
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");
});
let tx_next = tx;
btn_next.connect_clicked(move |_| {
tx_next
.try_send(PlayerCommand::Next)
.expect("Failed to send next track message");
});
container.show_all();
{
let music_dir = self.music_dir;
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"),
);
if let Ok(pixbuf) = Pixbuf::from_file_at_scale(cover_path, 128, 128, true) {
album_image.set_from_pixbuf(Some(&pixbuf));
} else {
album_image.set_from_pixbuf(None);
}
}
title_label
.label
.set_text(update.song.title().unwrap_or_default());
album_label.label.set_text(curr_album);
artist_label
.label
.set_text(update.song.artists().first().unwrap_or(&String::new()));
match update.status.state {
PlayState::Stopped => {
btn_play_pause.set_sensitive(false);
}
PlayState::Playing => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label("");
}
PlayState::Paused => {
btn_play_pause.set_sensitive(true);
btn_play_pause.set_label("");
}
}
let enable_prev = match update.status.current_song {
Some((pos, _)) => pos.0 > 0,
None => false,
};
let enable_next = match update.status.current_song {
Some((pos, _)) => pos.0 < update.status.playlist_length,
None => false,
};
btn_prev.set_sensitive(enable_prev);
btn_next.set_sensitive(enable_next);
}
Continue(true)
});
} }
compiled_string
}
/// Converts a string format token value Some(container)
/// into its respective MPD value. }
pub async fn get_token_value(&self, song: &Song, status: &Status, token: &str) -> String { }
let s = match token {
"icon" => { /// Replaces each of the formatting tokens in the formatting string
let icon = match status.state { /// with actual data pulled from MPD
PlayState::Stopped => None, fn replace_tokens(
PlayState::Playing => self.icon_play.as_ref(), format_string: &str,
PlayState::Paused => self.icon_pause.as_ref(), tokens: &Vec<String>,
}; song: &Song,
icon.map(String::as_str) status: &Status,
} icons: &Icons,
"title" => song.title(), ) -> String {
"album" => try_get_first_tag(song.tags.get(&Tag::Album)), let mut compiled_string = format_string.to_string();
"artist" => try_get_first_tag(song.tags.get(&Tag::Artist)), for token in tokens {
"date" => try_get_first_tag(song.tags.get(&Tag::Date)), let value = get_token_value(song, status, icons, token);
"disc" => try_get_first_tag(song.tags.get(&Tag::Disc)), compiled_string =
"genre" => try_get_first_tag(song.tags.get(&Tag::Genre)), compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str());
"track" => try_get_first_tag(song.tags.get(&Tag::Track)), }
"duration" => return format_time(get_duration(status)), compiled_string
"elapsed" => return format_time(get_elapsed(status)), }
_ => return token.to_string(),
}; /// Converts a string format token value
s.unwrap_or_default().to_string() /// 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 => icons.play.as_ref(),
PlayState::Paused => icons.pause.as_ref(),
};
icon.map(String::as_str)
}
"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()
}
#[derive(Clone)]
struct IconLabel {
label: Label,
container: gtk::Box,
}
impl IconLabel {
fn new(icon: &str, label: Option<&str>) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 5);
let icon = Label::new(Some(icon));
let label = Label::new(label);
icon.style_context().add_class("icon");
label.style_context().add_class("label");
container.add(&icon);
container.add(&label);
Self { label, container }
} }
} }

View File

@@ -1,164 +0,0 @@
pub use crate::popup::Popup;
use gtk::gdk_pixbuf::Pixbuf;
use gtk::prelude::*;
use gtk::{Button, Image, Label, Orientation};
use mpd_client::commands::responses::{PlayState, Song, Status};
use std::path::Path;
use tokio::sync::mpsc;
#[derive(Clone)]
struct IconLabel {
label: Label,
container: gtk::Box,
}
impl IconLabel {
fn new(icon: &str, label: Option<&str>) -> Self {
let container = gtk::Box::new(Orientation::Horizontal, 5);
let icon = Label::new(Some(icon));
let label = Label::new(label);
icon.style_context().add_class("icon");
label.style_context().add_class("label");
container.add(&icon);
container.add(&label);
Self { label, container }
}
}
#[derive(Clone)]
pub struct MpdPopup {
pub popup: Popup,
cover: Image,
title: IconLabel,
album: IconLabel,
artist: IconLabel,
btn_prev: Button,
btn_play_pause: Button,
btn_next: Button,
}
#[derive(Debug)]
pub enum PopupEvent {
Previous,
Toggle,
Next,
}
impl MpdPopup {
pub fn new(popup: Popup, tx: mpsc::Sender<PopupEvent>) -> Self {
let album_image = Image::builder()
.width_request(128)
.height_request(128)
.name("album-art")
.build();
let info_box = gtk::Box::new(Orientation::Vertical, 10);
let title_label = IconLabel::new("\u{f886}", None);
let album_label = IconLabel::new("\u{f524}", None);
let artist_label = IconLabel::new("\u{fd01}", None);
title_label.container.set_widget_name("title");
album_label.container.set_widget_name("album");
artist_label.container.set_widget_name("label");
info_box.add(&title_label.container);
info_box.add(&album_label.container);
info_box.add(&artist_label.container);
let controls_box = gtk::Box::builder().name("controls").build();
let btn_prev = Button::builder().label("\u{f9ad}").name("btn-prev").build();
let btn_play_pause = Button::builder().label("").name("btn-play-pause").build();
let btn_next = Button::builder().label("\u{f9ac}").name("btn-next").build();
controls_box.add(&btn_prev);
controls_box.add(&btn_play_pause);
controls_box.add(&btn_next);
info_box.add(&controls_box);
popup.container.add(&album_image);
popup.container.add(&info_box);
let tx_prev = tx.clone();
btn_prev.connect_clicked(move |_| {
tx_prev.try_send(PopupEvent::Previous).unwrap();
});
let tx_toggle = tx.clone();
btn_play_pause.connect_clicked(move |_| {
tx_toggle.try_send(PopupEvent::Toggle).unwrap();
});
let tx_next = tx;
btn_next.connect_clicked(move |_| {
tx_next.try_send(PopupEvent::Next).unwrap();
});
Self {
popup,
cover: album_image,
artist: artist_label,
album: album_label,
title: title_label,
btn_prev,
btn_play_pause,
btn_next,
}
}
pub fn update(&self, song: &Song, status: &Status, path: &Path) {
let prev_album = self.album.label.text();
let curr_album = song.album().unwrap_or_default();
// only update art when album changes
if prev_album != curr_album {
let cover_path = path.join(song.file_path().parent().unwrap().join("cover.jpg"));
if let Ok(pixbuf) = Pixbuf::from_file_at_scale(cover_path, 128, 128, true) {
self.cover.set_from_pixbuf(Some(&pixbuf));
}
}
self.title.label.set_text(song.title().unwrap_or_default());
self.album.label.set_text(song.album().unwrap_or_default());
self.artist
.label
.set_text(song.artists().first().unwrap_or(&String::new()));
match status.state {
PlayState::Stopped => {
self.btn_play_pause.set_sensitive(false);
}
PlayState::Playing => {
self.btn_play_pause.set_sensitive(true);
self.btn_play_pause.set_label("");
}
PlayState::Paused => {
self.btn_play_pause.set_sensitive(true);
self.btn_play_pause.set_label("");
}
}
let enable_prev = match status.current_song {
Some((pos, _)) => pos.0 > 0,
None => false,
};
let enable_next = match status.current_song {
Some((pos, _)) => pos.0 < status.playlist_length,
None => false,
};
self.btn_prev.set_sensitive(enable_prev);
self.btn_next.set_sensitive(enable_next);
}
}

View File

@@ -1,14 +1,19 @@
use crate::modules::{Module, ModuleInfo}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::{eyre::Report, eyre::Result, eyre::WrapErr, Section};
use gtk::prelude::*; use gtk::prelude::*;
use gtk::Label; use gtk::Label;
use serde::Deserialize; use serde::Deserialize;
use std::process::Command; use std::process::Command;
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::time::sleep; use tokio::time::sleep;
use tracing::{error, instrument};
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct ScriptModule { pub struct ScriptModule {
/// Path to script to execute.
path: String, path: String,
/// Time in milliseconds between executions.
#[serde(default = "default_interval")] #[serde(default = "default_interval")]
interval: u64, interval: u64,
} }
@@ -19,33 +24,79 @@ const fn default_interval() -> u64 {
} }
impl Module<Label> for ScriptModule { impl Module<Label> for ScriptModule {
fn into_widget(self, _info: &ModuleInfo) -> Label { type SendMessage = String;
let label = Label::builder().use_markup(true).build(); type ReceiveMessage = ();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); fn spawn_controller(
&self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
_rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let interval = self.interval;
let path = self.path.clone();
spawn(async move { spawn(async move {
loop { loop {
let output = Command::new("sh").arg("-c").arg(&self.path).output(); match run_script(&path) {
if let Ok(output) = output { Ok(stdout) => tx
let stdout = String::from_utf8(output.stdout) .send(ModuleUpdateEvent::Update(stdout))
.map(|output| output.trim().to_string()) .await
.expect("Script output not valid UTF-8"); .expect("Failed to send stdout"),
Err(err) => error!("{:?}", err),
tx.send(stdout).unwrap();
} }
sleep(tokio::time::Duration::from_millis(self.interval)).await; sleep(tokio::time::Duration::from_millis(interval)).await;
} }
}); });
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<Label>> {
let label = Label::builder().use_markup(true).build();
label.set_angle(info.bar_position.get_angle());
{ {
let label = label.clone(); let label = label.clone();
rx.attach(None, move |s| { context.widget_rx.attach(None, move |s| {
label.set_label(s.as_str()); label.set_label(s.as_str());
Continue(true) Continue(true)
}); });
} }
label Ok(ModuleWidget {
widget: label,
popup: None,
})
}
}
#[instrument]
fn run_script(path: &str) -> Result<String> {
let output = Command::new("sh")
.arg("-c")
.arg(path)
.output()
.wrap_err("Failed to get script output")?;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)
.map(|output| output.trim().to_string())
.wrap_err("Script stdout not valid UTF-8")?;
Ok(stdout)
} else {
let stderr = String::from_utf8(output.stderr)
.map(|output| output.trim().to_string())
.wrap_err("Script stderr not valid UTF-8")?;
Err(Report::msg(stderr)
.wrap_err("Script returned non-zero error code")
.suggestion("Check the path to your script")
.suggestion("Check the script for errors"))
} }
} }

View File

@@ -1,33 +1,31 @@
use crate::modules::{Module, ModuleInfo}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Label, Orientation}; use gtk::Label;
use regex::{Captures, Regex}; use regex::{Captures, Regex};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use sysinfo::{CpuExt, System, SystemExt}; use sysinfo::{CpuExt, System, SystemExt};
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc::{Receiver, Sender};
use tokio::time::sleep; use tokio::time::sleep;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct SysInfoModule { pub struct SysInfoModule {
/// List of formatting strings.
format: Vec<String>, format: Vec<String>,
} }
impl Module<gtk::Box> for SysInfoModule { impl Module<gtk::Box> for SysInfoModule {
fn into_widget(self, _info: &ModuleInfo) -> gtk::Box { type SendMessage = HashMap<String, String>;
let re = Regex::new(r"\{([\w-]+)}").unwrap(); type ReceiveMessage = ();
let container = gtk::Box::new(Orientation::Horizontal, 10); fn spawn_controller(
&self,
let mut labels = Vec::new(); _info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
for format in &self.format { _rx: Receiver<Self::ReceiveMessage>,
let label = Label::builder().label(format).name("item").build(); ) -> Result<()> {
container.add(&label);
labels.push(label);
}
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move { spawn(async move {
let mut sys = System::new_all(); let mut sys = System::new_all();
@@ -43,18 +41,47 @@ impl Module<gtk::Box> for SysInfoModule {
// TODO: Add remaining format info // TODO: Add remaining format info
format_info.insert("memory-percent", format!("{:0>2.0}", memory_percent)); format_info.insert(
format_info.insert("cpu-percent", format!("{:0>2.0}", cpu_percent)); String::from("memory-percent"),
format!("{:0>2.0}", memory_percent),
);
format_info.insert(
String::from("cpu-percent"),
format!("{:0>2.0}", cpu_percent),
);
tx.send(format_info).unwrap(); tx.send(ModuleUpdateEvent::Update(format_info))
.await
.expect("Failed to send system info map");
sleep(tokio::time::Duration::from_secs(1)).await; sleep(tokio::time::Duration::from_secs(1)).await;
} }
}); });
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let re = Regex::new(r"\{([\w-]+)}")?;
let container = gtk::Box::new(info.bar_position.get_orientation(), 10);
let mut labels = Vec::new();
for format in &self.format {
let label = Label::builder().label(format).name("item").build();
label.set_angle(info.bar_position.get_angle());
container.add(&label);
labels.push(label);
}
{ {
let formats = self.format; let formats = self.format;
rx.attach(None, move |info| { context.widget_rx.attach(None, move |info| {
for (format, label) in formats.iter().zip(labels.clone()) { for (format, label) in formats.iter().zip(labels.clone()) {
let format_compiled = re.replace(format, |caps: &Captures| { let format_compiled = re.replace(format, |caps: &Captures| {
info.get(&caps[1]) info.get(&caps[1])
@@ -69,6 +96,9 @@ impl Module<gtk::Box> for SysInfoModule {
}); });
} }
container Ok(ModuleWidget {
widget: container,
popup: None,
})
} }
} }

View File

@@ -0,0 +1,74 @@
use async_once::AsyncOnce;
use lazy_static::lazy_static;
use stray::message::{NotifierItemCommand, NotifierItemMessage};
use stray::StatusNotifierWatcher;
use tokio::spawn;
use tokio::sync::{broadcast, mpsc};
use tracing::debug;
pub struct TrayEventReceiver {
tx: mpsc::Sender<NotifierItemCommand>,
b_tx: broadcast::Sender<NotifierItemMessage>,
_b_rx: broadcast::Receiver<NotifierItemMessage>,
}
impl TrayEventReceiver {
async fn new() -> stray::error::Result<Self> {
let (tx, rx) = mpsc::channel(16);
let (b_tx, b_rx) = broadcast::channel(16);
let tray = StatusNotifierWatcher::new(rx).await?;
let mut host = tray.create_notifier_host("ironbar").await?;
let b_tx2 = b_tx.clone();
spawn(async move {
while let Ok(message) = host.recv().await {
b_tx2.send(message)?;
}
Ok::<(), broadcast::error::SendError<NotifierItemMessage>>(())
});
Ok(Self {
tx,
b_tx,
_b_rx: b_rx,
})
}
pub fn subscribe(
&self,
) -> (
mpsc::Sender<NotifierItemCommand>,
broadcast::Receiver<NotifierItemMessage>,
) {
(self.tx.clone(), self.b_tx.subscribe())
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<TrayEventReceiver> = AsyncOnce::new(async {
const MAX_RETRIES: i32 = 10;
// sometimes this can fail
let mut retries = 0;
let value = loop {
retries += 1;
let tray = TrayEventReceiver::new().await;
if tray.is_ok() || retries == MAX_RETRIES {
break tray;
}
debug!("Failed to create StatusNotifierWatcher (attempt {retries})");
};
value.expect("Failed to create StatusNotifierWatcher")
});
}
pub async fn get_tray_event_client() -> &'static TrayEventReceiver {
CLIENT.get().await
}

View File

@@ -1,35 +1,34 @@
use crate::modules::{Module, ModuleInfo}; mod client;
use futures_util::StreamExt;
use crate::await_sync;
use crate::modules::tray::client::get_tray_event_client;
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use color_eyre::Result;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem}; use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use stray::message::menu::{MenuItem as MenuItemInfo, MenuType, TrayMenu}; use stray::message::menu::{MenuItem as MenuItemInfo, MenuType};
use stray::message::tray::StatusNotifierItem; use stray::message::tray::StatusNotifierItem;
use stray::message::{NotifierItemCommand, NotifierItemMessage}; use stray::message::{NotifierItemCommand, NotifierItemMessage};
use stray::SystemTray;
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::mpsc::{Receiver, Sender};
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct TrayModule; pub struct TrayModule;
#[derive(Debug)]
enum TrayUpdate {
Update(String, Box<StatusNotifierItem>, Option<TrayMenu>),
Remove(String),
}
/// Gets a GTK `Image` component /// Gets a GTK `Image` component
/// for the status notifier item's icon. /// for the status notifier item's icon.
fn get_icon(item: &StatusNotifierItem) -> Option<Image> { fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
item.icon_theme_path.as_ref().and_then(|path| { item.icon_theme_path.as_ref().and_then(|path| {
let theme = IconTheme::new(); let theme = IconTheme::new();
theme.append_search_path(&path); theme.append_search_path(&path);
let icon_name = item.icon_name.as_ref().unwrap();
let icon_info = theme.lookup_icon(icon_name, 16, IconLookupFlags::empty());
icon_info.map(|icon_info| Image::from_pixbuf(icon_info.load_icon().ok().as_ref())) item.icon_name.as_ref().and_then(|icon_name| {
let icon_info = theme.lookup_icon(icon_name, 16, IconLookupFlags::empty());
icon_info.map(|icon_info| Image::from_pixbuf(icon_info.load_icon().ok().as_ref()))
})
}) })
} }
@@ -37,9 +36,9 @@ fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
/// for the provided submenu array. /// for the provided submenu array.
fn get_menu_items( fn get_menu_items(
menu: &[MenuItemInfo], menu: &[MenuItemInfo],
tx: &mpsc::Sender<NotifierItemCommand>, tx: &Sender<NotifierItemCommand>,
id: String, id: &str,
path: String, path: &str,
) -> Vec<MenuItem> { ) -> Vec<MenuItem> {
menu.iter() menu.iter()
.map(|item_info| { .map(|item_info| {
@@ -53,7 +52,7 @@ fn get_menu_items(
if !item_info.submenu.is_empty() { if !item_info.submenu.is_empty() {
let menu = Menu::new(); let menu = Menu::new();
get_menu_items(&item_info.submenu, &tx.clone(), id.clone(), path.clone()) get_menu_items(&item_info.submenu, &tx.clone(), id, path)
.iter() .iter()
.for_each(|item| menu.add(item)); .for_each(|item| menu.add(item));
@@ -63,8 +62,8 @@ fn get_menu_items(
let item = builder.build(); let item = builder.build();
let info = item_info.clone(); let info = item_info.clone();
let id = id.clone(); let id = id.to_string();
let path = path.clone(); let path = path.to_string();
{ {
let tx = tx.clone(); let tx = tx.clone();
@@ -74,7 +73,7 @@ fn get_menu_items(
menu_path: path.clone(), menu_path: path.clone(),
notifier_address: id.clone(), notifier_address: id.clone(),
}) })
.unwrap(); .expect("Failed to send menu item clicked event");
}); });
} }
@@ -88,74 +87,90 @@ fn get_menu_items(
} }
impl Module<MenuBar> for TrayModule { impl Module<MenuBar> for TrayModule {
fn into_widget(self, _info: &ModuleInfo) -> MenuBar { type SendMessage = NotifierItemMessage;
let container = MenuBar::new(); type ReceiveMessage = NotifierItemCommand;
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); fn spawn_controller(
let (ui_tx, ui_rx) = mpsc::channel(32); &self,
_info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let client = await_sync(async { get_tray_event_client().await });
let (tray_tx, mut tray_rx) = client.subscribe();
// listen to tray updates
spawn(async move { spawn(async move {
// FIXME: Can only spawn one of these at a time - means cannot have tray on multiple bars while let Ok(message) = tray_rx.recv().await {
let mut tray = SystemTray::new(ui_rx).await; tx.send(ModuleUpdateEvent::Update(message)).await?;
// listen for tray updates & send message to update UI
while let Some(message) = tray.next().await {
match message {
NotifierItemMessage::Update {
address: id,
item,
menu,
} => {
tx.send(TrayUpdate::Update(id, Box::new(item), menu))
.unwrap();
}
NotifierItemMessage::Remove { address: id } => {
tx.send(TrayUpdate::Remove(id)).unwrap();
}
}
} }
Ok::<(), mpsc::error::SendError<ModuleUpdateEvent<Self::SendMessage>>>(())
}); });
// send tray commands
spawn(async move {
while let Some(cmd) = rx.recv().await {
tray_tx.send(cmd).await?;
}
Ok::<(), mpsc::error::SendError<NotifierItemCommand>>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
_info: &ModuleInfo,
) -> Result<ModuleWidget<MenuBar>> {
let container = MenuBar::new();
{ {
let container = container.clone(); let container = container.clone();
let mut widgets = HashMap::new(); let mut widgets = HashMap::new();
// listen for UI updates // listen for UI updates
rx.attach(None, move |update| { context.widget_rx.attach(None, move |update| {
match update { match update {
TrayUpdate::Update(id, item, menu) => { NotifierItemMessage::Update {
let menu_item = widgets.remove(id.as_str()).unwrap_or_else(|| { item,
address,
menu,
} => {
let menu_item = widgets.remove(address.as_str()).unwrap_or_else(|| {
let menu_item = MenuItem::new(); let menu_item = MenuItem::new();
menu_item.style_context().add_class("item"); menu_item.style_context().add_class("item");
if let Some(image) = get_icon(&item) { if let Some(image) = get_icon(&item) {
image.set_widget_name(id.as_str()); image.set_widget_name(address.as_str());
menu_item.add(&image); menu_item.add(&image);
} }
container.add(&menu_item); container.add(&menu_item);
menu_item.show_all(); menu_item.show_all();
menu_item menu_item
}); });
if let (Some(menu_opts), Some(menu_path)) = (menu, item.menu) {
if let Some(menu_opts) = menu {
let menu_path = item.menu.as_ref().unwrap().to_string();
let submenus = menu_opts.submenus; let submenus = menu_opts.submenus;
if !submenus.is_empty() { if !submenus.is_empty() {
let menu = Menu::new(); let menu = Menu::new();
get_menu_items(&submenus, &ui_tx.clone(), id.clone(), menu_path) get_menu_items(
.iter() &submenus,
.for_each(|item| menu.add(item)); &context.controller_tx.clone(),
&address,
&menu_path,
)
.iter()
.for_each(|item| menu.add(item));
menu_item.set_submenu(Some(&menu)); menu_item.set_submenu(Some(&menu));
} }
} }
widgets.insert(address, menu_item);
widgets.insert(id, menu_item);
} }
TrayUpdate::Remove(id) => { NotifierItemMessage::Remove { address } => {
let widget = widgets.get(&id).unwrap(); if let Some(widget) = widgets.get(&address) {
container.remove(widget); container.remove(widget);
}
} }
}; };
@@ -163,6 +178,9 @@ impl Module<MenuBar> for TrayModule {
}); });
}; };
container Ok(ModuleWidget {
widget: container,
popup: None,
})
} }
} }

View File

@@ -1,59 +1,84 @@
use crate::modules::{Module, ModuleInfo}; use crate::await_sync;
use crate::sway::{Workspace, WorkspaceEvent}; use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
use crate::sway::{get_client, get_sub_client};
use color_eyre::{Report, Result};
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Button, Orientation}; use gtk::Button;
use ksway::client::Client;
use ksway::{IpcCommand, IpcEvent};
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
use swayipc_async::{Workspace, WorkspaceChange, WorkspaceEvent};
use tokio::spawn; use tokio::spawn;
use tokio::sync::mpsc; use tokio::sync::mpsc::{Receiver, Sender};
use tokio::task::spawn_blocking; use tracing::trace;
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]
pub struct WorkspacesModule { pub struct WorkspacesModule {
/// Map of actual workspace names to custom names.
name_map: Option<HashMap<String, String>>, name_map: Option<HashMap<String, String>>,
/// Whether to display buttons for all monitors.
#[serde(default = "crate::config::default_false")] #[serde(default = "crate::config::default_false")]
all_monitors: bool, all_monitors: bool,
} }
impl Workspace { #[derive(Clone, Debug)]
fn as_button(&self, name_map: &HashMap<String, String>, tx: &mpsc::Sender<String>) -> Button { pub enum WorkspaceUpdate {
let button = Button::builder() Init(Vec<Workspace>),
.label(name_map.get(self.name.as_str()).unwrap_or(&self.name)) Update(Box<WorkspaceEvent>),
.build(); }
let style_context = button.style_context(); /// Creates a button from a workspace
style_context.add_class("item"); fn create_button(
name: &str,
focused: bool,
name_map: &HashMap<String, String>,
tx: &Sender<String>,
) -> Button {
let button = Button::builder()
.label(name_map.get(name).map_or(name, String::as_str))
.build();
if self.focused { let style_context = button.style_context();
style_context.add_class("focused"); style_context.add_class("item");
}
{ if focused {
let tx = tx.clone(); style_context.add_class("focused");
let name = self.name.clone();
button.connect_clicked(move |_item| tx.try_send(name.clone()).unwrap());
}
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");
});
}
button
} }
impl Module<gtk::Box> for WorkspacesModule { impl Module<gtk::Box> for WorkspacesModule {
fn into_widget(self, info: &ModuleInfo) -> gtk::Box { type SendMessage = WorkspaceUpdate;
let mut sway = Client::connect().unwrap(); type ReceiveMessage = String;
let container = gtk::Box::new(Orientation::Horizontal, 0);
fn spawn_controller(
&self,
info: &ModuleInfo,
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
mut rx: Receiver<Self::ReceiveMessage>,
) -> Result<()> {
let workspaces = { let workspaces = {
let raw = sway.ipc(IpcCommand::GetWorkspaces).unwrap(); trace!("Getting current workspaces");
let workspaces = serde_json::from_slice::<Vec<Workspace>>(&raw).unwrap(); let workspaces = await_sync(async {
let sway = get_client().await;
let mut sway = sway.lock().await;
sway.get_workspaces().await
})?;
if self.all_monitors { if self.all_monitors {
workspaces workspaces
} else { } else {
trace!("Filtering workspaces to current monitor only");
workspaces workspaces
.into_iter() .into_iter()
.filter(|workspace| workspace.output == info.output_name) .filter(|workspace| workspace.output == info.output_name)
@@ -61,75 +86,154 @@ impl Module<gtk::Box> for WorkspacesModule {
} }
}; };
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()
};
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");
}
});
// 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?;
}
Ok::<(), Report>(())
});
Ok(())
}
fn into_widget(
self,
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
info: &ModuleInfo,
) -> Result<ModuleWidget<gtk::Box>> {
let container = gtk::Box::new(info.bar_position.get_orientation(), 0);
let name_map = self.name_map.unwrap_or_default(); let name_map = self.name_map.unwrap_or_default();
let mut button_map: HashMap<String, Button> = HashMap::new(); let mut button_map: HashMap<String, Button> = HashMap::new();
let (ui_tx, mut ui_rx) = mpsc::channel(32);
for workspace in workspaces {
let item = workspace.as_button(&name_map, &ui_tx);
container.add(&item);
button_map.insert(workspace.name, item);
}
let srx = sway.subscribe(vec![IpcEvent::Workspace]).unwrap();
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn_blocking(move || loop {
while let Ok((_, payload)) = srx.try_recv() {
let payload: WorkspaceEvent = serde_json::from_slice(&payload).unwrap();
tx.send(payload).unwrap();
}
sway.poll().unwrap();
});
{ {
let menubar = container.clone(); let container = container.clone();
let output_name = info.output_name.to_string(); let output_name = info.output_name.to_string();
rx.attach(None, move |event| {
match event.change.as_str() {
"focus" => {
let old = event.old.unwrap();
if let Some(old_button) = button_map.get(&old.name) {
old_button.style_context().remove_class("focused");
}
let new = event.current.unwrap(); context.widget_rx.attach(None, move |event| {
if let Some(new_button) = button_map.get(&new.name) { match event {
new_button.style_context().add_class("focused"); WorkspaceUpdate::Init(workspaces) => {
} trace!("Creating workspace buttons");
} for workspace in workspaces {
"init" => { let item = create_button(
let workspace = event.current.unwrap(); &workspace.name,
if self.all_monitors || workspace.output == output_name { workspace.focused,
let item = workspace.as_button(&name_map, &ui_tx); &name_map,
&context.controller_tx,
item.show(); );
menubar.add(&item); container.add(&item);
button_map.insert(workspace.name, item); button_map.insert(workspace.name, item);
} }
container.show_all();
} }
"empty" => { WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Focus => {
let current = event.current.unwrap(); let old = event
if let Some(item) = button_map.get(&current.name) { .old
menubar.remove(item); .and_then(|old| old.name)
.and_then(|name| button_map.get(&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));
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();
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);
}
}
}
}
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())
{
container.remove(item);
}
}
}
WorkspaceUpdate::Update(_) => {}
};
Continue(true) Continue(true)
}); });
} }
spawn(async move { Ok(ModuleWidget {
let mut sway = Client::connect().unwrap(); widget: container,
while let Some(name) = ui_rx.recv().await { popup: None,
sway.run(format!("workspace {}", name)).unwrap(); })
}
});
container
} }
} }

View File

@@ -1,24 +1,31 @@
use std::collections::HashMap;
use crate::config::BarPosition; use crate::config::BarPosition;
use crate::modules::ModuleInfo;
use gtk::gdk::Monitor; use gtk::gdk::Monitor;
use gtk::prelude::*; use gtk::prelude::*;
use gtk::{Application, ApplicationWindow, Button, Orientation}; use gtk::{ApplicationWindow, Button, Orientation};
use tracing::debug;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Popup { pub struct Popup {
pub window: ApplicationWindow, pub window: ApplicationWindow,
pub container: gtk::Box, pub cache: HashMap<usize, gtk::Box>,
monitor: Monitor, monitor: Monitor,
pos: BarPosition,
} }
impl Popup { impl Popup {
pub fn new( /// Creates a new popup window.
name: &str, /// This includes setting up gtk-layer-shell
app: &Application, /// and an empty `gtk::Box` container.
monitor: &Monitor, pub fn new(module_info: &ModuleInfo) -> Self {
orientation: Orientation, let pos = module_info.bar_position;
bar_position: &BarPosition, let orientation = pos.get_orientation();
) -> Self {
let win = ApplicationWindow::builder().application(app).build(); let win = ApplicationWindow::builder()
.application(module_info.app)
.build();
gtk_layer_shell::init_for_window(&win); gtk_layer_shell::init_for_window(&win);
gtk_layer_shell::set_layer(&win, gtk_layer_shell::Layer::Overlay); gtk_layer_shell::set_layer(&win, gtk_layer_shell::Layer::Overlay);
@@ -26,57 +33,69 @@ impl Popup {
gtk_layer_shell::set_margin( gtk_layer_shell::set_margin(
&win, &win,
gtk_layer_shell::Edge::Top, gtk_layer_shell::Edge::Top,
if bar_position == &BarPosition::Top { if pos == BarPosition::Top { 5 } else { 0 },
5
} else {
0
},
); );
gtk_layer_shell::set_margin( gtk_layer_shell::set_margin(
&win, &win,
gtk_layer_shell::Edge::Bottom, gtk_layer_shell::Edge::Bottom,
if bar_position == &BarPosition::Bottom { if pos == BarPosition::Bottom { 5 } else { 0 },
5 );
} else { gtk_layer_shell::set_margin(
0 &win,
}, gtk_layer_shell::Edge::Left,
if pos == BarPosition::Left { 5 } else { 0 },
);
gtk_layer_shell::set_margin(
&win,
gtk_layer_shell::Edge::Right,
if pos == BarPosition::Right { 5 } else { 0 },
); );
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Left, 0);
gtk_layer_shell::set_margin(&win, gtk_layer_shell::Edge::Right, 0);
gtk_layer_shell::set_anchor( gtk_layer_shell::set_anchor(
&win, &win,
gtk_layer_shell::Edge::Top, gtk_layer_shell::Edge::Top,
bar_position == &BarPosition::Top, pos == BarPosition::Top || orientation == Orientation::Vertical,
); );
gtk_layer_shell::set_anchor( gtk_layer_shell::set_anchor(
&win, &win,
gtk_layer_shell::Edge::Bottom, gtk_layer_shell::Edge::Bottom,
bar_position == &BarPosition::Bottom, pos == BarPosition::Bottom,
);
gtk_layer_shell::set_anchor(
&win,
gtk_layer_shell::Edge::Left,
pos == BarPosition::Left || orientation == Orientation::Horizontal,
);
gtk_layer_shell::set_anchor(
&win,
gtk_layer_shell::Edge::Right,
pos == BarPosition::Right,
); );
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Left, true);
gtk_layer_shell::set_anchor(&win, gtk_layer_shell::Edge::Right, false);
let content = gtk::Box::builder() win.connect_leave_notify_event(move |win, ev| {
.orientation(orientation)
.spacing(0)
.hexpand(false)
.name(name)
.build();
content.style_context().add_class("popup");
win.add(&content);
win.connect_leave_notify_event(|win, ev| {
const THRESHOLD: f64 = 3.0; const THRESHOLD: f64 = 3.0;
let (w, _h) = win.size(); let (w, h) = win.size();
let (x, y) = ev.position(); let (x, y) = ev.position();
// some child widgets trigger this event // some child widgets trigger this event
// so check we're actually outside the window // so check we're actually outside the window
if x < THRESHOLD || y < THRESHOLD || x > f64::from(w) - THRESHOLD { let hide = match pos {
BarPosition::Top => {
x < THRESHOLD || y > f64::from(h) - THRESHOLD || x > f64::from(w) - THRESHOLD
}
BarPosition::Bottom => {
x < THRESHOLD || y < THRESHOLD || x > f64::from(w) - THRESHOLD
}
BarPosition::Left => {
y < THRESHOLD || x > f64::from(w) - THRESHOLD || y > f64::from(h) - THRESHOLD
}
BarPosition::Right => {
y < THRESHOLD || x < THRESHOLD || y > f64::from(h) - THRESHOLD
}
};
if hide {
win.hide(); win.hide();
} }
@@ -85,15 +104,37 @@ impl Popup {
Self { Self {
window: win, window: win,
container: content, cache: HashMap::new(),
monitor: monitor.clone(), monitor: module_info.monitor.clone(),
pos,
} }
} }
/// Shows the popover pub fn register_content(&mut self, key: usize, content: gtk::Box) {
pub fn show(&self, button: &Button) { debug!("Registered popup content for #{}", key);
self.cache.insert(key, content);
}
pub fn show_content(&self, key: usize) {
self.clear_window();
if let Some(content) = self.cache.get(&key) {
content.style_context().add_class("popup");
self.window.add(content);
}
}
fn clear_window(&self) {
let children = self.window.children();
for child in children {
self.window.remove(&child);
}
}
/// Shows the popup
pub fn show(&self, geometry: ButtonGeometry) {
self.window.show_all(); self.window.show_all();
self.set_pos(button); self.set_pos(geometry);
} }
/// Hides the popover /// Hides the popover
@@ -101,26 +142,89 @@ impl Popup {
self.window.hide(); self.window.hide();
} }
/// Sets the popover's X position relative to the left border of the screen /// Checks if the popup is currently visible
fn set_pos(&self, button: &Button) { pub fn is_visible(&self) -> bool {
let widget_width = button.allocation().width(); self.window.is_visible()
let screen_width = self.monitor.workarea().width(); }
let popup_width = self.window.allocated_width();
let (widget_x, _) = button /// Sets the popup's X/Y position relative to the left or border of the screen
.translate_coordinates(&button.toplevel().unwrap(), 0, 0) /// (depending on orientation).
.unwrap(); fn set_pos(&self, geometry: ButtonGeometry) {
let orientation = self.pos.get_orientation();
let widget_center = f64::from(widget_x) + f64::from(widget_width) / 2.0; let mon_workarea = self.monitor.workarea();
let screen_size = if orientation == Orientation::Horizontal {
mon_workarea.width()
} else {
mon_workarea.height()
};
let mut offset = (widget_center - (f64::from(popup_width) / 2.0)).round(); let (popup_width, popup_height) = self.window.size();
let popup_size = if orientation == Orientation::Horizontal {
popup_width
} else {
popup_height
};
let widget_center = f64::from(geometry.position) + f64::from(geometry.size) / 2.0;
let bar_offset = (f64::from(screen_size) - f64::from(geometry.bar_size)) / 2.0;
let mut offset = bar_offset + (widget_center - (f64::from(popup_size) / 2.0)).round();
if offset < 5.0 { if offset < 5.0 {
offset = 5.0; offset = 5.0;
} else if offset > f64::from(screen_width - popup_width) - 5.0 { } else if offset > f64::from(screen_size - popup_size) - 5.0 {
offset = f64::from(screen_width - popup_width) - 5.0; offset = f64::from(screen_size - popup_size) - 5.0;
} }
gtk_layer_shell::set_margin(&self.window, gtk_layer_shell::Edge::Left, offset as i32); let edge = if orientation == Orientation::Horizontal {
gtk_layer_shell::Edge::Left
} else {
gtk_layer_shell::Edge::Top
};
gtk_layer_shell::set_margin(&self.window, edge, offset as i32);
}
/// Gets the absolute X position of the button
/// and its width / height (depending on orientation).
pub fn button_pos(button: &Button, orientation: Orientation) -> ButtonGeometry {
let button_size = if orientation == Orientation::Horizontal {
button.allocation().width()
} else {
button.allocation().height()
};
let top_level = button.toplevel().expect("Failed to get top-level widget");
let bar_size = if orientation == Orientation::Horizontal {
top_level.allocation().width()
} else {
top_level.allocation().height()
};
let (button_x, button_y) = button
.translate_coordinates(&top_level, 0, 0)
.unwrap_or((0, 0));
let button_pos = if orientation == Orientation::Horizontal {
button_x
} else {
button_y
};
ButtonGeometry {
position: button_pos,
size: button_size,
bar_size,
}
} }
} }
#[derive(Debug, Copy, Clone)]
pub struct ButtonGeometry {
position: i32,
size: i32,
bar_size: i32,
}

View File

@@ -1,45 +1,66 @@
use color_eyre::{Help, Report};
use glib::Continue; use glib::Continue;
use gtk::prelude::CssProviderExt; use gtk::prelude::CssProviderExt;
use gtk::{gdk, gio, CssProvider, StyleContext}; use gtk::{gdk, gio, CssProvider, StyleContext};
use notify::{DebouncedEvent, RecursiveMode, Watcher}; use notify::{Event, RecursiveMode, Result, Watcher};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::mpsc;
use std::time::Duration;
use tokio::spawn; use tokio::spawn;
use tracing::{error, info};
/// Attempts to load CSS file at the given path
/// and attach if to the current GTK application.
///
/// Installs a file watcher and reloads CSS when
/// write changes are detected on the file.
pub fn load_css(style_path: PathBuf) { pub fn load_css(style_path: PathBuf) {
let provider = CssProvider::new(); let provider = CssProvider::new();
provider
.load_from_file(&gio::File::for_path(&style_path))
.expect("Couldn't load custom style");
StyleContext::add_provider_for_screen(
&gdk::Screen::default().expect("Couldn't get default GDK screen"),
&provider,
800,
);
let (watcher_tx, watcher_rx) = mpsc::channel::<DebouncedEvent>(); if let Err(err) = provider.load_from_file(&gio::File::for_path(&style_path)) {
error!("{:?}", Report::new(err)
.wrap_err("Failed to load CSS")
.suggestion("Check the CSS file for errors")
.suggestion("GTK CSS uses a subset of the full CSS spec and many properties are not available. Ensure you are not using any unsupported property.")
);
}
let screen = gdk::Screen::default().expect("Failed to get default GTK screen");
StyleContext::add_provider_for_screen(&screen, &provider, 800);
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT); let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
spawn(async move { spawn(async move {
let mut watcher = notify::watcher(watcher_tx, Duration::from_millis(500)).unwrap(); match notify::recommended_watcher(move |res: Result<Event>| match res {
watcher Ok(event) => {
.watch(&style_path, RecursiveMode::NonRecursive) if let Some(path) = event.paths.first() {
.unwrap(); tx.send(path.clone())
.expect("Failed to send style changed message");
loop { }
if let Ok(DebouncedEvent::Write(path)) = watcher_rx.recv() {
tx.send(path).unwrap();
} }
Err(e) => error!("Error occurred when watching stylesheet: {:?}", e),
}) {
Ok(mut watcher) => {
watcher
.watch(&style_path, RecursiveMode::NonRecursive)
.expect("Unexpected error when attempting to watch CSS");
}
Err(err) => error!(
"{:?}",
Report::new(err).wrap_err("Failed to start CSS watcher")
),
} }
}); });
{ {
rx.attach(None, move |path| { rx.attach(None, move |path| {
println!("Reloading CSS"); info!("Reloading CSS");
provider if let Err(err) = provider
.load_from_file(&gio::File::for_path(path)) .load_from_file(&gio::File::for_path(path)) {
.expect("Couldn't load custom style"); error!("{:?}", Report::new(err)
.wrap_err("Failed to load CSS")
.suggestion("Check the CSS file for errors")
.suggestion("GTK CSS uses a subset of the full CSS spec and many properties are not available. Ensure you are not using any unsupported property.")
);
}
Continue(true) Continue(true)
}); });

74
src/sway.rs Normal file
View File

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

View File

@@ -1,49 +0,0 @@
use serde::Deserialize;
pub mod node;
#[derive(Deserialize, Debug)]
pub struct WorkspaceEvent {
pub change: String,
pub old: Option<Workspace>,
pub current: Option<Workspace>,
}
#[derive(Deserialize, Debug)]
pub struct Workspace {
pub name: String,
pub focused: bool,
// pub num: i32,
pub output: String,
}
#[derive(Debug, Deserialize)]
pub struct WindowEvent {
pub change: String,
pub container: SwayNode,
}
#[derive(Debug, Deserialize)]
pub struct SwayNode {
#[serde(rename = "type")]
pub node_type: String,
pub id: i32,
pub name: Option<String>,
pub app_id: Option<String>,
pub focused: bool,
pub urgent: bool,
pub nodes: Vec<SwayNode>,
pub floating_nodes: Vec<SwayNode>,
pub shell: Option<String>,
pub window_properties: Option<WindowProperties>,
}
#[derive(Debug, Deserialize)]
pub struct WindowProperties {
pub class: String,
}
#[derive(Deserialize)]
pub struct SwayOutput {
pub name: String,
}

View File

@@ -1,45 +0,0 @@
use crate::sway::SwayNode;
use ksway::{Client, IpcCommand};
impl SwayNode {
pub fn get_id(&self) -> &str {
self.app_id.as_ref().map_or_else(
|| {
&self
.window_properties
.as_ref()
.expect("cannot find node name")
.class
},
|app_id| app_id,
)
}
pub fn is_xwayland(&self) -> bool {
self.shell == Some(String::from("xwayland"))
}
}
fn check_node(node: SwayNode, window_nodes: &mut Vec<SwayNode>) {
if node.name.is_some() && (node.node_type == "con" || node.node_type == "floating_con") {
window_nodes.push(node);
} else {
node.nodes.into_iter().for_each(|node| {
check_node(node, window_nodes);
});
node.floating_nodes.into_iter().for_each(|node| {
check_node(node, window_nodes);
});
}
}
pub fn get_open_windows(sway: &mut Client) -> Vec<SwayNode> {
let raw = sway.ipc(IpcCommand::GetTree).unwrap();
let root_node = serde_json::from_slice::<SwayNode>(&raw).unwrap();
let mut window_nodes = vec![];
check_node(root_node, &mut window_nodes);
window_nodes
}

125
src/wayland/client.rs Normal file
View File

@@ -0,0 +1,125 @@
use super::{Env, ToplevelHandler};
use crate::collection::Collection;
use crate::wayland::toplevel::{ToplevelEvent, ToplevelInfo};
use crate::wayland::toplevel_manager::listen_for_toplevels;
use crate::wayland::ToplevelChange;
use smithay_client_toolkit::environment::Environment;
use smithay_client_toolkit::output::{with_output_info, OutputInfo};
use smithay_client_toolkit::reexports::calloop;
use smithay_client_toolkit::{new_default_environment, WaylandSource};
use std::sync::{Arc, RwLock};
use std::time::Duration;
use tokio::sync::{broadcast, oneshot};
use tokio::task::spawn_blocking;
use tracing::trace;
use wayland_client::protocol::wl_seat::WlSeat;
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
};
pub struct WaylandClient {
pub outputs: Vec<OutputInfo>,
pub seats: Vec<WlSeat>,
pub toplevels: Arc<RwLock<Collection<usize, (ToplevelInfo, ZwlrForeignToplevelHandleV1)>>>,
toplevel_tx: broadcast::Sender<ToplevelEvent>,
_toplevel_rx: broadcast::Receiver<ToplevelEvent>,
}
impl WaylandClient {
pub(super) async fn new() -> Self {
let (output_tx, output_rx) = oneshot::channel();
let (seat_tx, seat_rx) = oneshot::channel();
let (toplevel_tx, toplevel_rx) = broadcast::channel(32);
let toplevel_tx2 = toplevel_tx.clone();
let toplevels = Arc::new(RwLock::new(Collection::new()));
let toplevels2 = toplevels.clone();
// `queue` is not send so we need to handle everything inside the task
spawn_blocking(move || {
let (env, _display, queue) =
new_default_environment!(Env, fields = [toplevel: ToplevelHandler::init()])
.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");
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");
let _toplevel_manager = env.require_global::<ZwlrForeignToplevelManagerV1>();
let _listener = listen_for_toplevels(env, move |handle, event, _ddata| {
trace!("Received toplevel event: {:?}", event);
if event.change == ToplevelChange::Close {
toplevels2
.write()
.expect("Failed to get write lock on toplevels")
.remove(&event.toplevel.id);
} else {
toplevels2
.write()
.expect("Failed to get write lock on toplevels")
.insert(event.toplevel.id, (event.toplevel.clone(), handle));
}
toplevel_tx2
.send(event)
.expect("Failed to send toplevel event");
});
let mut event_loop =
calloop::EventLoop::<()>::try_new().expect("Failed to create new event loop");
WaylandSource::new(queue)
.quick_insert(event_loop.handle())
.expect("Failed to insert event loop into wayland event queue");
loop {
// TODO: Avoid need for duration here - can we force some event when sending requests?
event_loop
.dispatch(Duration::from_millis(50), &mut ())
.expect("Failed to dispatch pending wayland events");
}
});
let outputs = output_rx
.await
.expect("Failed to receive outputs from task");
let seats = seat_rx.await.expect("Failed to receive seats from task");
Self {
outputs,
seats,
toplevels,
toplevel_tx,
_toplevel_rx: toplevel_rx,
}
}
pub fn subscribe_toplevels(&self) -> broadcast::Receiver<ToplevelEvent> {
self.toplevel_tx.subscribe()
}
fn get_outputs(env: &Environment<Env>) -> Vec<OutputInfo> {
let outputs = env.get_all_outputs();
outputs
.iter()
.filter_map(|output| with_output_info(output, Clone::clone))
.collect()
}
}

54
src/wayland/mod.rs Normal file
View File

@@ -0,0 +1,54 @@
mod client;
mod toplevel;
mod toplevel_manager;
extern crate smithay_client_toolkit as sctk;
use self::toplevel_manager::ToplevelHandler;
pub use crate::wayland::toplevel::{ToplevelChange, ToplevelEvent, ToplevelInfo};
use crate::wayland::toplevel_manager::{ToplevelHandling, ToplevelStatusListener};
use async_once::AsyncOnce;
use lazy_static::lazy_static;
use wayland_client::{Attached, DispatchData, Interface};
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
};
pub use client::WaylandClient;
/// A utility for lazy-loading globals.
/// Taken from `smithay_client_toolkit` where it's not exposed
#[derive(Debug)]
enum LazyGlobal<I: Interface> {
Unknown,
Seen { id: u32, version: u32 },
Bound(Attached<I>),
}
sctk::default_environment!(Env,
fields = [
toplevel: ToplevelHandler
],
singles = [
ZwlrForeignToplevelManagerV1 => toplevel
],
);
impl ToplevelHandling for Env {
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
where
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
{
self.toplevel.listen(f)
}
}
lazy_static! {
static ref CLIENT: AsyncOnce<WaylandClient> =
AsyncOnce::new(async { WaylandClient::new().await });
}
pub async fn get_client() -> &'static WaylandClient {
CLIENT.get().await
}

151
src/wayland/toplevel.rs Normal file
View File

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

View File

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