Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1e1743b5e | ||
|
|
37458642df | ||
|
|
862c46c7ec | ||
|
|
afedf0214d | ||
|
|
64f54040ef | ||
|
|
d20972cb32 | ||
|
|
1320639d4e | ||
|
|
907a565f3d | ||
|
|
ec69649a04 | ||
|
|
c4cdf4be8b | ||
|
|
00f973c3a4 | ||
|
|
5d153a02fc | ||
|
|
e274ba39cd | ||
|
|
8c75bc46ac | ||
|
|
df77020c52 | ||
|
|
0fb5fa8c2a | ||
|
|
cf87bb4e8d | ||
|
|
badfcc0c2d | ||
|
|
c9e66d4664 | ||
|
|
a3f90adaf1 | ||
|
|
47420d83bf | ||
|
|
4662f60ac5 | ||
|
|
94693c92e3 | ||
|
|
8c774100f1 | ||
|
|
b4db0226cd | ||
|
|
ff17ec1996 | ||
|
|
c48029664d | ||
|
|
58d55db660 | ||
|
|
73158c2fce | ||
|
|
1c032ae8e3 | ||
|
|
3b04642148 | ||
|
|
0a331f3138 | ||
|
|
bc625b929b | ||
|
|
ad77dc4e4c | ||
|
|
3a83bd31ab | ||
|
|
5ebc84c7b9 | ||
|
|
51d1cd4a16 | ||
|
|
b7792a415e | ||
|
|
9f82ba58cd | ||
|
|
a93700e8fd | ||
|
|
2a3fe33446 | ||
|
|
3750124d8c | ||
|
|
e693c1c166 | ||
|
|
cbd0c49e25 | ||
|
|
e23e691bc6 | ||
|
|
be0f4c6366 | ||
|
|
493df6bb49 | ||
|
|
b4ac1c9850 | ||
|
|
27f6abad67 | ||
|
|
ec1d59677b | ||
|
|
70e1b526a9 | ||
|
|
3c43c20c6a | ||
|
|
b66bd788b2 | ||
|
|
f17ae7a415 | ||
|
|
a06c4bccca | ||
|
|
e4e72d8008 | ||
|
|
9e6dbbd131 | ||
|
|
91c57edc73 | ||
|
|
dec402edd9 | ||
|
|
fad90fdad6 | ||
|
|
35ce3b4d45 | ||
|
|
27d04795af | ||
|
|
9d9c275313 | ||
|
|
0669504519 | ||
|
|
eb5170ff6a | ||
|
|
b7b64886e3 | ||
|
|
75339f07ed | ||
|
|
06cfad62e2 | ||
|
|
1b853bcb71 | ||
|
|
bd5bdf5af5 | ||
|
|
8536ad719a | ||
|
|
006c242f49 | ||
|
|
2cd59ef5ff | ||
|
|
f411b7c451 | ||
|
|
1dd0a9e52f | ||
|
|
5523e9af46 | ||
|
|
9e31107251 | ||
|
|
668fe4a308 | ||
|
|
994d0f580b | ||
|
|
5ce50b0987 | ||
|
|
b1c66b9117 | ||
|
|
bb4fe7f7f5 | ||
|
|
324f00cdf9 | ||
|
|
b188bc7146 | ||
|
|
d22d954e83 | ||
|
|
45e44d7913 | ||
|
|
b352181b3d | ||
|
|
720ba7bfb0 | ||
|
|
daafa0943e |
43
.github/scripts/sync-wiki.sh
vendored
Executable file
43
.github/scripts/sync-wiki.sh
vendored
Executable file
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
TEMP_REPO_DIR="wiki_action_$GITHUB_REPOSITORY$GITHUB_SHA"
|
||||
TEMP_WIKI_DIR="temp_wiki_$GITHUB_SHA"
|
||||
|
||||
WIKI_DIR='docs'
|
||||
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "Token is not specified"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
#Clone repo
|
||||
echo "Cloning repo https://github.com/$GITHUB_REPOSITORY"
|
||||
git clone "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY" "$TEMP_REPO_DIR"
|
||||
|
||||
#Clone wiki repo
|
||||
echo "Cloning wiki repo https://github.com/$GITHUB_REPOSITORY.wiki.git"
|
||||
cd "$TEMP_REPO_DIR"
|
||||
git clone "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git" "$TEMP_WIKI_DIR"
|
||||
|
||||
#Get commit details
|
||||
author='Jake Stanger'
|
||||
email='mail@jstanger.dev'
|
||||
message='action: sync wiki'
|
||||
|
||||
echo "Copying edited wiki"
|
||||
cp -R "$TEMP_WIKI_DIR/.git" "$WIKI_DIR/"
|
||||
|
||||
echo "Checking if wiki has changes"
|
||||
cd "$WIKI_DIR"
|
||||
git config --local user.email "$email"
|
||||
git config --local user.name "$author"
|
||||
git add .
|
||||
|
||||
if git diff-index --quiet HEAD; then
|
||||
echo "Nothing changed"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Pushing changes to wiki"
|
||||
git commit -m "$message" && git push "https://$GITHUB_ACTOR:$GH_TOKEN@github.com/$GITHUB_REPOSITORY.wiki.git"
|
||||
36
.github/workflows/build.yml
vendored
36
.github/workflows/build.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
@@ -24,8 +25,8 @@ jobs:
|
||||
- name: Install build deps
|
||||
run: sudo apt install libgtk-3-dev libgtk-layer-shell-dev
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Clippy
|
||||
uses: actions-rs/clippy-check@v1
|
||||
@@ -33,5 +34,32 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --all-features
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
|
||||
- name: Run tests
|
||||
uses: actions-rs/cargo@v1
|
||||
with:
|
||||
command: test
|
||||
|
||||
|
||||
|
||||
build-nix:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- uses: cachix/install-nix-action@v17
|
||||
with:
|
||||
install_url: https://nixos.org/nix/install
|
||||
extra_nix_config: |
|
||||
auto-optimise-store = true
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- uses: cachix/cachix-action@v11
|
||||
with:
|
||||
name: jakestanger
|
||||
signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'
|
||||
|
||||
- run: nix build --print-build-logs
|
||||
1
.github/workflows/deploy.yml
vendored
1
.github/workflows/deploy.yml
vendored
@@ -26,6 +26,7 @@ jobs:
|
||||
with:
|
||||
token: ${{ github.token }}
|
||||
tag: ${{ github.ref_name }}
|
||||
excludeTypes: 'build,chore,style'
|
||||
|
||||
- name: Create release
|
||||
uses: ncipollo/release-action@v1
|
||||
|
||||
27
.github/workflows/update-nix-flake-lock.yml
vendored
Normal file
27
.github/workflows/update-nix-flake-lock.yml
vendored
Normal file
@@ -0,0 +1,27 @@
|
||||
name: update-nix-flake-lock
|
||||
on:
|
||||
workflow_dispatch: # allows manual triggering
|
||||
schedule:
|
||||
- cron: '0 0 1 * *' # first day of every month
|
||||
|
||||
jobs:
|
||||
update-nix-flake-lock:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v16
|
||||
with:
|
||||
extra_nix_config: |
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update flake.lock
|
||||
uses: DeterminateSystems/update-flake-lock@vX
|
||||
with:
|
||||
pr-title: "Update flake.lock" # Title of PR to be created
|
||||
pr-labels: | # Labels to be set on the PR
|
||||
dependencies
|
||||
automated
|
||||
17
.github/workflows/wiki.yml
vendored
Normal file
17
.github/workflows/wiki.yml
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
name: Sync Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Sync Wiki
|
||||
run: ./.github/scripts/sync-wiki.sh
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
2
.idea/runConfigurations/Run.xml
generated
2
.idea/runConfigurations/Run.xml
generated
@@ -12,7 +12,7 @@
|
||||
<envs>
|
||||
<env name="IRONBAR_CONFIG" value="examples/config.json" />
|
||||
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
|
||||
<env name="RUST_LOG" value="trace" />
|
||||
<env name="RUST_LOG" value="debug" />
|
||||
</envs>
|
||||
<option name="isRedirectInput" value="false" />
|
||||
<option name="redirectInputPath" value="" />
|
||||
|
||||
2
.idea/runConfigurations/Run__GTK_Debug_.xml
generated
2
.idea/runConfigurations/Run__GTK_Debug_.xml
generated
@@ -12,6 +12,8 @@
|
||||
<envs>
|
||||
<env name="GTK_DEBUG" value="interactive" />
|
||||
<env name="IRONBAR_CONFIG" value="examples/config.json" />
|
||||
<env name="RUST_LOG" value="debug" />
|
||||
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
|
||||
</envs>
|
||||
<option name="isRedirectInput" value="false" />
|
||||
<option name="redirectInputPath" value="" />
|
||||
|
||||
2
.idea/runConfigurations/Run__Live_Config_.xml
generated
2
.idea/runConfigurations/Run__Live_Config_.xml
generated
@@ -11,7 +11,7 @@
|
||||
<option name="backtrace" value="SHORT" />
|
||||
<envs>
|
||||
<env name="PATH" value="/usr/local/bin:/usr/bin:$USER_HOME$/.local/share/npm/bin" />
|
||||
<env name="RUST_LOG" value="trace" />
|
||||
<env name="RUST_LOG" value="debug" />
|
||||
</envs>
|
||||
<option name="isRedirectInput" value="false" />
|
||||
<option name="redirectInputPath" value="" />
|
||||
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -4,6 +4,74 @@ 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.7.0] - 2022-11-05
|
||||
### :sparkles: New Features
|
||||
- [`fad90fd`](https://github.com/JakeStanger/ironbar/commit/fad90fdad683a612497ac7822a66a90f43fce0a2) - **sys-info**: add loads more formatting tokens *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`dec402e`](https://github.com/JakeStanger/ironbar/commit/dec402edd9d6c5b8677ff337699ad99ebc69b776) - **sys-info**: config options for refresh intervals *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`91c57ed`](https://github.com/JakeStanger/ironbar/commit/91c57edc73f15397ea0de70c4a6a6532c35caf2a) - **sys-info**: pango markup support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`ec1d596`](https://github.com/JakeStanger/ironbar/commit/ec1d59677b13c9654a98d78f909ba2d0fcfbb72d) - **logging**: `IRONBAR_LOG` and `IRONBAR_FILE_LOG` env vars *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`493df6b`](https://github.com/JakeStanger/ironbar/commit/493df6bb49fec8c465706d3f9b395728ba73a621) - **mpd**: add volume slider to popup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`3750124`](https://github.com/JakeStanger/ironbar/commit/3750124d8cfb4783932a6b3359384f245fcd2394) - new custom module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b7792a4`](https://github.com/JakeStanger/ironbar/commit/b7792a415e09fc535750ea5af530f91aa791c4bc) - env var to set custom css location *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`ad77dc4`](https://github.com/JakeStanger/ironbar/commit/ad77dc4e4c2f80fcb4c9604c796be0f981e895ee) - improved logging & error handling *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :bug: Bug Fixes
|
||||
- [`9e6dbbd`](https://github.com/JakeStanger/ironbar/commit/9e6dbbd131a09f101b0d490265fe7d4ec564e38c) - **sys-info**: tokens not replaced if more than one in string *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`f17ae7a`](https://github.com/JakeStanger/ironbar/commit/f17ae7a415b931c64942de085e8889f37b3f9b11) - **script**: not parsing pango markup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b66bd78`](https://github.com/JakeStanger/ironbar/commit/b66bd788b23256a2127a1352693fdd3f929d9c4b) - logging for creating bar incorrect still *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`3c43c20`](https://github.com/JakeStanger/ironbar/commit/3c43c20c6ae53a9aa6b67770b0c489806784f4ac) - weird behaviour when config does not exist *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`70e1b52`](https://github.com/JakeStanger/ironbar/commit/70e1b526a9681b16545d7f05d77470d76bd8819e) - **logging**: file log not capturing panics *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`cbd0c49`](https://github.com/JakeStanger/ironbar/commit/cbd0c49e251b5c8e0289ca6200a393d89994992d) - css watcher not working *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`e693c1c`](https://github.com/JakeStanger/ironbar/commit/e693c1c166eef0b5edcdcd033bb12d572e4e5f04) - **mpd**: volume slider causing mpd server errors *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`3a83bd3`](https://github.com/JakeStanger/ironbar/commit/3a83bd31ab165869f7f274b054b2f16485261fd1) - able to insert duplicate keys into collection *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`5ebc84c`](https://github.com/JakeStanger/ironbar/commit/5ebc84c7b98cc648a659ca37fdc0f041057f0ea4) - **logging**: consts for default log levels *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`bc625b9`](https://github.com/JakeStanger/ironbar/commit/bc625b929b8644ce92f275b5d98cdf74b93fe067) - clippy & fmt *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`9d9c275`](https://github.com/JakeStanger/ironbar/commit/9d9c2753137331ae85ac8ab7d75a6de9a9c82042) - update CHANGELOG.md for v0.6.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`27d0479`](https://github.com/JakeStanger/ironbar/commit/27d04795af1c25fe5f765c7480d5dd5d096a8ab7) - **readme**: add warning about crate being outdated *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`a06c4bc`](https://github.com/JakeStanger/ironbar/commit/a06c4bccca6cb51935605ac9239e63024fb7c663) - **examples**: add full system info config *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`0a331f3`](https://github.com/JakeStanger/ironbar/commit/0a331f31381f0d967793c0d8b7a14e2a43bf666f) - **readme**: remove warning about outdated cargo package *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.6.0] - 2022-10-15
|
||||
### :sparkles: New Features
|
||||
- [`b188bc7`](https://github.com/JakeStanger/ironbar/commit/b188bc714614406935d8bb88a719adab2dfce32f) - initial support for running outside sway *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`324f00c`](https://github.com/JakeStanger/ironbar/commit/324f00cdf9200e3e3ecedfa68ab4c99b170242e2) - wlroots-agnostic support for `focused` module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b1c66b9`](https://github.com/JakeStanger/ironbar/commit/b1c66b9117cf8a10350cdb857a5267a1a72ad914) - wlroots-agnostic support for `launcher` module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`1dd0a9e`](https://github.com/JakeStanger/ironbar/commit/1dd0a9e52f69e672d9ac313c1da0e201c911e6c2) - **launcher**: add popup css selectors *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`06cfad6`](https://github.com/JakeStanger/ironbar/commit/06cfad62e228f7fc63938f2280206450005cb064) - more positioning options *(PR [#23](https://github.com/JakeStanger/ironbar/pull/23) by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :bug: Bug Fixes
|
||||
- [`5523e9a`](https://github.com/JakeStanger/ironbar/commit/5523e9af46e457f9d45902debaaacf26b586e457) - **popup**: often opening in wrong place *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`8536ad7`](https://github.com/JakeStanger/ironbar/commit/8536ad719a92aec4166e35b75cb029075ad3ae34) - **mpd**: incorrectly checking for unix sockets *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`bd5bdf5`](https://github.com/JakeStanger/ironbar/commit/bd5bdf5af548304958663d593fccb454afa6c8ff) - logging for creating bar incorrect *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`75339f0`](https://github.com/JakeStanger/ironbar/commit/75339f07ed164fa94838036a604a1dcb6d53564c) - vertical bars ignoring height config option *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b7b6488`](https://github.com/JakeStanger/ironbar/commit/b7b64886e3c48ace3faffbb1e277275aeeac3adf) - sometimes panicking on startup *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`5ce50b0`](https://github.com/JakeStanger/ironbar/commit/5ce50b0987812a1ade2d1262e8d7df6916cfc39a) - tidy and format *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`1b853bc`](https://github.com/JakeStanger/ironbar/commit/1b853bcb71197a4bf3ca75725cc010b1d404c2b3) - fix clippy warning *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`daafa09`](https://github.com/JakeStanger/ironbar/commit/daafa0943e5b9886b09fd18d6fff04558fb02335) - update CHANGELOG.md for v0.5.2 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b352181`](https://github.com/JakeStanger/ironbar/commit/b352181b3d232ccc79ffc1d9e22a633729d01a47) - update json example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`bb4fe7f`](https://github.com/JakeStanger/ironbar/commit/bb4fe7f7f58fa2a6d0a2259bd9442700d2c884f7) - **readme**: credit smithay client toolkit *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`994d0f5`](https://github.com/JakeStanger/ironbar/commit/994d0f580b4d1b6ff750839652a7f06149743172) - **readme**: update references to sway *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :boom: BREAKING CHANGES
|
||||
- due to [`06cfad6`](https://github.com/JakeStanger/ironbar/commit/06cfad62e228f7fc63938f2280206450005cb064) - more positioning options *(PR [#23](https://github.com/JakeStanger/ironbar/pull/23) by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
The `left` and `right` config options have been renamed to `start` and `end`
|
||||
|
||||
|
||||
## [v0.5.2] - 2022-09-07
|
||||
### :wrench: Chores
|
||||
- [`b801751`](https://github.com/JakeStanger/ironbar/commit/b801751bdabd8416084f46e6b6d803ea28a259ec) - **release**: v0.5.2 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.5.1] - 2022-09-06
|
||||
### :bug: Bug Fixes
|
||||
- [`b81927e`](https://github.com/JakeStanger/ironbar/commit/b81927e3a57808188e31419695a36aa4ea3f2830) - **launcher**: opening new instances when focused/urgent *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
@@ -39,4 +107,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
[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.1]: https://github.com/JakeStanger/ironbar/compare/v0.5.0...v0.5.1
|
||||
[v0.5.2]: https://github.com/JakeStanger/ironbar/compare/v0.5.1...v0.5.2
|
||||
[v0.6.0]: https://github.com/JakeStanger/ironbar/compare/v0.5.2...v0.6.0
|
||||
[v0.7.0]: https://github.com/JakeStanger/ironbar/compare/v0.6.0...v0.7.0
|
||||
@@ -14,3 +14,4 @@ I welcome contributions of any kind with open arms. That said, please do stick t
|
||||
|
||||
- For issues:
|
||||
- Please provide as much information as you can - share your config, any logs, steps to reproduce...
|
||||
- If reporting an error, please ensure you use `IRONBAR_LOG` or `IRONBAR_FILE_LOG` set to `debug`.
|
||||
|
||||
1517
Cargo.lock
generated
1517
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
42
Cargo.toml
42
Cargo.toml
@@ -1,38 +1,40 @@
|
||||
[package]
|
||||
name = "ironbar"
|
||||
version = "0.5.2"
|
||||
version = "0.8.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Customisable wlroots/sway bar"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
description = "Customisable GTK Layer Shell wlroots/sway bar"
|
||||
|
||||
[dependencies]
|
||||
gtk = "0.15.5"
|
||||
gtk-layer-shell = "0.4.1"
|
||||
glib = "0.15.12"
|
||||
tokio = { version = "1.20.1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
tracing = "0.1.36"
|
||||
tracing-subscriber = { version = "0.3.15", features = ["env-filter"] }
|
||||
derive_builder = "0.11.2"
|
||||
gtk = "0.16.0"
|
||||
gtk-layer-shell = "0.5.0"
|
||||
glib = "0.16.2"
|
||||
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time", "process"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||
tracing-error = "0.2.0"
|
||||
tracing-appender = "0.2.2"
|
||||
strip-ansi-escapes = "0.1.1"
|
||||
color-eyre = "0.6.2"
|
||||
futures-util = "0.3.21"
|
||||
chrono = "0.4.19"
|
||||
serde = { version = "1.0.141", features = ["derive"] }
|
||||
serde_json = "1.0.82"
|
||||
serde_yaml = "0.9.4"
|
||||
toml = "0.5.9"
|
||||
cornfig = "0.3.0"
|
||||
libcorn = "0.6.0"
|
||||
lazy_static = "1.4.0"
|
||||
regex = "1.6.0"
|
||||
stray = "0.1.1"
|
||||
async_once = "0.2.6"
|
||||
indexmap = "1.9.1"
|
||||
futures-util = "0.3.21"
|
||||
chrono = "0.4.19"
|
||||
regex = { version = "1.6.0", default-features = false, features = ["std"] }
|
||||
stray = { version = "0.1.2" }
|
||||
dirs = "4.0.0"
|
||||
walkdir = "2.3.2"
|
||||
notify = "5.0.0"
|
||||
notify = { version = "5.0.0", default-features = false }
|
||||
mpd_client = "1.0.0"
|
||||
ksway = "0.1.0"
|
||||
sysinfo = "0.26.2"
|
||||
# required for wrapping ksway
|
||||
crossbeam-channel = "0.5.6"
|
||||
swayipc-async = { version = "2.0.1" }
|
||||
sysinfo = "0.26.4"
|
||||
wayland-client = "0.29.5"
|
||||
wayland-protocols = { version = "0.29.5", features = ["unstable_protocols", "client"] }
|
||||
smithay-client-toolkit = { version = "0.16.0", default-features = false, features = ["calloop"] }
|
||||
75
README.md
75
README.md
@@ -1,16 +1,15 @@
|
||||
# Ironbar
|
||||
|
||||
Ironbar is a customisable and feature-rich bar targeting the Sway compositor, written in Rust.
|
||||
Ironbar is a customisable and feature-rich bar for wlroots compositors, written in Rust.
|
||||
It uses GTK3 and gtk-layer-shell.
|
||||
|
||||
The bar can be styled to your liking using CSS and hot-loads style changes.
|
||||
The bar can be styled to your liking using CSS and hot-loads style changes.
|
||||
For information and examples on styling please see the [wiki](https://github.com/JakeStanger/ironbar/wiki).
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
Run using `ironbar`.
|
||||
## Installation
|
||||
|
||||
### Cargo
|
||||
|
||||
@@ -28,6 +27,49 @@ yay -S ironbar-git
|
||||
|
||||
[aur package](https://aur.archlinux.org/packages/ironbar-git)
|
||||
|
||||
### Nix Flake
|
||||
|
||||
#### Example
|
||||
Here is an example nix flake that uses ironbar, this is just a
|
||||
proof of concept, please adapt it to your config
|
||||
|
||||
```nix
|
||||
{
|
||||
# Add the ironbar flake input
|
||||
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
inputs.ironbar = {
|
||||
url = "github:JakeStanger/ironbar";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
inputs.hm = {
|
||||
url = "github:nix-community/home-manager";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
|
||||
outputs = inputs: {
|
||||
homeManagerConfigurations."USER@HOSTNAME" = inputs.hm.lib.homeManagerConfiguration {
|
||||
pkgs = nixpkgs.legacyPackages.x86_64-linux;
|
||||
modules = [
|
||||
# And add the home-manager module
|
||||
inputs.ironbar.homeManagerModules.default
|
||||
{
|
||||
# And configure
|
||||
programs.ironbar = {
|
||||
enable = true;
|
||||
config = {};
|
||||
style = "";
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Binary Caching
|
||||
There is also a cachix cache at `https://app.cachix.org/cache/jakestanger`
|
||||
incase you don't want to compile ironbar!
|
||||
|
||||
### Source
|
||||
|
||||
```sh
|
||||
@@ -40,31 +82,41 @@ install target/release/ironbar ~/.local/bin/ironbar
|
||||
|
||||
[repo](https://github.com/jakestanger/ironbar)
|
||||
|
||||
## Running
|
||||
|
||||
All of the above installation methods provide a binary called `ironbar`.
|
||||
|
||||
You can set the `IRONBAR_LOG` or `IRONBAR_FILE_LOG` environment variables to
|
||||
`error`, `warn`, `info`, `debug` or `trace` to configure the log output level.
|
||||
These default to `IRONBAR_LOG=info` and `IRONBAR_FILE_LOG=error`.
|
||||
File output can be found at `~/.local/share/ironbar/error.log`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Ironbar gives a lot of flexibility when configuring, including multiple file formats
|
||||
and options for scaling complexity: you can use a single config across all monitors,
|
||||
or configure different/multiple bars per monitor.
|
||||
Ironbar gives a lot of flexibility when configuring, including multiple file formats
|
||||
and options for scaling complexity: you can use a single config across all monitors,
|
||||
or configure different/multiple bars per monitor.
|
||||
|
||||
A full configuration guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/configuration-guide).
|
||||
|
||||
## Styling
|
||||
|
||||
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the file.
|
||||
To get started, create a stylesheet at `.config/ironbar/style.css`. Changes will be hot-reloaded every time you save the
|
||||
file.
|
||||
|
||||
A full styling guide can be found [here](https://github.com/JakeStanger/ironbar/wiki/styling-guide).
|
||||
|
||||
## Project Status
|
||||
|
||||
This project is in alpha, but should be usable.
|
||||
Everything that is implemented works and should be documented.
|
||||
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.
|
||||
|
||||
There is currently room for lots more modules, and lots more configuration options for the existing modules.
|
||||
The current configuration schema is not set in stone and breaking changes could come along at any point;
|
||||
until the project matures I am more interested in ease of use than backwards compatibility.
|
||||
|
||||
A few bugs do exist, and I am sure there are plenty more to be found.
|
||||
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.
|
||||
@@ -77,3 +129,4 @@ Please check [here](https://github.com/JakeStanger/ironbar/blob/master/CONTRIBUT
|
||||
|
||||
- [Waybar](https://github.com/Alexays/Waybar) - A lot of the initial inspiration, and a pretty great bar.
|
||||
- [Rustbar](https://github.com/zeroeightysix/rustbar) - Served as a good demo for writing a basic GTK bar in Rust
|
||||
- [Smithay Client Toolkit](https://github.com/Smithay/client-toolkit) - Essential in being able to communicate to Wayland
|
||||
|
||||
288
docs/Configuration guide.md
Normal file
288
docs/Configuration guide.md
Normal file
@@ -0,0 +1,288 @@
|
||||
By default, you get a single bar at the bottom of all your screens.
|
||||
To change that, you'll unsurprisingly need a config file.
|
||||
|
||||
This page details putting together the skeleton for your config to get you to a stage where you can start configuring modules.
|
||||
It may look long and overwhelming, but that is just because the bar supports a lot of scenarios!
|
||||
|
||||
If you want to see some ready-to-go config files check the [examples folder](https://github.com/JakeStanger/ironbar/tree/master/examples)
|
||||
and the example pages in the sidebar.
|
||||
|
||||
## 1. Create config file
|
||||
|
||||
The config file lives inside the `ironbar` directory in your XDG_CONFIG_DIR, which is usually `~/.config/ironbar`.
|
||||
|
||||
Ironbar supports a range of configuration formats, so you can pick your favourite:
|
||||
|
||||
- `config.json`
|
||||
- `config.toml`
|
||||
- `config.yaml`
|
||||
- `config.corn` (Experimental, includes variable support for re-using blocks.
|
||||
See [here](https://github.com/jakestanger/corn) for info)
|
||||
|
||||
You can also override the default config path using the `IRONBAR_CONFIG` environment variable.
|
||||
|
||||
## 2. Pick your use-case
|
||||
|
||||
Ironbar gives you a few ways to configure the bar to suit your needs.
|
||||
This allows you to keep your config simple and relatively flat if your use-case is simple,
|
||||
and make it more complex if required.
|
||||
|
||||
### a) I want the same bar across all monitors
|
||||
|
||||
Place the bar config inside the top-level object. This is automatically applied to each of your monitors.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"position": "bottom",
|
||||
"height": 42,
|
||||
"start": [],
|
||||
"center": [],
|
||||
"end": []
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
position = "bottom"
|
||||
height = 42
|
||||
start = []
|
||||
center = []
|
||||
end = []
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
position: "bottom"
|
||||
height: 42
|
||||
start: [ ]
|
||||
center: [ ]
|
||||
end: [ ]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```
|
||||
{
|
||||
position = "bottom"
|
||||
height = 42
|
||||
start = []
|
||||
center = []
|
||||
end = []
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### b) I want my config to differ across one or more monitors
|
||||
|
||||
Create a map/object called `monitors` inside the top-level object.
|
||||
Each of the map's keys should be an output name,
|
||||
and each value should be an object containing the bar config.
|
||||
|
||||
To find your output names, run `wayland-info | grep wl_output -A1`.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"monitors": {
|
||||
"DP-1": {
|
||||
"start": []
|
||||
},
|
||||
"DP-2": {
|
||||
"position": "bottom",
|
||||
"height": 30,
|
||||
"start": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[monitors]
|
||||
|
||||
[monitors.DP-1]
|
||||
start = []
|
||||
|
||||
[monitors.DP-2]
|
||||
position = "bottom"
|
||||
height = 30
|
||||
start = []
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
monitors:
|
||||
DP-1:
|
||||
start: [ ]
|
||||
DP-2:
|
||||
position: "bottom"
|
||||
height: 30
|
||||
start: [ ]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```
|
||||
{
|
||||
monitors.DP-1.start = []
|
||||
monitors.DP-2 = {
|
||||
position = "bottom"
|
||||
height = 30
|
||||
start = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### c) I want one or more monitors to have multiple bars
|
||||
|
||||
Create a map/object called `monitors` inside the top-level object.
|
||||
Each of the map's keys should be an output name.
|
||||
If you want the screen to have multiple bars, use an array of bar config objects.
|
||||
If you want the screen to have a single bar, use an object.
|
||||
|
||||
To find your output names, run `wayland-info | grep wl_output -A1`.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"monitors": {
|
||||
"DP-1": [
|
||||
{
|
||||
"start": []
|
||||
},
|
||||
{
|
||||
"position": "top",
|
||||
"start": []
|
||||
}
|
||||
],
|
||||
"DP-2": {
|
||||
"position": "bottom",
|
||||
"height": 30,
|
||||
"start": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[monitors]
|
||||
|
||||
[[monitors.DP-1]]
|
||||
start = []
|
||||
|
||||
[[monitors.DP-2]]
|
||||
position = "top"
|
||||
start = []
|
||||
|
||||
[monitors.DP-2]
|
||||
position = "bottom"
|
||||
height = 30
|
||||
start = []
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
monitors:
|
||||
DP-1:
|
||||
- start: [ ]
|
||||
- position: "top"
|
||||
start: [ ]
|
||||
DP-2:
|
||||
position: "bottom"
|
||||
height: 30
|
||||
start: [ ]
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```
|
||||
{
|
||||
monitors.DP-1 = [
|
||||
{ start = [] }
|
||||
{ position = "top" start = [] }
|
||||
]
|
||||
monitors.DP-2 = {
|
||||
position = "bottom"
|
||||
height = 30
|
||||
start = []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## 3. Write your bar config(s)
|
||||
|
||||
Once you have the basic config structure set up, it's time to actually configure your bar(s).
|
||||
|
||||
Check [here](config) for an example config file for a fully configured bar in each format.
|
||||
|
||||
### 3.1 Top-level options
|
||||
|
||||
The following table lists each of the top-level bar config options:
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------------------|----------------------------------------|----------|-----------------------------------------------------------------------------------------|
|
||||
| `position` | `top` or `bottom` or `left` or `right` | `bottom` | The bar's position on screen. |
|
||||
| `anchor_to_edges` | `boolean` | `false` | Whether to anchor the bar to the edges of the screen. Setting to false centres the bar. |
|
||||
| `height` | `integer` | `42` | The bar's height in pixels. |
|
||||
| `start` | `Module[]` | `[]` | Array of left or top modules. |
|
||||
| `center` | `Module[]` | `[]` | Array of center modules. |
|
||||
| `end` | `Module[]` | `[]` | Array of right or bottom modules. |
|
||||
|
||||
### 3.2 Module-level options
|
||||
|
||||
The following table lists each of the module-level options that are present on **all** modules.
|
||||
For details on available modules and each of their config options, check the sidebar.
|
||||
|
||||
For information on the `Script` type, and embedding scripts in strings, see [here](script).
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `show_if` | `Script [polling]` | `null` | Polls the script to check its exit code. If exit code is zero, the module is shown. For other codes, it is hidden. |
|
||||
| `on_click` | `Script [polling]` | `null` | Runs the script when the module is clicked. |
|
||||
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |
|
||||
4
docs/Home.md
Normal file
4
docs/Home.md
Normal file
@@ -0,0 +1,4 @@
|
||||
Welcome to the Ironbar wiki.
|
||||
|
||||
Detail about each module, and their configuration and styling options can be found on the sidebar.
|
||||
You can also find an example configuration and stylesheet there.
|
||||
102
docs/Scripts.md
Normal file
102
docs/Scripts.md
Normal file
@@ -0,0 +1,102 @@
|
||||
There are various places inside the configuration (other than the `script` module)
|
||||
that allow script input to dynamically set values.
|
||||
|
||||
Scripts are passed to `sh -c`.
|
||||
|
||||
Two types of scripts exist: polling and watching:
|
||||
|
||||
- Polling scripts will run and wait for exit.
|
||||
Normally they will repeat this at an interval, hence the name, although in some cases they may only run on a user
|
||||
event.
|
||||
If the script exited code 0, the `stdout` will be used. Otherwise, `stderr` will be printed to the log.
|
||||
- Watching scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
|
||||
and used.
|
||||
|
||||
One should prefer to use watch-mode where possible, as it removes the overhead of regularly spawning processes.
|
||||
That said, there are some cases which only support polling. These are indicated by `Script [polling]` as the option
|
||||
type.
|
||||
|
||||
## Writing script configs
|
||||
|
||||
There are two available config formats for scripts, shorthand as a string, or longhand as an object.
|
||||
Shorthand can be used in all cases, but there are some cases (such as embedding scripts inside strings) where longhand
|
||||
cannot be used.
|
||||
|
||||
In both formats, `mode` is one of `poll` or `watch` and `interval` is the number of milliseconds to wait between
|
||||
spawning the script.
|
||||
|
||||
Both `mode` and `interval` are optional and can be excluded to fall back to their defaults of `poll` and `5000`
|
||||
respectively.
|
||||
|
||||
### Shorthand (string)
|
||||
|
||||
Shorthand scripts should be written in the format:
|
||||
|
||||
```
|
||||
mode:interval:script
|
||||
```
|
||||
|
||||
For example:
|
||||
|
||||
```
|
||||
poll:5000:uptime -p | cut -d ' ' -f2-
|
||||
```
|
||||
|
||||
#### Embedding
|
||||
|
||||
Some string config options support "embedding scripts". This allows you to mix static/dynamic content.
|
||||
An example of this is the common `tooltip` option.
|
||||
|
||||
Scripts can be embedded in these cases using `{{double braces}}` and the shorthand syntax:
|
||||
|
||||
```json
|
||||
"Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
|
||||
```
|
||||
|
||||
### Longhand (object)
|
||||
|
||||
An object consisting of the `cmd` key and optionally the `mode` and/or `interval` keys.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "poll",
|
||||
"interval": 5000,
|
||||
"cmd": "uptime -p | cut -d ' ' -f2-"
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
mode: poll
|
||||
interval: 5000
|
||||
cmd: "uptime -p | cut -d ' ' -f2-"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```toml
|
||||
mode = "poll"
|
||||
interval = 5000
|
||||
cmd = "uptime -p | cut -d ' ' -f2-"
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
mode = "poll"
|
||||
interval = 5000
|
||||
cmd = "uptime -p | cut -d ' ' -f2-"
|
||||
}
|
||||
```
|
||||
</details>
|
||||
20
docs/Styling guide.md
Normal file
20
docs/Styling guide.md
Normal file
@@ -0,0 +1,20 @@
|
||||
Ironbar ships with no styles by default, so will fall back to the default GTK styles.
|
||||
|
||||
To style the bar, create a file at `~/.config/ironbar/style.css`.
|
||||
|
||||
Style changes are hot-loaded so there is no need to reload the bar.
|
||||
|
||||
A reminder: since the bar is GTK-based, it uses GTK's implementation of CSS,
|
||||
which only includes a subset of the full web spec (plus a few non-standard properties).
|
||||
|
||||
The below table describes the selectors provided by the bar itself.
|
||||
Information on styling individual modules can be found on their pages in the sidebar.
|
||||
|
||||
| Selector | Description |
|
||||
|----------------|-------------------------------------------|
|
||||
| `.background` | Top-level window |
|
||||
| `#bar` | Bar root box |
|
||||
| `#bar #start` | Bar left or top modules container box |
|
||||
| `#bar #center` | Bar center modules container box |
|
||||
| `#bar #end` | Bar right or bottom modules container box |
|
||||
| `.container` | All of the above |
|
||||
26
docs/_Sidebar.md
Normal file
26
docs/_Sidebar.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Guides
|
||||
|
||||
- [Configuration guide](configuration-guide)
|
||||
- [Scripts](scripts)
|
||||
- [Styling guide](styling-guide)
|
||||
|
||||
# Examples
|
||||
|
||||
- [Config](config)
|
||||
- [Stylesheet](stylesheet)
|
||||
|
||||
## Custom
|
||||
|
||||
- [Power Menu](power-menu)
|
||||
|
||||
# Modules
|
||||
|
||||
- [Clock](clock)
|
||||
- [Custom](custom)
|
||||
- [Focused](focused)
|
||||
- [Launcher](launcher)
|
||||
- [MPD](mpd)
|
||||
- [Script](script)
|
||||
- [Sys_Info](sys-info)
|
||||
- [Tray](tray)
|
||||
- [Workspaces](workspaces)
|
||||
BIN
docs/_imgs/custom-power-menu.png
Normal file
BIN
docs/_imgs/custom-power-menu.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
202
docs/examples/Config.md
Normal file
202
docs/examples/Config.md
Normal file
@@ -0,0 +1,202 @@
|
||||
The below config shows a module of each type being used.
|
||||
|
||||
The Corn format makes heavy use of variables
|
||||
to show how module configs can be easily referenced to improve readability
|
||||
and reduce config length when using multiple bars.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start": [
|
||||
{
|
||||
"all_monitors": false,
|
||||
"name_map": {
|
||||
"1": "ﭮ",
|
||||
"2": "",
|
||||
"3": "",
|
||||
"Code": "",
|
||||
"Games": ""
|
||||
},
|
||||
"type": "workspaces"
|
||||
},
|
||||
{
|
||||
"favorites": [
|
||||
"firefox",
|
||||
"discord",
|
||||
"Steam"
|
||||
],
|
||||
"icon_theme": "Paper",
|
||||
"show_icons": true,
|
||||
"show_names": false,
|
||||
"type": "launcher"
|
||||
}
|
||||
],
|
||||
"end": [
|
||||
{
|
||||
"music_dir": "/home/jake/Music",
|
||||
"type": "mpd"
|
||||
},
|
||||
{
|
||||
"host": "chloe:6600",
|
||||
"type": "mpd"
|
||||
},
|
||||
{
|
||||
"path": "/home/jake/bin/phone-battery",
|
||||
"type": "script"
|
||||
},
|
||||
{
|
||||
"format": [
|
||||
"{cpu-percent}% ",
|
||||
"{memory-percent}% "
|
||||
],
|
||||
"type": "sys-info"
|
||||
},
|
||||
{
|
||||
"type": "clock"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[start]]
|
||||
all_monitors = false
|
||||
type = 'workspaces'
|
||||
|
||||
[start.name_map]
|
||||
1 = 'ﭮ'
|
||||
2 = ''
|
||||
3 = ''
|
||||
Code = ''
|
||||
Games = ''
|
||||
|
||||
[[start]]
|
||||
icon_theme = 'Paper'
|
||||
show_icons = true
|
||||
show_names = false
|
||||
type = 'launcher'
|
||||
favorites = [
|
||||
'firefox',
|
||||
'discord',
|
||||
'Steam',
|
||||
]
|
||||
|
||||
[[end]]
|
||||
music_dir = '/home/jake/Music'
|
||||
type = 'mpd'
|
||||
|
||||
[[end]]
|
||||
host = 'chloe:6600'
|
||||
type = 'mpd'
|
||||
|
||||
[[end]]
|
||||
path = '/home/jake/bin/phone-battery'
|
||||
type = 'script'
|
||||
|
||||
[[end]]
|
||||
type = 'sys-info'
|
||||
format = [
|
||||
'{cpu-percent}% ',
|
||||
'{memory-percent}% ',
|
||||
]
|
||||
|
||||
[[end]]
|
||||
type = 'clock'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
---
|
||||
start:
|
||||
- all_monitors: false
|
||||
name_map:
|
||||
"1": ﭮ
|
||||
"2":
|
||||
"3":
|
||||
Code:
|
||||
Games:
|
||||
type: workspaces
|
||||
- favorites:
|
||||
- firefox
|
||||
- discord
|
||||
- Steam
|
||||
icon_theme: Paper
|
||||
show_icons: true
|
||||
show_names: false
|
||||
type: launcher
|
||||
end:
|
||||
- music_dir: /home/jake/Music
|
||||
type: mpd
|
||||
- host: "chloe:6600"
|
||||
type: mpd
|
||||
- path: /home/jake/bin/phone-battery
|
||||
type: script
|
||||
- format:
|
||||
- "{cpu-percent}% "
|
||||
- "{memory-percent}% "
|
||||
type: sys-info
|
||||
- type: clock
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
let {
|
||||
$workspaces = {
|
||||
type = "workspaces"
|
||||
all_monitors = false
|
||||
name_map = {
|
||||
1 = "ﭮ"
|
||||
2 = ""
|
||||
3 = ""
|
||||
Games = ""
|
||||
Code = ""
|
||||
}
|
||||
}
|
||||
|
||||
$launcher = {
|
||||
type = "launcher"
|
||||
favorites = ["firefox" "discord" "Steam"]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
icon_theme = "Paper"
|
||||
}
|
||||
|
||||
$mpd_local = { type = "mpd" music_dir = "/home/jake/Music" }
|
||||
$mpd_server = { type = "mpd" host = "chloe:6600" }
|
||||
|
||||
$sys_info = {
|
||||
type = "sys-info"
|
||||
format = ["{cpu-percent}% " "{memory-percent}% "]
|
||||
}
|
||||
|
||||
$tray = { type = "tray" }
|
||||
$clock = { type = "clock" }
|
||||
|
||||
$phone_battery = {
|
||||
type = "script"
|
||||
path = "/home/jake/bin/phone-battery"
|
||||
}
|
||||
|
||||
$start = [ $workspaces $launcher ]
|
||||
$end = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
|
||||
}
|
||||
in {
|
||||
start = $start
|
||||
end = $end
|
||||
}
|
||||
```
|
||||
</details>
|
||||
142
docs/examples/Stylesheet.md
Normal file
142
docs/examples/Stylesheet.md
Normal file
@@ -0,0 +1,142 @@
|
||||
The below example is a full stylesheet for all modules:
|
||||
|
||||
```css
|
||||
* {
|
||||
/* a nerd font is required to be installed for icons */
|
||||
font-family: Noto Sans Nerd Font, sans-serif;
|
||||
font-size: 16px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
#bar {
|
||||
border-top: 1px solid #424242;
|
||||
}
|
||||
|
||||
.container {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.container#end > * + * {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
.popup {
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid #424242;
|
||||
}
|
||||
|
||||
#workspaces .item {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#workspaces .item.focused {
|
||||
box-shadow: inset 0 -3px;
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#workspaces *:not(.focused):hover {
|
||||
box-shadow: inset 0 -3px;
|
||||
}
|
||||
|
||||
#launcher .item {
|
||||
border-radius: 0;
|
||||
background-color: #2d2d2d;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
#launcher .item:not(.focused):hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#launcher .open {
|
||||
border-bottom: 2px solid #6699cc;
|
||||
}
|
||||
|
||||
#launcher .focused {
|
||||
color: white;
|
||||
background-color: black;
|
||||
border-bottom: 4px solid #6699cc;
|
||||
}
|
||||
|
||||
#launcher .urgent {
|
||||
color: white;
|
||||
background-color: #8f0a0a;
|
||||
}
|
||||
|
||||
#script {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sysinfo {
|
||||
color: white;
|
||||
}
|
||||
|
||||
#tray .item {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
#mpd {
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#popup-mpd {
|
||||
color: white;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#popup-mpd #album-art {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
#popup-mpd #title .icon, #popup-mpd #title .label {
|
||||
font-size: 1.7em;
|
||||
}
|
||||
|
||||
#popup-mpd #controls * {
|
||||
border-radius: 0;
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#popup-mpd #controls *:disabled {
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
#clock {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#popup-clock {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#popup-clock #calendar-clock {
|
||||
color: white;
|
||||
font-size: 2.5em;
|
||||
padding-bottom: 0.1em;
|
||||
}
|
||||
|
||||
#popup-clock #calendar {
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#popup-clock #calendar .header {
|
||||
padding-top: 1em;
|
||||
border-top: 1px solid #424242;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
#popup-clock #calendar:selected {
|
||||
background-color: #6699cc;
|
||||
}
|
||||
|
||||
#focused {
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
230
docs/examples/custom/Power-Menu.md
Normal file
230
docs/examples/custom/Power-Menu.md
Normal file
@@ -0,0 +1,230 @@
|
||||
Creates a button on the bar, which opens a popup. The popup contains a header, shutdown button, restart button, and uptime.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "clock"
|
||||
},
|
||||
{
|
||||
"bar": [
|
||||
{
|
||||
"on_click": "popup:toggle",
|
||||
"label": "",
|
||||
"name": "power-btn",
|
||||
"type": "button"
|
||||
}
|
||||
],
|
||||
"class": "power-menu",
|
||||
"popup": [
|
||||
{
|
||||
"orientation": "vertical",
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"label": "Power menu",
|
||||
"name": "header",
|
||||
"type": "label"
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"class": "power-btn",
|
||||
"on_click": "!shutdown now",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"type": "button"
|
||||
},
|
||||
{
|
||||
"class": "power-btn",
|
||||
"on_click": "!reboot",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"type": "button"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
|
||||
"name": "uptime",
|
||||
"type": "label"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = 'clock'
|
||||
|
||||
[[end]]
|
||||
class = 'power-menu'
|
||||
type = 'custom'
|
||||
|
||||
[[end.bar]]
|
||||
on_click = 'popup:toggle'
|
||||
label = ''
|
||||
name = 'power-btn'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup]]
|
||||
orientation = 'vertical'
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = 'Power menu'
|
||||
name = 'header'
|
||||
type = 'label'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
on_click = '!shutdown now'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
on_click = '!reboot'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
|
||||
name = 'uptime'
|
||||
type = 'label'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: clock
|
||||
- bar:
|
||||
- on_click: popup:toggle
|
||||
label:
|
||||
name: power-btn
|
||||
type: button
|
||||
class: power-menu
|
||||
popup:
|
||||
- orientation: vertical
|
||||
type: box
|
||||
widgets:
|
||||
- label: Power menu
|
||||
name: header
|
||||
type: label
|
||||
- type: box
|
||||
widgets:
|
||||
- class: power-btn
|
||||
on_click: '!shutdown now'
|
||||
label: <span font-size='40pt'></span>
|
||||
type: button
|
||||
- class: power-btn
|
||||
on_click: '!reboot'
|
||||
label: <span font-size='40pt'></span>
|
||||
type: button
|
||||
- label: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
|
||||
name: uptime
|
||||
type: label
|
||||
type: custom
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
let {
|
||||
$power_menu = {
|
||||
type = "custom"
|
||||
class = "power-menu"
|
||||
|
||||
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
|
||||
|
||||
popup = [ {
|
||||
type = "box"
|
||||
orientation = "vertical"
|
||||
widgets = [
|
||||
{ type = "label" name = "header" label = "Power menu" }
|
||||
{
|
||||
type = "box"
|
||||
widgets = [
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
|
||||
]
|
||||
}
|
||||
{ type = "label" name = "uptime" label = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}" }
|
||||
]
|
||||
} ]
|
||||
}
|
||||
} in {
|
||||
end = [ $power_menu ]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
```css
|
||||
.power-menu {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.power-menu #power-btn {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.power-menu #power-btn:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
.popup-power-menu {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu #header {
|
||||
color: white;
|
||||
font-size: 1.4em;
|
||||
border-bottom: 1px solid white;
|
||||
padding-bottom: 0.4em;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid white;
|
||||
padding: 0.6em 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn + .power-btn {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
```
|
||||
77
docs/modules/Clock.md
Normal file
77
docs/modules/Clock.md
Normal file
@@ -0,0 +1,77 @@
|
||||
Displays the current date and time.
|
||||
Clicking on the widget opens a popup with the time and a calendar.
|
||||
|
||||

|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `clock`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|----------|--------|------------------|--------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `format` | `string` | `%d/%m/%Y %H:%M` | Date/time format string. Detail on available tokens can be found here: <https://docs.rs/chrono/latest/chrono/format/strftime/index.html> |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "clock",
|
||||
"format": "%d/%m/%Y %H:%M"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "clock"
|
||||
format = "%d/%m/%Y %H:%M"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "clock"
|
||||
format: "%d/%m/%Y %H:%M"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "clock"
|
||||
format = "%d/%m/%Y %H:%M"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|-------------------------------|------------------------------------------------------------------------------------|
|
||||
| `#clock` | Clock widget button |
|
||||
| `#popup-clock` | Clock popup box |
|
||||
| `#popup-clock #calendar-clock` | Clock inside the popup |
|
||||
| `#popup-clock #calendar` | Calendar widget inside the popup. GTK provides some OOTB styling options for this. |
|
||||
272
docs/modules/Custom.md
Normal file
272
docs/modules/Custom.md
Normal file
@@ -0,0 +1,272 @@
|
||||
Allows you to compose custom modules consisting of multiple widgets, including popups.
|
||||
Labels can display dynamic content from scripts, and buttons can interact with the bar or execute commands on click.
|
||||
|
||||

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

|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `focused`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|--------------|-----------|---------|---------------------------------|
|
||||
| `show_icon` | `boolean` | `true` | Whether to show the app's icon |
|
||||
| `show_title` | `boolean` | `true` | Whether to show the app's title |
|
||||
| `icon_size` | `integer` | `32` | Size of icon in pixels |
|
||||
| `icon_theme` | `string` | `null` | GTK icon theme to use |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "focused",
|
||||
"show_icon": true,
|
||||
"show_title": true,
|
||||
"icon_size": 32,
|
||||
"icon_theme": "Paper"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "focused"
|
||||
show_icon = true
|
||||
show_title = true
|
||||
icon_size = 32
|
||||
icon_theme = "Paper"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "focused"
|
||||
show_icon: true
|
||||
show_title: true
|
||||
icon_size: 32
|
||||
icon_theme: "Paper"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "focused"
|
||||
show_icon = true
|
||||
show_title = true
|
||||
icon_size = 32
|
||||
icon_theme = "Paper"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|--------------------------|--------------------|
|
||||
| `#focused` | Focused widget box |
|
||||
| `#focused #icon` | App icon |
|
||||
| `#focused #label` | App name |
|
||||
103
docs/modules/Launcher.md
Normal file
103
docs/modules/Launcher.md
Normal file
@@ -0,0 +1,103 @@
|
||||
Windows-style taskbar that displays running windows, grouped by program.
|
||||
Hovering over a program with multiple windows open shows a popup with each window.
|
||||
Clicking an icon/popup item focuses or launches the program.
|
||||
Optionally displays a launchable set of favourites.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `launcher`
|
||||
|
||||
| | Type | Default | Description |
|
||||
|--------------|------------|---------|-----------------------------------------------------------------------------------------------------|
|
||||
| `favorites` | `string[]` | `[]` | List of app IDs (or classes) to always show at the start of the launcher |
|
||||
| `show_names` | `boolean` | `false` | Whether to show app names on the button label. Names will still show on tooltips when set to false. |
|
||||
| `show_icons` | `boolean` | `true` | Whether to show app icons on the button. |
|
||||
| `icon_theme` | `string` | `null` | GTK icon theme to use. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start": [
|
||||
{
|
||||
"type": "launcher",
|
||||
"favourites": [
|
||||
"firefox",
|
||||
"discord"
|
||||
],
|
||||
"show_names": false,
|
||||
"show_icons": true,
|
||||
"icon_theme": "Paper"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[start]]
|
||||
type = "launcher"
|
||||
favorites = ["firefox", "discord"]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
icon_theme = "Paper"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
start:
|
||||
- type: "launcher"
|
||||
favorites:
|
||||
- firefox
|
||||
- discord
|
||||
show_names: false
|
||||
show_icons: true
|
||||
icon_theme: "Paper"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
start = [
|
||||
{
|
||||
type = "launcher"
|
||||
favorites = ["firefox" "discord"]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
icon_theme = "Paper"
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|-------------------------------|--------------------------|
|
||||
| `#launcher` | Launcher widget box |
|
||||
| `#launcher .item` | App button |
|
||||
| `#launcher .item.open` | App button (open app) |
|
||||
| `#launcher .item.focused` | App button (focused app) |
|
||||
| `#launcher .item.urgent` | App button (urgent app) |
|
||||
| `#launcher-popup` | Popup container |
|
||||
| `#launcher-popup .popup-item` | Window button in popup |
|
||||
131
docs/modules/MPD.md
Normal file
131
docs/modules/MPD.md
Normal file
@@ -0,0 +1,131 @@
|
||||
Displays currently playing song from MPD.
|
||||
Clicking on the widget opens a popout displaying info about the current song, album art
|
||||
and playback controls.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `mpd`
|
||||
|
||||
| | Type | Default | Description |
|
||||
|----------------|----------|-----------------------------|-----------------------------------------------------------------------|
|
||||
| `host` | `string` | `localhost:6600` | TCP or Unix socket for the MPD server. |
|
||||
| `format` | `string` | `{icon} {title} / {artist}` | Format string for the widget. More info below. |
|
||||
| `icons.play` | `string` | `` | Icon to show when playing. |
|
||||
| `icons.pause` | `string` | `` | Icon to show when paused. |
|
||||
| `icons.volume` | `string` | `墳` | Icon to show under popup volume slider. |
|
||||
| `music_dir` | `string` | `$HOME/Music` | Path to MPD server's music directory on disc. Required for album art. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start": [
|
||||
{
|
||||
"type": "mpd",
|
||||
"format": "{icon} {title} / {artist}",
|
||||
"icons": {
|
||||
"play": "",
|
||||
"pause": ""
|
||||
},
|
||||
"music_dir": "/home/jake/Music"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[start]]
|
||||
type = "mpd"
|
||||
format = "{icon} {title} / {artist}"
|
||||
music_dir = "/home/jake/Music"
|
||||
|
||||
[[start.icons]]
|
||||
play = ""
|
||||
pause = ""
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
start:
|
||||
- type: "mpd"
|
||||
format: "{icon} {title} / {artist}"
|
||||
icons:
|
||||
play: ""
|
||||
pause: ""
|
||||
music_dir: "/home/jake/Music"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
start = [
|
||||
{
|
||||
type = "mpd"
|
||||
format = "{icon} {title} / {artist}"
|
||||
icons.play = ""
|
||||
icons.pause = ""
|
||||
music_dir = "/home/jake/Music"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Formatting Tokens
|
||||
|
||||
The following tokens can be used in the `format` config option,
|
||||
and will be replaced with values from the currently playing track:
|
||||
|
||||
| Token | Description |
|
||||
|--------------|--------------------------------------|
|
||||
| `{icon}` | Either `icons.play` or `icons.pause` |
|
||||
| `{title}` | Title |
|
||||
| `{album}` | Album name |
|
||||
| `{artist}` | Artist name |
|
||||
| `{date}` | Release date |
|
||||
| `{track}` | Track number |
|
||||
| `{disc}` | Disc number |
|
||||
| `{genre}` | Genre |
|
||||
| `{duration}` | Duration in `mm:ss` |
|
||||
| `{elapsed}` | Time elapsed in `mm:ss` |
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|----------------------------------------|------------------------------------------|
|
||||
| `#mpd` | Tray widget button |
|
||||
| `#popup-mpd` | Popup box |
|
||||
| `#popup-mpd #album-art` | Album art image inside popup box |
|
||||
| `#popup-mpd #title` | Track title container inside popup box |
|
||||
| `#popup-mpd #title .icon` | Track title icon label inside popup box |
|
||||
| `#popup-mpd #title .label` | Track title label inside popup box |
|
||||
| `#popup-mpd #album` | Track album container inside popup box |
|
||||
| `#popup-mpd #album .icon` | Track album icon label inside popup box |
|
||||
| `#popup-mpd #album .label` | Track album label inside popup box |
|
||||
| `#popup-mpd #artist` | Track artist container inside popup box |
|
||||
| `#popup-mpd #artist .icon` | Track artist icon label inside popup box |
|
||||
| `#popup-mpd #artist .label` | Track artist label inside popup box |
|
||||
| `#popup-mpd #controls` | Controls container inside popup box |
|
||||
| `#popup-mpd #controls #btn-prev` | Previous button inside popup box |
|
||||
| `#popup-mpd #controls #btn-play-pause` | Play/pause button inside popup box |
|
||||
| `#popup-mpd #controls #btn-next` | Next button inside popup box |
|
||||
| `#popup-mpd #volume` | Volume container inside popup box |
|
||||
| `#popup-mpd #volume #slider` | Volume slider popup box |
|
||||
| `#popup-mpd #volume .icon` | Volume icon label inside popup box |
|
||||
87
docs/modules/Script.md
Normal file
87
docs/modules/Script.md
Normal file
@@ -0,0 +1,87 @@
|
||||
Executes a script and shows the result of `stdout` on a label.
|
||||
Pango markup is supported.
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `script`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------------|-----------------------|---------|---------------------------------------------------------|
|
||||
| `cmd` | `string` | `null` | Path to the script on disk |
|
||||
| `mode` | `'poll'` or `'watch'` | `poll` | See [#modes](#modes) |
|
||||
| `interval` | `number` | `5000` | Number of milliseconds to wait between executing script |
|
||||
|
||||
### Modes
|
||||
|
||||
- Use `poll` to run the script wait for it to exit. On exit, the label is updated to show everything the script wrote to `stdout`.
|
||||
- Use `watch` to start a long-running script. Every time the script writes to `stdout`, the label is updated to show the latest line.
|
||||
Note this does not work for all programs as they may use block-buffering instead of line-buffering when they detect output being piped.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "script",
|
||||
"cmd": "/home/jake/.local/bin/phone-battery",
|
||||
"mode": "poll",
|
||||
"interval": 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "script"
|
||||
cmd = "/home/jake/.local/bin/phone-battery"
|
||||
mode = "poll"
|
||||
interval = 5000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "script"
|
||||
cmd: "/home/jake/.local/bin/phone-battery"
|
||||
mode: 'poll'
|
||||
interval : 5000
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "script"
|
||||
cmd = "/home/jake/.local/bin/phone-battery"
|
||||
mode = "poll"
|
||||
interval = 5000
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|---------------|---------------------|
|
||||
| `#script` | Script widget label |
|
||||
176
docs/modules/Sys-Info.md
Normal file
176
docs/modules/Sys-Info.md
Normal file
@@ -0,0 +1,176 @@
|
||||
Displays one or more labels containing system information.
|
||||
|
||||
Separating information across several labels allows for styling each one independently.
|
||||
Pango markup is supported.
|
||||
|
||||

|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `sys_info`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|--------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `format` | `string[]` | `null` | Array of strings including formatting tokens. For available tokens see below. |
|
||||
| `interval` | `integer` or `Map` | `5` | Seconds between refreshing. Can be a single value for all data or a map of individual refresh values for different data types. |
|
||||
| `interval.memory` | `integer` | `5` | Seconds between refreshing memory data |
|
||||
| `interval.cpu` | `integer` | `5` | Seconds between refreshing cpu data |
|
||||
| `interval.temps` | `integer` | `5` | Seconds between refreshing temperature data |
|
||||
| `interval.disks` | `integer` | `5` | Seconds between refreshing disk data |
|
||||
| `interval.network` | `integer` | `5` | Seconds between refreshing network data |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"format": [
|
||||
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C",
|
||||
" {memory_used} / {memory_total} GB ({memory_percent}%)",
|
||||
"| {swap_used} / {swap_total} GB ({swap_percent}%)",
|
||||
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)",
|
||||
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps",
|
||||
"猪 {load_average:1} | {load_average:5} | {load_average:15}",
|
||||
" {uptime}"
|
||||
],
|
||||
"interval": {
|
||||
"cpu": 1,
|
||||
"disks": 300,
|
||||
"memory": 30,
|
||||
"networks": 3,
|
||||
"temps": 5
|
||||
},
|
||||
"type": "sys_info"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = 'sys_info'
|
||||
format = [
|
||||
' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C',
|
||||
' {memory_used} / {memory_total} GB ({memory_percent}%)',
|
||||
'| {swap_used} / {swap_total} GB ({swap_percent}%)',
|
||||
' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)',
|
||||
'李 {net_down:enp39s0} / {net_up:enp39s0} Mbps',
|
||||
'猪 {load_average:1} | {load_average:5} | {load_average:15}',
|
||||
' {uptime}',
|
||||
]
|
||||
|
||||
[end.interval]
|
||||
cpu = 1
|
||||
disks = 300
|
||||
memory = 30
|
||||
networks = 3
|
||||
temps = 5
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- format:
|
||||
- ' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C'
|
||||
- ' {memory_used} / {memory_total} GB ({memory_percent}%)'
|
||||
- '| {swap_used} / {swap_total} GB ({swap_percent}%)'
|
||||
- ' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)'
|
||||
- '李 {net_down:enp39s0} / {net_up:enp39s0} Mbps'
|
||||
- '猪 {load_average:1} | {load_average:5} | {load_average:15}'
|
||||
- ' {uptime}'
|
||||
interval:
|
||||
cpu: 1
|
||||
disks: 300
|
||||
memory: 30
|
||||
networks: 3
|
||||
temps: 5
|
||||
type: sys_info
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "sys_info"
|
||||
|
||||
interval.memory = 30
|
||||
interval.cpu = 1
|
||||
interval.temps = 5
|
||||
interval.disks = 300
|
||||
interval.networks = 3
|
||||
|
||||
format = [
|
||||
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C"
|
||||
" {memory_used} / {memory_total} GB ({memory_percent}%)"
|
||||
"| {swap_used} / {swap_total} GB ({swap_percent}%)"
|
||||
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)"
|
||||
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps"
|
||||
"猪 {load_average:1} | {load_average:5} | {load_average:15}"
|
||||
" {uptime}"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### Formatting Tokens
|
||||
|
||||
The following tokens can be used in the `format` configuration option:
|
||||
|
||||
| Token | Description |
|
||||
|--------------------------|------------------------------------------------------------------------------------|
|
||||
| **CPU** | |
|
||||
| `{cpu_percent}` | Total CPU utilisation percentage |
|
||||
| **Memory** | |
|
||||
| `{memory_free}` | Memory free in GB. |
|
||||
| `{memory_used}` | Memory used in GB. |
|
||||
| `{memory_total}` | Memory total in GB. |
|
||||
| `{memory_percent}` | Memory utilisation percentage. |
|
||||
| `{swap_free}` | Swap free in GB. |
|
||||
| `{swap_used}` | Swap used in GB. |
|
||||
| `{swap_total}` | Swap total in GB. |
|
||||
| `{swap_percent}` | Swap utilisation percentage. |
|
||||
| **Temperature** | |
|
||||
| `{temp_c:[sensor]}` | Temperature in degrees C. Replace `[sensor]` with the sensor label. |
|
||||
| `{temp_f:[sensor]}` | Temperature in degrees F. Replace `[sensor]` with the sensor label. |
|
||||
| **Disk** | |
|
||||
| `{disk_free:[mount]}` | Disk free space in GB. Replace `[mount]` with the disk mountpoint. |
|
||||
| `{disk_used:[mount]}` | Disk used space in GB. Replace `[mount]` with the disk mountpoint. |
|
||||
| `{disk_total:[mount]}` | Disk total space in GB. Replace `[mount]` with the disk mountpoint. |
|
||||
| `{disk_percent:[mount]}` | Disk utilisation percentage. Replace `[mount]` with the disk mountpoint. |
|
||||
| **Network** | |
|
||||
| `{net_down:[adapter]}` | Average network download speed in Mbps. Replace `[adapter]` with the adapter name. |
|
||||
| `{net_up:[adapter]}` | Average network upload speed in Mbps. Replace `[adapter]` with the adapter name. |
|
||||
| **System** | |
|
||||
| `{load_average:1}` | 1-minute load average. |
|
||||
| `{load_average:5}` | 5-minute load average. |
|
||||
| `{load_average:15}` | 15-minute load average. |
|
||||
| `{uptime}` | System uptime formatted as `HH:mm`. |
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|------------------|------------------------------|
|
||||
| `#sysinfo` | Sysinfo widget box |
|
||||
| `#sysinfo #item` | Individual information label |
|
||||
64
docs/modules/Tray.md
Normal file
64
docs/modules/Tray.md
Normal file
@@ -0,0 +1,64 @@
|
||||
Displays a fully interactive icon tray using the KDE `libappindicator` protocol.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `tray`
|
||||
|
||||
***This module provides no configuration options.***
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "tray"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "tray"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "tray"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{ type = "tray" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|---------------|------------------|
|
||||
| `#tray` | Tray widget box |
|
||||
| `#tray .item` | Tray icon button |
|
||||
94
docs/modules/Workspaces.md
Normal file
94
docs/modules/Workspaces.md
Normal file
@@ -0,0 +1,94 @@
|
||||
> ⚠ **This module is currently only supported on Sway**
|
||||
|
||||
Shows all current Sway workspaces. Clicking a workspace changes focus to it.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `workspaces`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|----------------|-----------------------|---------|----------------------------------------------------------------------------------------------------------------------|
|
||||
| `name_map` | `Map<string, string>` | `{}` | A map of actual workspace names to their display labels. Workspaces use their actual name if not present in the map. |
|
||||
| `all_monitors` | `boolean` | `false` | Whether to display workspaces from all monitors. When `false`, only shows workspaces on the current monitor. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "workspaces",
|
||||
"name_map": {
|
||||
"1": "",
|
||||
"2": "",
|
||||
"3": ""
|
||||
},
|
||||
"all_monitors": false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "workspaces"
|
||||
all_monitors = false
|
||||
|
||||
[[end.name_map]]
|
||||
1 = ""
|
||||
2 = ""
|
||||
3 = ""
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "workspaces"
|
||||
name_map:
|
||||
1: ""
|
||||
2: ""
|
||||
3: ""
|
||||
all_monitors: false
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "workspaces",
|
||||
name_map.1 = ""
|
||||
name_map.2 = ""
|
||||
name_map.3 = ""
|
||||
all_monitors = false
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|-----------------------------|--------------------------------------|
|
||||
| `#workspaces` | Workspaces widget box |
|
||||
| `#workspaces .item` | Workspace button |
|
||||
| `#workspaces .item.focused` | Workspace button (workspace focused) |
|
||||
@@ -23,21 +23,59 @@ let {
|
||||
$mpd_server = { type = "mpd" host = "chloe:6600" }
|
||||
|
||||
$sys_info = {
|
||||
type = "sys-info"
|
||||
format = ["{cpu-percent}% " "{memory-percent}% "]
|
||||
type = "sys_info"
|
||||
format = ["{cpu_percent}% " "{memory_percent}% "]
|
||||
}
|
||||
|
||||
$tray = { type = "tray" }
|
||||
$clock = { type = "clock" }
|
||||
$clock = {
|
||||
type = "clock"
|
||||
// show-if = "500:[ $(($(date +%s) % 2)) -eq 0 ]"
|
||||
show_if.cmd = "exit 0"
|
||||
show_if.interval = 500
|
||||
}
|
||||
|
||||
$phone_battery = {
|
||||
type = "script"
|
||||
path = "/home/jake/bin/phone-battery"
|
||||
cmd = "/home/jake/bin/phone-battery"
|
||||
}
|
||||
|
||||
$log_tail = {
|
||||
type = "script"
|
||||
path = "tail -f /home/jake/.local/share/ironbar/error.log"
|
||||
mode = "watch"
|
||||
}
|
||||
|
||||
$power_menu = {
|
||||
type = "custom"
|
||||
class = "power-menu"
|
||||
|
||||
bar = [ { type = "button" name="power-btn" label = "" on_click = "popup:toggle" } ]
|
||||
|
||||
popup = [ {
|
||||
type = "box"
|
||||
orientation = "vertical"
|
||||
widgets = [
|
||||
{ type = "label" name = "header" label = "Power menu" }
|
||||
{
|
||||
type = "box"
|
||||
widgets = [
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
|
||||
]
|
||||
}
|
||||
{ type = "label" name = "uptime" label = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}" }
|
||||
]
|
||||
} ]
|
||||
|
||||
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
|
||||
}
|
||||
|
||||
$left = [ $workspaces $launcher ]
|
||||
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
|
||||
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $power_menu $clock ]
|
||||
}
|
||||
in {
|
||||
left = $left right = $right
|
||||
anchor_to_edges = true
|
||||
position = "top"
|
||||
start = $left end = $right
|
||||
}
|
||||
|
||||
@@ -1,18 +1,43 @@
|
||||
{
|
||||
"monitors": {
|
||||
"DP-1": [
|
||||
{
|
||||
"left": [{"type": "clock"}]
|
||||
},
|
||||
{
|
||||
"position": "top",
|
||||
"left": []
|
||||
}
|
||||
],
|
||||
"DP-2": {
|
||||
"position": "bottom",
|
||||
"height": 30,
|
||||
"left": []
|
||||
"start": [
|
||||
{
|
||||
"type": "workspaces"
|
||||
},
|
||||
{
|
||||
"type": "launcher",
|
||||
"icon_theme": "Paper",
|
||||
"favorites": [
|
||||
"firefox",
|
||||
"discord",
|
||||
"Steam"
|
||||
],
|
||||
"show_names": false
|
||||
}
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
]
|
||||
}
|
||||
29
examples/custom.corn
Normal file
29
examples/custom.corn
Normal file
@@ -0,0 +1,29 @@
|
||||
let {
|
||||
$button = { type = "button" name="power-btn" label = "" on_click = "popup:toggle" }
|
||||
|
||||
$popup = {
|
||||
type = "box"
|
||||
orientation = "vertical"
|
||||
widgets = [
|
||||
{ type = "label" name = "header" label = "Power menu" }
|
||||
{
|
||||
type = "box"
|
||||
widgets = [
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!shutdown now" }
|
||||
{ type = "button" class="power-btn" label = "<span font-size='40pt'></span>" on_click = "!reboot" }
|
||||
]
|
||||
}
|
||||
{ type = "label" name = "uptime" label = "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}" }
|
||||
]
|
||||
}
|
||||
|
||||
$power_menu = {
|
||||
type = "custom"
|
||||
class = "power-menu"
|
||||
|
||||
bar = [ $button ]
|
||||
popup = [ $popup ]
|
||||
}
|
||||
} in {
|
||||
end = [ $power_menu { type = "clock" } ]
|
||||
}
|
||||
23
examples/sys-info.corn
Normal file
23
examples/sys-info.corn
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "sys_info"
|
||||
|
||||
interval.memory = 30
|
||||
interval.cpu = 1
|
||||
interval.temps = 5
|
||||
interval.disks = 300
|
||||
interval.networks = 3
|
||||
|
||||
format = [
|
||||
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C"
|
||||
" {memory_used} / {memory_total} GB ({memory_percent}%)"
|
||||
"| {swap_used} / {swap_total} GB ({swap_percent}%)"
|
||||
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)"
|
||||
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps"
|
||||
"猪 {load_average:1} | {load_average:5} | {load_average:15}"
|
||||
" {uptime}"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
64
flake.lock
generated
Normal file
64
flake.lock
generated
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"locked": {
|
||||
"lastModified": 1659877975,
|
||||
"narHash": "sha256-zllb8aq3YO3h8B/U0/J1WBgAL8EX5yWf5pMj3G0NAmc=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "c0e246b9b83f637f4681389ecabcb2681b4f3af0",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1668994630,
|
||||
"narHash": "sha256-1lqx6HLyw6fMNX/hXrrETG1vMvZRGm2XVC9O/Jt0T6c=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "af50806f7c6ab40df3e6b239099e8f8385f6c78b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nixos",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1669084742,
|
||||
"narHash": "sha256-aLYwYVnrmEE1LVqd17v99CuqVmAZQrlgi2DVTAs4wFg=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "9652ef34c7439eca9f86cee11e94dbef5c9adb09",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
142
flake.nix
Normal file
142
flake.nix
Normal file
@@ -0,0 +1,142 @@
|
||||
{
|
||||
description = "Nix Flake for ironbar";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
#nci.url = "github:yusdacra/nix-cargo-integration";
|
||||
#nci.inputs.nixpkgs.follows = "nixpkgs";
|
||||
#nci.inputs.rust-overlay.follows = "rust-overlay";
|
||||
};
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
rust-overlay,
|
||||
...
|
||||
}: let
|
||||
inherit (nixpkgs) lib;
|
||||
genSystems = lib.genAttrs [
|
||||
"aarch64-linux"
|
||||
"x86_64-linux"
|
||||
];
|
||||
pkgsFor = system:
|
||||
import nixpkgs {
|
||||
inherit system;
|
||||
|
||||
overlays = [
|
||||
self.overlays.default
|
||||
rust-overlay.overlays.default
|
||||
];
|
||||
};
|
||||
mkRustToolchain = pkgs: pkgs.rust-bin.stable.latest.default;
|
||||
in {
|
||||
overlays.default = final: prev: let
|
||||
rust = mkRustToolchain final;
|
||||
|
||||
rustPlatform = prev.makeRustPlatform {
|
||||
cargo = rust;
|
||||
rustc = rust;
|
||||
};
|
||||
in {
|
||||
ironbar = rustPlatform.buildRustPackage {
|
||||
pname = "ironbar";
|
||||
version = self.rev or "dirty";
|
||||
src = builtins.path {
|
||||
name = "ironbar";
|
||||
path = prev.lib.cleanSource ./.;
|
||||
};
|
||||
cargoDeps = rustPlatform.importCargoLock {lockFile = ./Cargo.lock;};
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
nativeBuildInputs = with prev; [pkg-config];
|
||||
buildInputs = with prev; [gtk3 gdk-pixbuf gtk-layer-shell libxkbcommon];
|
||||
};
|
||||
};
|
||||
packages = genSystems (
|
||||
system: let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
(self.overlays.default pkgs pkgs)
|
||||
// {
|
||||
default = self.packages.${system}.ironbar;
|
||||
}
|
||||
);
|
||||
devShells = genSystems (system: let
|
||||
pkgs = pkgsFor system;
|
||||
rust = mkRustToolchain pkgs;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
rust
|
||||
rust-analyzer-unwrapped
|
||||
gcc
|
||||
gtk3
|
||||
gtk-layer-shell
|
||||
pkg-config
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
});
|
||||
homeManagerModules.default = {
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
cfg = config.programs.ironbar;
|
||||
defaultIronbarPackage = self.packages.${pkgs.system}.default;
|
||||
jsonFormat = pkgs.formats.json {};
|
||||
in {
|
||||
options.programs.ironbar = {
|
||||
enable = lib.mkEnableOption "ironbar status bar";
|
||||
package = lib.mkOption {
|
||||
type = with lib.types; package;
|
||||
default = defaultIronbarPackage;
|
||||
description = "The package for ironbar to use";
|
||||
};
|
||||
systemd = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = pkgs.stdenv.isLinux;
|
||||
description = "Whether to enable to systemd service for ironbar";
|
||||
};
|
||||
style = lib.mkOption {
|
||||
type = lib.types.lines;
|
||||
default = "";
|
||||
description = "The stylesheet to apply to ironbar";
|
||||
};
|
||||
config = lib.mkOption {
|
||||
type = jsonFormat.type;
|
||||
default = {};
|
||||
description = "The config to pass to ironbar";
|
||||
};
|
||||
};
|
||||
config = lib.mkIf cfg.enable {
|
||||
home.packages = [cfg.package];
|
||||
xdg.configFile = {
|
||||
"ironbar/config.json" = lib.mkIf (cfg.config != "") {
|
||||
source = jsonFormat.generate "ironbar-config" cfg.config;
|
||||
};
|
||||
"ironbar/style.css" = lib.mkIf (cfg.style != "") {
|
||||
text = cfg.style;
|
||||
};
|
||||
};
|
||||
systemd.user.services.ironbar = lib.mkIf cfg.systemd {
|
||||
Unit = {
|
||||
Description = "Systemd service for Ironbar";
|
||||
Requires = ["graphical-session.target"];
|
||||
};
|
||||
Service = {
|
||||
Type = "simple";
|
||||
ExecStart = "${cfg.package}/bin/ironbar";
|
||||
};
|
||||
Install.WantedBy = [
|
||||
(lib.mkIf config.wayland.windowManager.hyprland.systemdIntegration "hyprland-session.target")
|
||||
(lib.mkIf config.wayland.windowManager.sway.systemdIntegration "sway-session.target")
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
322
src/bar.rs
322
src/bar.rs
@@ -1,11 +1,26 @@
|
||||
use crate::bridge_channel::BridgeChannel;
|
||||
use crate::config::{BarPosition, ModuleConfig};
|
||||
use crate::modules::{Module, ModuleInfo, ModuleLocation};
|
||||
use crate::Config;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::modules::custom::ExecEvent;
|
||||
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
|
||||
use crate::modules::mpd::{PlayerCommand, SongUpdate};
|
||||
use crate::modules::workspaces::WorkspaceUpdate;
|
||||
use crate::modules::{Module, ModuleInfoBuilder, ModuleLocation, ModuleUpdateEvent, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use crate::script::{OutputStream, Script};
|
||||
use crate::{await_sync, Config};
|
||||
use chrono::{DateTime, Local};
|
||||
use color_eyre::Result;
|
||||
use gtk::gdk::Monitor;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Application, ApplicationWindow, Orientation};
|
||||
use tracing::{debug, info};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use stray::message::NotifierItemCommand;
|
||||
use stray::NotifierItemMessage;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, error, info, trace};
|
||||
|
||||
/// Creates a new window for a bar,
|
||||
/// sets it up and adds its widgets.
|
||||
@@ -17,30 +32,49 @@ pub fn create_bar(
|
||||
) -> Result<()> {
|
||||
let win = ApplicationWindow::builder().application(app).build();
|
||||
|
||||
setup_layer_shell(&win, monitor, &config.position);
|
||||
setup_layer_shell(&win, monitor, config.position, config.anchor_to_edges);
|
||||
|
||||
let orientation = config.position.get_orientation();
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.orientation(orientation)
|
||||
.spacing(0)
|
||||
.hexpand(false)
|
||||
.height_request(config.height)
|
||||
.name("bar")
|
||||
.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();
|
||||
|
||||
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");
|
||||
left.style_context().add_class("container");
|
||||
start.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(¢er));
|
||||
content.pack_end(&right, false, false, 0);
|
||||
content.pack_end(&end, false, false, 0);
|
||||
|
||||
load_modules(&left, ¢er, &right, app, config, monitor, monitor_name)?;
|
||||
load_modules(&start, ¢er, &end, app, config, monitor, monitor_name)?;
|
||||
win.add(&content);
|
||||
|
||||
win.connect_destroy_event(|_, _| {
|
||||
@@ -50,7 +84,11 @@ pub fn create_bar(
|
||||
});
|
||||
|
||||
debug!("Showing bar");
|
||||
win.show_all();
|
||||
start.show();
|
||||
center.show();
|
||||
end.show();
|
||||
content.show();
|
||||
win.show();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -65,40 +103,29 @@ fn load_modules(
|
||||
monitor: &Monitor,
|
||||
output_name: &str,
|
||||
) -> Result<()> {
|
||||
if let Some(modules) = config.left {
|
||||
let info = ModuleInfo {
|
||||
app,
|
||||
location: ModuleLocation::Left,
|
||||
bar_position: &config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
};
|
||||
let mut info_builder = ModuleInfoBuilder::default();
|
||||
let info_builder = info_builder
|
||||
.app(app)
|
||||
.bar_position(config.position)
|
||||
.monitor(monitor)
|
||||
.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 {
|
||||
let info = ModuleInfo {
|
||||
app,
|
||||
location: ModuleLocation::Center,
|
||||
bar_position: &config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
};
|
||||
let info_builder = info_builder.location(ModuleLocation::Center);
|
||||
|
||||
add_modules(center, modules, &info)?;
|
||||
add_modules(center, modules, info_builder)?;
|
||||
}
|
||||
|
||||
if let Some(modules) = config.right {
|
||||
let info = ModuleInfo {
|
||||
app,
|
||||
location: ModuleLocation::Right,
|
||||
bar_position: &config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
};
|
||||
if let Some(modules) = config.end {
|
||||
let info_builder = info_builder.location(ModuleLocation::Right);
|
||||
|
||||
add_modules(right, modules, &info)?;
|
||||
add_modules(right, modules, info_builder)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -106,34 +133,185 @@ fn load_modules(
|
||||
|
||||
/// Adds modules into a provided GTK box,
|
||||
/// which should be one of its left, center or right containers.
|
||||
fn add_modules(content: >k::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) -> Result<()> {
|
||||
fn add_modules(
|
||||
content: >k::Box,
|
||||
modules: Vec<ModuleConfig>,
|
||||
info_builder: &mut ModuleInfoBuilder,
|
||||
) -> Result<()> {
|
||||
let base_popup_info = info_builder.module_name("").build()?;
|
||||
let popup = Popup::new(&base_popup_info);
|
||||
let popup = Arc::new(RwLock::new(popup));
|
||||
|
||||
macro_rules! add_module {
|
||||
($module:expr, $name:literal) => {{
|
||||
let widget = $module.into_widget(&info)?;
|
||||
widget.set_widget_name($name);
|
||||
content.add(&widget);
|
||||
debug!("Added module of type {}", $name);
|
||||
}};
|
||||
($module:expr, $id:expr, $name:literal, $send_message:ty, $receive_message:ty) => {
|
||||
let info = info_builder.module_name($name).build()?;
|
||||
|
||||
let (w_tx, w_rx) = glib::MainContext::channel::<$send_message>(glib::PRIORITY_DEFAULT);
|
||||
let (p_tx, p_rx) = glib::MainContext::channel::<$send_message>(glib::PRIORITY_DEFAULT);
|
||||
|
||||
let channel = BridgeChannel::<ModuleUpdateEvent<$send_message>>::new();
|
||||
let (ui_tx, ui_rx) = mpsc::channel::<$receive_message>(16);
|
||||
|
||||
$module.spawn_controller(&info, channel.create_sender(), ui_rx)?;
|
||||
|
||||
let context = WidgetContext {
|
||||
id: $id,
|
||||
widget_rx: w_rx,
|
||||
popup_rx: p_rx,
|
||||
tx: channel.create_sender(),
|
||||
controller_tx: ui_tx,
|
||||
};
|
||||
|
||||
let common = $module.common.clone();
|
||||
|
||||
let widget = $module.into_widget(context, &info)?;
|
||||
|
||||
let container = gtk::EventBox::new();
|
||||
container.add(&widget.widget);
|
||||
|
||||
content.add(&container);
|
||||
widget.widget.set_widget_name(info.module_name);
|
||||
|
||||
if let Some(show_if) = common.show_if {
|
||||
let script = Script::new_polling(show_if);
|
||||
let container = container.clone();
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(|(_, success)| {
|
||||
tx.send(success)
|
||||
.expect("Failed to send widget visibility toggle message");
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
rx.attach(None, move |success| {
|
||||
if success {
|
||||
container.show_all()
|
||||
} else {
|
||||
container.hide()
|
||||
};
|
||||
Continue(true)
|
||||
});
|
||||
} else {
|
||||
container.show_all();
|
||||
}
|
||||
|
||||
if let Some(on_click) = common.on_click {
|
||||
let script = Script::new_polling(on_click);
|
||||
container.connect_button_press_event(move |_, _| {
|
||||
trace!("Running on-click script");
|
||||
match await_sync(async { script.get_output().await }) {
|
||||
Ok((OutputStream::Stderr(out), _)) => error!("{out}"),
|
||||
Err(err) => error!("{err:?}"),
|
||||
_ => {}
|
||||
}
|
||||
Inhibit(false)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(tooltip) = common.tooltip {
|
||||
DynamicString::new(&tooltip, move |string| {
|
||||
container.set_tooltip_text(Some(&string));
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
let has_popup = widget.popup.is_some();
|
||||
if let Some(popup_content) = widget.popup {
|
||||
popup
|
||||
.write()
|
||||
.expect("Failed to get write lock on popup")
|
||||
.register_content($id, popup_content);
|
||||
}
|
||||
|
||||
let popup2 = Arc::clone(&popup);
|
||||
channel.recv(move |ev| {
|
||||
let popup = popup2.clone();
|
||||
match ev {
|
||||
ModuleUpdateEvent::Update(update) => {
|
||||
if has_popup {
|
||||
p_tx.send(update.clone())
|
||||
.expect("Failed to send update to popup");
|
||||
}
|
||||
|
||||
w_tx.send(update).expect("Failed to send update to module");
|
||||
}
|
||||
ModuleUpdateEvent::TogglePopup(geometry) => {
|
||||
debug!("Toggling popup for {} [#{}]", $name, $id);
|
||||
let popup = popup.read().expect("Failed to get read lock on popup");
|
||||
if popup.is_visible() {
|
||||
popup.hide()
|
||||
} else {
|
||||
popup.show_content($id);
|
||||
popup.show(geometry);
|
||||
}
|
||||
}
|
||||
ModuleUpdateEvent::OpenPopup(geometry) => {
|
||||
debug!("Opening popup for {} [#{}]", $name, $id);
|
||||
|
||||
let popup = popup.read().expect("Failed to get read lock on popup");
|
||||
popup.hide();
|
||||
popup.show_content($id);
|
||||
popup.show(geometry);
|
||||
}
|
||||
ModuleUpdateEvent::ClosePopup => {
|
||||
debug!("Closing popup for {} [#{}]", $name, $id);
|
||||
|
||||
let popup = popup.read().expect("Failed to get read lock on popup");
|
||||
popup.hide();
|
||||
}
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
for config in modules {
|
||||
for (id, config) in modules.into_iter().enumerate() {
|
||||
match config {
|
||||
ModuleConfig::Clock(module) => add_module!(module, "clock"),
|
||||
ModuleConfig::Mpd(module) => add_module!(module, "mpd"),
|
||||
ModuleConfig::Tray(module) => add_module!(module, "tray"),
|
||||
ModuleConfig::Workspaces(module) => add_module!(module, "workspaces"),
|
||||
ModuleConfig::SysInfo(module) => add_module!(module, "sysinfo"),
|
||||
ModuleConfig::Launcher(module) => add_module!(module, "launcher"),
|
||||
ModuleConfig::Script(module) => add_module!(module, "script"),
|
||||
ModuleConfig::Focused(module) => add_module!(module, "focused"),
|
||||
ModuleConfig::Clock(module) => {
|
||||
add_module!(module, id, "clock", DateTime<Local>, ());
|
||||
}
|
||||
ModuleConfig::Script(module) => {
|
||||
add_module!(module, id, "script", String, ());
|
||||
}
|
||||
ModuleConfig::SysInfo(module) => {
|
||||
add_module!(module, id, "sysinfo", HashMap<String, String>, ());
|
||||
}
|
||||
ModuleConfig::Focused(module) => {
|
||||
add_module!(module, id, "focused", (String, String), ());
|
||||
}
|
||||
ModuleConfig::Workspaces(module) => {
|
||||
add_module!(module, id, "workspaces", WorkspaceUpdate, String);
|
||||
}
|
||||
ModuleConfig::Tray(module) => {
|
||||
add_module!(module, id, "tray", NotifierItemMessage, NotifierItemCommand);
|
||||
}
|
||||
ModuleConfig::Mpd(module) => {
|
||||
add_module!(module, id, "mpd", Option<SongUpdate>, PlayerCommand);
|
||||
}
|
||||
ModuleConfig::Launcher(module) => {
|
||||
add_module!(module, id, "launcher", LauncherUpdate, ItemEvent);
|
||||
}
|
||||
ModuleConfig::Custom(module) => {
|
||||
add_module!(module, id, "custom", (), ExecEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets up GTK layer shell for a provided aplication window.
|
||||
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::set_monitor(win, monitor);
|
||||
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
|
||||
@@ -144,16 +322,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::Right, 0);
|
||||
|
||||
let bar_orientation = position.get_orientation();
|
||||
|
||||
gtk_layer_shell::set_anchor(
|
||||
win,
|
||||
gtk_layer_shell::Edge::Top,
|
||||
position == &BarPosition::Top,
|
||||
position == BarPosition::Top
|
||||
|| (bar_orientation == Orientation::Vertical && anchor_to_edges),
|
||||
);
|
||||
gtk_layer_shell::set_anchor(
|
||||
win,
|
||||
gtk_layer_shell::Edge::Bottom,
|
||||
position == &BarPosition::Bottom,
|
||||
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
43
src/bridge_channel.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
4
src/clients/mod.rs
Normal file
4
src/clients/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod mpd;
|
||||
pub mod sway;
|
||||
pub mod system_tray;
|
||||
pub mod wayland;
|
||||
167
src/clients/mpd.rs
Normal file
167
src/clients/mpd.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use lazy_static::lazy_static;
|
||||
use mpd_client::client::{CommandError, Connection, ConnectionEvent, Subsystem};
|
||||
use mpd_client::commands::Command;
|
||||
use mpd_client::protocol::MpdProtocolError;
|
||||
use mpd_client::responses::Status;
|
||||
use mpd_client::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{TcpStream, UnixStream};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::broadcast::{channel, error::SendError, Receiver, Sender};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tracing::debug;
|
||||
|
||||
lazy_static! {
|
||||
static ref CONNECTIONS: Arc<Mutex<HashMap<String, Arc<MpdClient>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
pub struct MpdClient {
|
||||
client: Client,
|
||||
tx: Sender<()>,
|
||||
_rx: Receiver<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MpdConnectionError {
|
||||
MaxRetries,
|
||||
ProtocolError(MpdProtocolError),
|
||||
}
|
||||
|
||||
impl Display for MpdConnectionError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MaxRetries => write!(f, "Reached max retries"),
|
||||
Self::ProtocolError(e) => write!(f, "{:?}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MpdConnectionError {}
|
||||
|
||||
impl MpdClient {
|
||||
async fn new(host: &str) -> Result<Self, MpdConnectionError> {
|
||||
debug!("Creating new MPD connection to {}", host);
|
||||
|
||||
let (client, mut state_changes) =
|
||||
wait_for_connection(host, Duration::from_secs(5), None).await?;
|
||||
|
||||
let (tx, rx) = channel(16);
|
||||
let tx2 = tx.clone();
|
||||
|
||||
spawn(async move {
|
||||
while let Some(change) = state_changes.next().await {
|
||||
debug!("Received state change: {:?}", change);
|
||||
|
||||
if let ConnectionEvent::SubsystemChange(
|
||||
Subsystem::Player | Subsystem::Queue | Subsystem::Mixer,
|
||||
) = change
|
||||
{
|
||||
tx2.send(())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), SendError<()>>(())
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
tx,
|
||||
_rx: rx,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe(&self) -> Receiver<()> {
|
||||
self.tx.subscribe()
|
||||
}
|
||||
|
||||
pub async fn command<C: Command>(&self, command: C) -> Result<C::Response, CommandError> {
|
||||
self.client.command(command).await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_client(host: &str) -> Result<Arc<MpdClient>, MpdConnectionError> {
|
||||
let mut connections = CONNECTIONS.lock().await;
|
||||
match connections.get(host) {
|
||||
None => {
|
||||
let client = MpdClient::new(host).await?;
|
||||
let client = Arc::new(client);
|
||||
connections.insert(host.to_string(), Arc::clone(&client));
|
||||
Ok(client)
|
||||
}
|
||||
Some(client) => Ok(Arc::clone(client)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_connection(
|
||||
host: &str,
|
||||
interval: Duration,
|
||||
max_retries: Option<usize>,
|
||||
) -> Result<Connection, MpdConnectionError> {
|
||||
let mut retries = 0;
|
||||
let max_retries = max_retries.unwrap_or(usize::MAX);
|
||||
|
||||
loop {
|
||||
if retries == max_retries {
|
||||
break Err(MpdConnectionError::MaxRetries);
|
||||
}
|
||||
|
||||
retries += 1;
|
||||
|
||||
match try_get_mpd_conn(host).await {
|
||||
Ok(conn) => break Ok(conn),
|
||||
Err(err) => {
|
||||
if retries == max_retries {
|
||||
break Err(MpdConnectionError::ProtocolError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycles through each MPD host and
|
||||
/// returns the first one which connects,
|
||||
/// or none if there are none
|
||||
async fn try_get_mpd_conn(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
if is_unix_socket(host) {
|
||||
connect_unix(host).await
|
||||
} else {
|
||||
connect_tcp(host).await
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unix_socket(host: &str) -> bool {
|
||||
let path = PathBuf::from(host);
|
||||
path.exists()
|
||||
&& path
|
||||
.metadata()
|
||||
.map_or(false, |metadata| metadata.file_type().is_socket())
|
||||
}
|
||||
|
||||
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = UnixStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = TcpStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
/// Gets the duration of the current song
|
||||
pub fn get_duration(status: &Status) -> Option<u64> {
|
||||
status.duration.map(|duration| duration.as_secs())
|
||||
}
|
||||
|
||||
/// Gets the elapsed time of the current song
|
||||
pub fn get_elapsed(status: &Status) -> Option<u64> {
|
||||
status.elapsed.map(|duration| duration.as_secs())
|
||||
}
|
||||
74
src/clients/sway.rs
Normal file
74
src/clients/sway.rs
Normal 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
|
||||
}
|
||||
74
src/clients/system_tray.rs
Normal file
74
src/clients/system_tray.rs
Normal 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
|
||||
}
|
||||
129
src/clients/wayland/client.rs
Normal file
129
src/clients/wayland/client.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use super::toplevel::{ToplevelEvent, ToplevelInfo};
|
||||
use super::toplevel_manager::listen_for_toplevels;
|
||||
use super::ToplevelChange;
|
||||
use super::{Env, ToplevelHandler};
|
||||
use color_eyre::Report;
|
||||
use indexmap::IndexMap;
|
||||
use smithay_client_toolkit::environment::Environment;
|
||||
use smithay_client_toolkit::output::{with_output_info, OutputInfo};
|
||||
use smithay_client_toolkit::reexports::calloop;
|
||||
use smithay_client_toolkit::{new_default_environment, WaylandSource};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::time::Duration;
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::{error, 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<IndexMap<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(IndexMap::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?
|
||||
if let Err(err) = event_loop.dispatch(Duration::from_millis(50), &mut ()) {
|
||||
error!(
|
||||
"{:?}",
|
||||
Report::new(err).wrap_err("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()
|
||||
}
|
||||
}
|
||||
53
src/clients/wayland/mod.rs
Normal file
53
src/clients/wayland/mod.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
mod client;
|
||||
mod toplevel;
|
||||
mod toplevel_manager;
|
||||
|
||||
extern crate smithay_client_toolkit as sctk;
|
||||
|
||||
use async_once::AsyncOnce;
|
||||
use lazy_static::lazy_static;
|
||||
pub use toplevel::{ToplevelChange, ToplevelEvent, ToplevelInfo};
|
||||
use toplevel_manager::{ToplevelHandler, ToplevelHandling, ToplevelStatusListener};
|
||||
use wayland_client::{Attached, DispatchData, Interface};
|
||||
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
|
||||
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
|
||||
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
|
||||
};
|
||||
|
||||
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/clients/wayland/toplevel.rs
Normal file
151
src/clients/wayland/toplevel.rs
Normal 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
|
||||
}
|
||||
}
|
||||
164
src/clients/wayland/toplevel_manager.rs
Normal file
164
src/clients/wayland/toplevel_manager.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
use super::toplevel::{Toplevel, ToplevelEvent};
|
||||
use super::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))
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use std::slice::{Iter, IterMut};
|
||||
|
||||
/// An ordered map.
|
||||
/// Internally this is just two vectors -
|
||||
/// one for keys and one for values.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct Collection<TKey, TData> {
|
||||
keys: Vec<TKey>,
|
||||
values: Vec<TData>,
|
||||
}
|
||||
|
||||
impl<TKey: PartialEq, TData> Collection<TKey, TData> {
|
||||
/// Creates a new empty collection.
|
||||
pub const fn new() -> Self {
|
||||
Self {
|
||||
keys: vec![],
|
||||
values: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts a new key/value pair at the end of the collection.
|
||||
pub fn insert(&mut self, key: TKey, value: TData) {
|
||||
self.keys.push(key);
|
||||
self.values.push(value);
|
||||
|
||||
assert_eq!(self.keys.len(), self.values.len());
|
||||
}
|
||||
|
||||
/// Gets a reference of the value for the specified key
|
||||
/// if it exists in the collection.
|
||||
pub fn get(&self, key: &TKey) -> Option<&TData> {
|
||||
let index = self.keys.iter().position(|k| k == key);
|
||||
match index {
|
||||
Some(index) => self.values.get(index),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let index = self.keys.iter().position(|k| k == key);
|
||||
match index {
|
||||
Some(index) => self.values.get_mut(index),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the key/value from the collection
|
||||
/// if it exists
|
||||
/// and returns the removed value.
|
||||
pub fn remove(&mut self, key: &TKey) -> Option<TData> {
|
||||
assert_eq!(self.keys.len(), self.values.len());
|
||||
|
||||
let index = self.keys.iter().position(|k| k == key);
|
||||
if let Some(index) = index {
|
||||
self.keys.remove(index);
|
||||
Some(self.values.remove(index))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the length of the collection.
|
||||
pub fn len(&self) -> usize {
|
||||
self.keys.len()
|
||||
}
|
||||
|
||||
/// Gets a reference to the first value in the collection.
|
||||
pub fn first(&self) -> Option<&TData> {
|
||||
self.values.first()
|
||||
}
|
||||
|
||||
/// Gets the values as a slice.
|
||||
pub fn as_slice(&self) -> &[TData] {
|
||||
self.values.as_slice()
|
||||
}
|
||||
|
||||
/// Checks whether the collection is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.keys.is_empty()
|
||||
}
|
||||
|
||||
/// Gets an iterator for the collection.
|
||||
pub fn iter(&self) -> Iter<'_, TData> {
|
||||
self.values.iter()
|
||||
}
|
||||
|
||||
/// Gets a mutable iterator for the collection
|
||||
pub fn iter_mut(&mut self) -> IterMut<'_, TData> {
|
||||
self.values.iter_mut()
|
||||
}
|
||||
}
|
||||
|
||||
impl<TKey: PartialEq, TData> From<(TKey, TData)> for Collection<TKey, TData> {
|
||||
fn from((key, value): (TKey, TData)) -> Self {
|
||||
let mut collection = Self::new();
|
||||
collection.insert(key, value);
|
||||
collection
|
||||
}
|
||||
}
|
||||
|
||||
impl<TKey: PartialEq, TData> FromIterator<(TKey, TData)> for Collection<TKey, TData> {
|
||||
fn from_iter<T: IntoIterator<Item = (TKey, TData)>>(iter: T) -> Self {
|
||||
let mut collection = Self::new();
|
||||
for (key, value) in iter {
|
||||
collection.insert(key, value);
|
||||
}
|
||||
|
||||
collection
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, TKey: PartialEq, TData> IntoIterator for &'a Collection<TKey, TData> {
|
||||
type Item = &'a TData;
|
||||
type IntoIter = CollectionIntoIterator<'a, TKey, TData>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
CollectionIntoIterator {
|
||||
collection: self,
|
||||
index: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CollectionIntoIterator<'a, TKey, TData> {
|
||||
collection: &'a Collection<TKey, TData>,
|
||||
index: usize,
|
||||
}
|
||||
|
||||
impl<'a, TKey: PartialEq, TData> Iterator for CollectionIntoIterator<'a, TKey, TData> {
|
||||
type Item = &'a TData;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
let res = self.collection.values.get(self.index);
|
||||
self.index += 1;
|
||||
res
|
||||
}
|
||||
}
|
||||
|
||||
impl<TKey: PartialEq, TData> Default for Collection<TKey, TData> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::modules::clock::ClockModule;
|
||||
use crate::modules::custom::CustomModule;
|
||||
use crate::modules::focused::FocusedModule;
|
||||
use crate::modules::launcher::LauncherModule;
|
||||
use crate::modules::mpd::MpdModule;
|
||||
@@ -6,17 +7,27 @@ use crate::modules::script::ScriptModule;
|
||||
use crate::modules::sysinfo::SysInfoModule;
|
||||
use crate::modules::tray::TrayModule;
|
||||
use crate::modules::workspaces::WorkspacesModule;
|
||||
use crate::script::ScriptInput;
|
||||
use color_eyre::eyre::{Context, ContextCompat};
|
||||
use color_eyre::{eyre, Help, Report};
|
||||
use dirs::config_dir;
|
||||
use eyre::Result;
|
||||
use gtk::Orientation;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{env, fs};
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub struct CommonConfig {
|
||||
pub show_if: Option<ScriptInput>,
|
||||
pub on_click: Option<ScriptInput>,
|
||||
pub tooltip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ModuleConfig {
|
||||
Clock(ClockModule),
|
||||
Mpd(MpdModule),
|
||||
@@ -26,6 +37,7 @@ pub enum ModuleConfig {
|
||||
Launcher(LauncherModule),
|
||||
Script(ScriptModule),
|
||||
Focused(FocusedModule),
|
||||
Custom(CustomModule),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
@@ -35,11 +47,13 @@ pub enum MonitorConfig {
|
||||
Multiple(Vec<Config>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BarPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Default for BarPosition {
|
||||
@@ -48,16 +62,36 @@ impl Default for BarPosition {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
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)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_bar_position")]
|
||||
pub position: BarPosition,
|
||||
#[serde(default = "default_true")]
|
||||
pub anchor_to_edges: bool,
|
||||
#[serde(default = "default_bar_height")]
|
||||
pub height: i32,
|
||||
|
||||
pub left: Option<Vec<ModuleConfig>>,
|
||||
pub start: Option<Vec<ModuleConfig>>,
|
||||
pub center: Option<Vec<ModuleConfig>>,
|
||||
pub right: Option<Vec<ModuleConfig>>,
|
||||
pub end: Option<Vec<ModuleConfig>>,
|
||||
|
||||
pub monitors: Option<HashMap<String, MonitorConfig>>,
|
||||
}
|
||||
@@ -73,18 +107,23 @@ const fn default_bar_height() -> i32 {
|
||||
impl Config {
|
||||
/// Attempts to load the config file from file,
|
||||
/// parse it and return a new instance of `Self`.
|
||||
#[instrument]
|
||||
pub fn load() -> Result<Self> {
|
||||
let config_path = if let Ok(config_path) = env::var("IRONBAR_CONFIG") {
|
||||
let path = PathBuf::from(config_path);
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(Report::msg("Specified config file does not exist")
|
||||
let config_path = env::var("IRONBAR_CONFIG").map_or_else(
|
||||
|_| Self::try_find_config(),
|
||||
|config_path| {
|
||||
let path = PathBuf::from(config_path);
|
||||
if path.exists() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(Report::msg(format!(
|
||||
"Specified config file does not exist: {}",
|
||||
path.display()
|
||||
))
|
||||
.note("Config file was specified using `IRONBAR_CONFIG` environment variable"))
|
||||
}
|
||||
} else {
|
||||
Self::try_find_config()
|
||||
}?;
|
||||
}
|
||||
},
|
||||
)?;
|
||||
|
||||
Self::load_file(&config_path)
|
||||
}
|
||||
@@ -93,6 +132,7 @@ impl Config {
|
||||
/// by checking each valid format's extension.
|
||||
///
|
||||
/// Returns the path of the first valid match, if any.
|
||||
#[instrument]
|
||||
fn try_find_config() -> Result<PathBuf> {
|
||||
let config_dir = config_dir().wrap_err("Failed to locate user config dir")?;
|
||||
|
||||
@@ -110,10 +150,15 @@ impl Config {
|
||||
}
|
||||
});
|
||||
|
||||
match file {
|
||||
Some(file) => Ok(file),
|
||||
None => Err(Report::msg("Could not find config file")),
|
||||
}
|
||||
file.map_or_else(
|
||||
|| {
|
||||
Err(Report::msg("Could not find config file")
|
||||
.suggestion("Ironbar does not include a configuration out of the box")
|
||||
.suggestion("A guide on writing a config can be found on the wiki:")
|
||||
.suggestion("https://github.com/JakeStanger/ironbar/wiki/configuration-guide"))
|
||||
},
|
||||
Ok,
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads the config file at the specified path
|
||||
@@ -130,14 +175,7 @@ impl Config {
|
||||
"json" => serde_json::from_slice(&file).wrap_err("Invalid JSON config"),
|
||||
"toml" => toml::from_slice(&file).wrap_err("Invalid TOML config"),
|
||||
"yaml" | "yml" => serde_yaml::from_slice(&file).wrap_err("Invalid YAML config"),
|
||||
"corn" => {
|
||||
// corn doesn't support deserialization yet
|
||||
// so serialize the interpreted result then deserialize that
|
||||
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)?)?)
|
||||
}
|
||||
"corn" => libcorn::from_slice(&file).wrap_err("Invalid Corn config"),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
143
src/dynamic_string.rs
Normal file
143
src/dynamic_string.rs
Normal file
@@ -0,0 +1,143 @@
|
||||
use crate::script::{OutputStream, Script};
|
||||
use gtk::prelude::*;
|
||||
use indexmap::IndexMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::spawn;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum DynamicStringSegment {
|
||||
Static(String),
|
||||
Dynamic(Script),
|
||||
}
|
||||
|
||||
pub struct DynamicString {
|
||||
// pub label: gtk::Label,
|
||||
}
|
||||
|
||||
impl DynamicString {
|
||||
pub fn new<F>(input: &str, f: F) -> Self
|
||||
where
|
||||
F: FnMut(String) -> Continue + 'static,
|
||||
{
|
||||
let mut segments = vec![];
|
||||
|
||||
let mut chars = input.chars().collect::<Vec<_>>();
|
||||
while !chars.is_empty() {
|
||||
let char = &chars[..=1];
|
||||
|
||||
let (token, skip) = if let ['{', '{'] = char {
|
||||
const SKIP_BRACKETS: usize = 4;
|
||||
|
||||
let str = chars
|
||||
.iter()
|
||||
.skip(2)
|
||||
.enumerate()
|
||||
.take_while(|(i, &c)| c != '}' && chars[i + 1] != '}')
|
||||
.map(|(_, c)| c)
|
||||
.collect::<String>();
|
||||
|
||||
let len = str.len();
|
||||
|
||||
(
|
||||
DynamicStringSegment::Dynamic(Script::from(str.as_str())),
|
||||
len + SKIP_BRACKETS,
|
||||
)
|
||||
} else {
|
||||
let str = chars
|
||||
.iter()
|
||||
.enumerate()
|
||||
.take_while(|(i, &c)| !(c == '{' && chars[i + 1] == '{'))
|
||||
.map(|(_, c)| c)
|
||||
.collect::<String>();
|
||||
|
||||
let len = str.len();
|
||||
|
||||
(DynamicStringSegment::Static(str), len)
|
||||
};
|
||||
|
||||
assert_ne!(skip, 0);
|
||||
|
||||
segments.push(token);
|
||||
chars.drain(..skip);
|
||||
}
|
||||
|
||||
let label_parts = Arc::new(Mutex::new(IndexMap::new()));
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
for (i, segment) in segments.into_iter().enumerate() {
|
||||
match segment {
|
||||
DynamicStringSegment::Static(str) => {
|
||||
label_parts
|
||||
.lock()
|
||||
.expect("Failed to get lock on label parts")
|
||||
.insert(i, str);
|
||||
}
|
||||
DynamicStringSegment::Dynamic(script) => {
|
||||
let tx = tx.clone();
|
||||
let label_parts = label_parts.clone();
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(|(out, _)| {
|
||||
if let OutputStream::Stdout(out) = out {
|
||||
let mut label_parts = label_parts
|
||||
.lock()
|
||||
.expect("Failed to get lock on label parts");
|
||||
|
||||
label_parts
|
||||
// .lock()
|
||||
// .expect("Failed to get lock on label parts")
|
||||
.insert(i, out);
|
||||
|
||||
let string = label_parts
|
||||
.iter()
|
||||
.map(|(_, part)| part.as_str())
|
||||
.collect::<String>();
|
||||
|
||||
tx.send(string).expect("Failed to send update");
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initialize
|
||||
{
|
||||
let label_parts = label_parts
|
||||
.lock()
|
||||
.expect("Failed to get lock on label parts")
|
||||
.iter()
|
||||
.map(|(_, part)| part.as_str())
|
||||
.collect::<String>();
|
||||
|
||||
tx.send(label_parts).expect("Failed to send update");
|
||||
}
|
||||
|
||||
rx.attach(None, f);
|
||||
|
||||
// Self { label }
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test() {
|
||||
// TODO: see if we can run gtk tests in ci
|
||||
if gtk::init().is_ok() {
|
||||
let label = gtk::Label::new(None);
|
||||
DynamicString::new(
|
||||
"Uptime: {{1000:uptime -p | cut -d ' ' -f2-}}",
|
||||
move |string| {
|
||||
label.set_label(&string);
|
||||
Continue(true)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
23
src/icon.rs
23
src/icon.rs
@@ -68,17 +68,12 @@ fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
|
||||
|
||||
/// Attempts to get the icon name from the app's `.desktop` file.
|
||||
fn get_desktop_icon_name(app_id: &str) -> Option<String> {
|
||||
match find_desktop_file(app_id) {
|
||||
Some(file) => {
|
||||
let map = parse_desktop_file(file);
|
||||
|
||||
match map {
|
||||
Ok(map) => map.get("Icon").map(std::string::ToString::to_string),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
find_desktop_file(app_id).and_then(|file| {
|
||||
let map = parse_desktop_file(file);
|
||||
map.map_or(None, |map| {
|
||||
map.get("Icon").map(std::string::ToString::to_string)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
enum IconLocation {
|
||||
@@ -137,11 +132,7 @@ pub fn get_icon(theme: &IconTheme, app_id: &str, size: i32) -> Option<Pixbuf> {
|
||||
match icon_location {
|
||||
Some(IconLocation::Theme(icon_name)) => {
|
||||
let icon = theme.load_icon(&icon_name, size, IconLookupFlags::FORCE_SIZE);
|
||||
|
||||
match icon {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => None,
|
||||
}
|
||||
icon.map_or(None, |icon| icon)
|
||||
}
|
||||
Some(IconLocation::File(path)) => Pixbuf::from_file_at_scale(path, size, size, true).ok(),
|
||||
None => None,
|
||||
|
||||
@@ -31,10 +31,15 @@ impl<'a> MakeWriter<'a> for MakeFileWriter {
|
||||
/// 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> {
|
||||
const DEFAULT_LOG: &str = "info";
|
||||
const DEFAULT_FILE_LOG: &str = "warn";
|
||||
|
||||
let fmt_layer = fmt::layer().with_target(true);
|
||||
let filter_layer = EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("info"))?;
|
||||
let file_filter_layer =
|
||||
EnvFilter::try_from_default_env().or_else(|_| EnvFilter::try_new("warn"))?;
|
||||
let filter_layer =
|
||||
EnvFilter::try_from_env("IRONBAR_LOG").or_else(|_| EnvFilter::try_new(DEFAULT_LOG))?;
|
||||
|
||||
let file_filter_layer = EnvFilter::try_from_env("IRONBAR_FILE_LOG")
|
||||
.or_else(|_| EnvFilter::try_new(DEFAULT_FILE_LOG))?;
|
||||
|
||||
let log_path = data_dir().unwrap_or(env::current_dir()?).join("ironbar");
|
||||
|
||||
|
||||
126
src/main.rs
126
src/main.rs
@@ -1,32 +1,44 @@
|
||||
mod bar;
|
||||
mod collection;
|
||||
mod bridge_channel;
|
||||
mod clients;
|
||||
mod config;
|
||||
mod dynamic_string;
|
||||
mod icon;
|
||||
mod logging;
|
||||
mod modules;
|
||||
mod popup;
|
||||
mod script;
|
||||
mod style;
|
||||
mod sway;
|
||||
|
||||
use crate::bar::create_bar;
|
||||
use crate::config::{Config, MonitorConfig};
|
||||
use crate::style::load_css;
|
||||
use crate::sway::{get_client, SwayOutput};
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::Report;
|
||||
use dirs::config_dir;
|
||||
use gtk::gdk::Display;
|
||||
use gtk::prelude::*;
|
||||
use gtk::Application;
|
||||
use ksway::IpcCommand;
|
||||
use std::env;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::process::exit;
|
||||
use std::{env, panic};
|
||||
use tokio::runtime::Handle;
|
||||
use tokio::task::block_in_place;
|
||||
|
||||
use crate::logging::install_tracing;
|
||||
use clients::wayland::{self, WaylandClient};
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[repr(i32)]
|
||||
enum ErrorCode {
|
||||
GtkDisplay = 1,
|
||||
CreateBars = 2,
|
||||
Config = 3,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Disable backtraces by default
|
||||
@@ -38,53 +50,67 @@ async fn main() -> Result<()> {
|
||||
// otherwise file logging drops
|
||||
let _guard = install_tracing()?;
|
||||
|
||||
color_eyre::install()?;
|
||||
let hook_builder = color_eyre::config::HookBuilder::default();
|
||||
let (panic_hook, eyre_hook) = hook_builder.into_hooks();
|
||||
|
||||
eyre_hook.install()?;
|
||||
|
||||
// custom hook allows tracing_appender to capture panics
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
error!("{}", panic_hook.panic_report(panic_info));
|
||||
}));
|
||||
|
||||
info!("Ironbar version {}", VERSION);
|
||||
info!("Starting application");
|
||||
|
||||
let wayland_client = wayland::get_client().await;
|
||||
|
||||
let app = Application::builder()
|
||||
.application_id("dev.jstanger.ironbar")
|
||||
.build();
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
let display = match Display::default() {
|
||||
Some(display) => display,
|
||||
None => {
|
||||
let display = Display::default().map_or_else(
|
||||
|| {
|
||||
let report = Report::msg("Failed to get default GTK display");
|
||||
error!("{:?}", report);
|
||||
exit(1)
|
||||
}
|
||||
};
|
||||
exit(ErrorCode::GtkDisplay as i32)
|
||||
},
|
||||
|display| display,
|
||||
);
|
||||
|
||||
let config = match Config::load() {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
error!("{:?}", err);
|
||||
Config::default()
|
||||
exit(ErrorCode::Config as i32)
|
||||
}
|
||||
};
|
||||
debug!("Loaded config file");
|
||||
|
||||
if let Err(err) = create_bars(app, &display, &config) {
|
||||
if let Err(err) = create_bars(app, &display, wayland_client, &config) {
|
||||
error!("{:?}", err);
|
||||
exit(2);
|
||||
exit(ErrorCode::CreateBars as i32);
|
||||
}
|
||||
|
||||
debug!("Created bars");
|
||||
|
||||
let style_path = match config_dir() {
|
||||
Some(dir) => dir.join("ironbar").join("style.css"),
|
||||
None => {
|
||||
let report = Report::msg("Failed to locate user config dir");
|
||||
error!("{:?}", report);
|
||||
exit(3);
|
||||
}
|
||||
};
|
||||
let style_path = env::var("IRONBAR_CSS").ok().map_or_else(
|
||||
|| {
|
||||
config_dir().map_or_else(
|
||||
|| {
|
||||
let report = Report::msg("Failed to locate user config dir");
|
||||
error!("{:?}", report);
|
||||
exit(ErrorCode::CreateBars as i32);
|
||||
},
|
||||
|dir| dir.join("ironbar").join("style.css"),
|
||||
)
|
||||
},
|
||||
PathBuf::from,
|
||||
);
|
||||
|
||||
if style_path.exists() {
|
||||
load_css(style_path);
|
||||
debug!("Loaded CSS watcher file");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -96,42 +122,40 @@ async fn main() -> Result<()> {
|
||||
}
|
||||
|
||||
/// Creates each of the bars across each of the (configured) outputs.
|
||||
fn create_bars(app: &Application, display: &Display, config: &Config) -> Result<()> {
|
||||
let outputs = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
fn create_bars(
|
||||
app: &Application,
|
||||
display: &Display,
|
||||
wl: &WaylandClient,
|
||||
config: &Config,
|
||||
) -> Result<()> {
|
||||
let outputs = wl.outputs.as_slice();
|
||||
|
||||
let outputs = sway.ipc(IpcCommand::GetOutputs);
|
||||
|
||||
match outputs {
|
||||
Ok(outputs) => Ok(outputs),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}?;
|
||||
|
||||
let outputs = serde_json::from_slice::<Vec<SwayOutput>>(&outputs)?;
|
||||
|
||||
debug!("Received {} outputs from Sway IPC", outputs.len());
|
||||
debug!("Received {} outputs from Wayland", outputs.len());
|
||||
debug!("Outputs: {:?}", outputs);
|
||||
|
||||
let num_monitors = display.n_monitors();
|
||||
|
||||
for i in 0..num_monitors {
|
||||
let monitor = display.monitor(i).ok_or_else(|| Report::msg("GTK and Sway are reporting a different number of outputs - this is a severe bug and should never happen"))?;
|
||||
let monitor_name = &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"))?.name;
|
||||
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;
|
||||
|
||||
info!("Creating bar on '{}'", monitor_name);
|
||||
|
||||
// TODO: Could we use an Arc<Config> here to avoid cloning?
|
||||
// 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()),
|
||||
|| {
|
||||
info!("Creating bar on '{}'", monitor_name);
|
||||
create_bar(app, &monitor, monitor_name, config.clone())
|
||||
},
|
||||
|config| {
|
||||
let config = config.get(monitor_name);
|
||||
match &config {
|
||||
Some(MonitorConfig::Single(config)) => {
|
||||
info!("Creating bar on '{}'", monitor_name);
|
||||
create_bar(app, &monitor, monitor_name, config.clone())
|
||||
}
|
||||
Some(MonitorConfig::Multiple(configs)) => {
|
||||
for config in configs {
|
||||
info!("Creating bar on '{}'", monitor_name);
|
||||
create_bar(app, &monitor, monitor_name, config.clone())?;
|
||||
}
|
||||
|
||||
@@ -145,3 +169,15 @@ fn create_bars(app: &Application, display: &Display, config: &Config) -> Result<
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
124
src/modules/clock.rs
Normal file
124
src/modules/clock.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::config::CommonConfig;
|
||||
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")]
|
||||
format: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
mod popup;
|
||||
|
||||
use self::popup::Popup;
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use chrono::Local;
|
||||
use color_eyre::Result;
|
||||
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) -> Result<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).expect("Failed to send date string");
|
||||
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(button)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +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).expect("Failed to send date string");
|
||||
|
||||
sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
rx.attach(None, move |s| {
|
||||
clock.set_label(s.as_str());
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
256
src/modules/custom.rs
Normal file
256
src/modules/custom.rs
Normal file
@@ -0,0 +1,256 @@
|
||||
use crate::config::CommonConfig;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::{ButtonGeometry, Popup};
|
||||
use crate::script::Script;
|
||||
use color_eyre::{Report, Result};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Label, Orientation};
|
||||
use serde::Deserialize;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CustomModule {
|
||||
/// Container class name
|
||||
class: Option<String>,
|
||||
/// Widgets to add to the bar container
|
||||
bar: Vec<Widget>,
|
||||
/// Widgets to add to the popup container
|
||||
popup: Option<Vec<Widget>>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// Attempts to parse an `Orientation` from `String`
|
||||
fn try_get_orientation(orientation: &str) -> Result<Orientation> {
|
||||
match orientation.to_lowercase().as_str() {
|
||||
"horizontal" | "h" => Ok(Orientation::Horizontal),
|
||||
"vertical" | "v" => Ok(Orientation::Vertical),
|
||||
_ => Err(Report::msg("Invalid orientation string in config")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget attributes
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Widget {
|
||||
/// Type of GTK widget to add
|
||||
#[serde(rename = "type")]
|
||||
widget_type: WidgetType,
|
||||
widgets: Option<Vec<Widget>>,
|
||||
label: Option<String>,
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
on_click: Option<String>,
|
||||
orientation: Option<String>,
|
||||
}
|
||||
|
||||
/// Supported GTK widget types
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum WidgetType {
|
||||
Box,
|
||||
Label,
|
||||
Button,
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
/// Creates this widget and adds it to the parent container
|
||||
fn add_to(self, parent: >k::Box, tx: Sender<ExecEvent>, bar_orientation: Orientation) {
|
||||
match self.widget_type {
|
||||
WidgetType::Box => parent.add(&self.into_box(&tx, bar_orientation)),
|
||||
WidgetType::Label => parent.add(&self.into_label()),
|
||||
WidgetType::Button => parent.add(&self.into_button(tx, bar_orientation)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a `gtk::Box` from this widget
|
||||
fn into_box(self, tx: &Sender<ExecEvent>, bar_orientation: Orientation) -> gtk::Box {
|
||||
let mut builder = gtk::Box::builder();
|
||||
|
||||
if let Some(name) = self.name {
|
||||
builder = builder.name(&name);
|
||||
}
|
||||
|
||||
if let Some(orientation) = self.orientation {
|
||||
builder = builder
|
||||
.orientation(try_get_orientation(&orientation).unwrap_or(Orientation::Horizontal));
|
||||
}
|
||||
|
||||
let container = builder.build();
|
||||
|
||||
if let Some(class) = self.class {
|
||||
container.style_context().add_class(&class);
|
||||
}
|
||||
|
||||
if let Some(widgets) = self.widgets {
|
||||
widgets
|
||||
.into_iter()
|
||||
.for_each(|widget| widget.add_to(&container, tx.clone(), bar_orientation));
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
/// Creates a `gtk::Label` from this widget
|
||||
fn into_label(self) -> Label {
|
||||
let mut builder = Label::builder().use_markup(true);
|
||||
|
||||
if let Some(name) = self.name {
|
||||
builder = builder.name(&name);
|
||||
}
|
||||
|
||||
let label = builder.build();
|
||||
|
||||
if let Some(class) = self.class {
|
||||
label.style_context().add_class(&class);
|
||||
}
|
||||
|
||||
let text = self.label.map_or_else(String::new, |text| text);
|
||||
|
||||
{
|
||||
let label = label.clone();
|
||||
DynamicString::new(&text, move |string| {
|
||||
label.set_label(&string);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
label
|
||||
|
||||
// DynamicString::new(label, &text)
|
||||
}
|
||||
|
||||
/// Creates a `gtk::Button` from this widget
|
||||
fn into_button(self, tx: Sender<ExecEvent>, bar_orientation: Orientation) -> Button {
|
||||
let mut builder = Button::builder();
|
||||
|
||||
if let Some(name) = self.name {
|
||||
builder = builder.name(&name);
|
||||
}
|
||||
|
||||
let button = builder.build();
|
||||
|
||||
if let Some(text) = self.label {
|
||||
let label = Label::new(None);
|
||||
label.set_use_markup(true);
|
||||
label.set_markup(&text);
|
||||
button.add(&label);
|
||||
}
|
||||
|
||||
if let Some(class) = self.class {
|
||||
button.style_context().add_class(&class);
|
||||
}
|
||||
|
||||
if let Some(exec) = self.on_click {
|
||||
button.connect_clicked(move |button| {
|
||||
tx.try_send(ExecEvent {
|
||||
cmd: exec.clone(),
|
||||
geometry: Popup::button_pos(button, bar_orientation),
|
||||
})
|
||||
.expect("Failed to send exec message");
|
||||
});
|
||||
}
|
||||
|
||||
button
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecEvent {
|
||||
cmd: String,
|
||||
geometry: ButtonGeometry,
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for CustomModule {
|
||||
type SendMessage = ();
|
||||
type ReceiveMessage = ExecEvent;
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
mut rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
if event.cmd.starts_with('!') {
|
||||
let script = Script::from(&event.cmd[1..]);
|
||||
|
||||
debug!("executing command: '{}'", script.cmd);
|
||||
// TODO: Migrate to use script.run
|
||||
if let Err(err) = script.get_output().await {
|
||||
error!("{err:?}");
|
||||
}
|
||||
} else if event.cmd == "popup:toggle" {
|
||||
tx.send(ModuleUpdateEvent::TogglePopup(event.geometry))
|
||||
.await
|
||||
.expect("Failed to send open popup event");
|
||||
} else if event.cmd == "popup:open" {
|
||||
tx.send(ModuleUpdateEvent::OpenPopup(event.geometry))
|
||||
.await
|
||||
.expect("Failed to send open popup event");
|
||||
} else if event.cmd == "popup:close" {
|
||||
tx.send(ModuleUpdateEvent::ClosePopup)
|
||||
.await
|
||||
.expect("Failed to send open popup event");
|
||||
} else {
|
||||
error!("Received invalid command: '{}'", event.cmd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> Result<ModuleWidget<gtk::Box>> {
|
||||
let orientation = info.bar_position.get_orientation();
|
||||
let container = gtk::Box::builder().orientation(orientation).build();
|
||||
|
||||
if let Some(ref class) = self.class {
|
||||
container.style_context().add_class(class);
|
||||
}
|
||||
|
||||
self.bar.clone().into_iter().for_each(|widget| {
|
||||
widget.add_to(&container, context.controller_tx.clone(), orientation);
|
||||
});
|
||||
|
||||
let popup = self.into_popup(context.controller_tx, context.popup_rx);
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup,
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
tx: Sender<Self::ReceiveMessage>,
|
||||
_rx: glib::Receiver<Self::SendMessage>,
|
||||
) -> Option<gtk::Box>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let container = gtk::Box::builder().name("popup-custom").build();
|
||||
|
||||
if let Some(class) = self.class {
|
||||
container
|
||||
.style_context()
|
||||
.add_class(format!("popup-{class}").as_str());
|
||||
}
|
||||
|
||||
if let Some(popup) = self.popup {
|
||||
popup
|
||||
.into_iter()
|
||||
.for_each(|widget| widget.add_to(&container, tx.clone(), Orientation::Horizontal));
|
||||
}
|
||||
|
||||
Some(container)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
use crate::icon;
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::sway::get_client;
|
||||
use crate::clients::wayland::{self, ToplevelChange};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::{await_sync, icon};
|
||||
use color_eyre::Result;
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconTheme, Image, Label, Orientation};
|
||||
use gtk::{IconTheme, Image, Label};
|
||||
use serde::Deserialize;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct FocusedModule {
|
||||
@@ -22,6 +24,9 @@ pub struct FocusedModule {
|
||||
icon_size: i32,
|
||||
/// GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
const fn default_icon_size() -> i32 {
|
||||
@@ -29,14 +34,69 @@ const fn default_icon_size() -> i32 {
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for FocusedModule {
|
||||
fn into_widget(self, _info: &ModuleInfo) -> Result<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();
|
||||
|
||||
if let Some(theme) = self.icon_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 label = Label::builder().name("label").build();
|
||||
@@ -44,59 +104,25 @@ impl Module<gtk::Box> for FocusedModule {
|
||||
container.add(&icon);
|
||||
container.add(&label);
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
let focused = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
sway.get_open_windows()?
|
||||
.into_iter()
|
||||
.find(|node| node.focused)
|
||||
};
|
||||
|
||||
if let Some(focused) = focused {
|
||||
tx.send(focused)?;
|
||||
}
|
||||
|
||||
spawn_blocking(move || {
|
||||
let srx = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
sway.subscribe_window()
|
||||
};
|
||||
|
||||
while let Ok(payload) = srx.recv() {
|
||||
let update = match payload.change.as_str() {
|
||||
"focus" => true,
|
||||
"title" => payload.container.focused,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if update {
|
||||
tx.send(payload.container)
|
||||
.expect("Failed to sendf focus update");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
rx.attach(None, move |node| {
|
||||
let value = node.name.as_deref().unwrap_or_else(|| node.get_id());
|
||||
|
||||
let pixbuf = icon::get_icon(&icon_theme, node.get_id(), self.icon_size);
|
||||
context.widget_rx.attach(None, move |(name, id)| {
|
||||
let pixbuf = icon::get_icon(&icon_theme, &id, self.icon_size);
|
||||
|
||||
if self.show_icon {
|
||||
icon.set_pixbuf(pixbuf.as_ref());
|
||||
}
|
||||
|
||||
if self.show_title {
|
||||
label.set_label(value);
|
||||
label.set_label(&name);
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(container)
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,292 +1,258 @@
|
||||
use crate::collection::Collection;
|
||||
use crate::icon::{find_desktop_file, get_icon};
|
||||
use crate::modules::launcher::open_state::OpenState;
|
||||
use crate::modules::launcher::popup::Popup;
|
||||
use crate::modules::launcher::FocusEvent;
|
||||
use crate::sway::SwayNode;
|
||||
use crate::Report;
|
||||
use color_eyre::Help;
|
||||
use super::open_state::OpenState;
|
||||
use crate::clients::wayland::ToplevelInfo;
|
||||
use crate::icon::get_icon;
|
||||
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
|
||||
use crate::modules::ModuleUpdateEvent;
|
||||
use crate::popup::Popup;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, IconTheme, Image};
|
||||
use std::process::{Command, Stdio};
|
||||
use gtk::{Button, IconTheme, Image, Orientation};
|
||||
use indexmap::IndexMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::error;
|
||||
use std::sync::RwLock;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LauncherItem {
|
||||
pub struct Item {
|
||||
pub app_id: String,
|
||||
pub favorite: bool,
|
||||
pub windows: Rc<RwLock<Collection<i32, LauncherWindow>>>,
|
||||
pub state: Arc<RwLock<State>>,
|
||||
pub button: Button,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LauncherWindow {
|
||||
pub con_id: i32,
|
||||
pub name: Option<String>,
|
||||
pub open_state: OpenState,
|
||||
pub windows: IndexMap<usize, Window>,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct State {
|
||||
pub is_xwayland: bool,
|
||||
pub open_state: OpenState,
|
||||
}
|
||||
|
||||
#[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_state: OpenState::Closed,
|
||||
is_xwayland: false,
|
||||
};
|
||||
|
||||
let item = Self {
|
||||
impl Item {
|
||||
pub fn new(app_id: String, open_state: OpenState, favorite: bool) -> Self {
|
||||
Self {
|
||||
app_id,
|
||||
favorite,
|
||||
windows: Rc::new(RwLock::new(Collection::new())),
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
button,
|
||||
};
|
||||
|
||||
item.configure_button(config);
|
||||
item
|
||||
open_state,
|
||||
windows: IndexMap::new(),
|
||||
name: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_node(node: &SwayNode, config: &ButtonConfig) -> Self {
|
||||
let button = Button::new();
|
||||
button.style_context().add_class("item");
|
||||
/// Merges the provided node into this launcher item
|
||||
pub fn merge_toplevel(&mut self, node: ToplevelInfo) -> Window {
|
||||
let id = node.id;
|
||||
|
||||
let windows = Collection::from((
|
||||
node.id,
|
||||
LauncherWindow {
|
||||
con_id: node.id,
|
||||
name: node.name.clone(),
|
||||
open_state: OpenState::from_node(node),
|
||||
},
|
||||
));
|
||||
if self.windows.is_empty() {
|
||||
self.name = node.title.clone();
|
||||
}
|
||||
|
||||
let state = State {
|
||||
open_state: OpenState::from_node(node),
|
||||
is_xwayland: node.is_xwayland(),
|
||||
};
|
||||
let window: Window = node.into();
|
||||
self.windows.insert(id, window.clone());
|
||||
|
||||
let item = Self {
|
||||
app_id: node.get_id().to_string(),
|
||||
favorite: false,
|
||||
windows: Rc::new(RwLock::new(windows)),
|
||||
state: Arc::new(RwLock::new(state)),
|
||||
button,
|
||||
};
|
||||
self.recalculate_open_state();
|
||||
|
||||
item.configure_button(config);
|
||||
item
|
||||
window
|
||||
}
|
||||
|
||||
fn configure_button(&self, config: &ButtonConfig) {
|
||||
let button = &self.button;
|
||||
pub fn unmerge_toplevel(&mut self, node: &ToplevelInfo) {
|
||||
self.windows.remove(&node.id);
|
||||
self.recalculate_open_state();
|
||||
}
|
||||
|
||||
let windows = self
|
||||
.windows
|
||||
.read()
|
||||
.expect("Failed to get read lock on windows");
|
||||
|
||||
let name = if windows.len() == 1 {
|
||||
windows
|
||||
.first()
|
||||
.expect("Failed to get first window")
|
||||
.name
|
||||
.as_ref()
|
||||
} else {
|
||||
Some(&self.app_id)
|
||||
};
|
||||
|
||||
if let Some(name) = name {
|
||||
self.set_title(name, config);
|
||||
}
|
||||
|
||||
if config.show_icons {
|
||||
let icon = get_icon(&config.icon_theme, &self.app_id, 32);
|
||||
if icon.is_some() {
|
||||
let image = Image::from_pixbuf(icon.as_ref());
|
||||
button.set_image(Some(&image));
|
||||
button.set_always_show_image(true);
|
||||
}
|
||||
}
|
||||
|
||||
let app_id = self.app_id.clone();
|
||||
let state = Arc::clone(&self.state);
|
||||
let tx_click = config.tx.clone();
|
||||
|
||||
let (focus_tx, mut focus_rx) = mpsc::channel(32);
|
||||
|
||||
button.connect_clicked(move |_| {
|
||||
let state = state.read().expect("Failed to get read lock on state");
|
||||
if state.open_state.is_open() {
|
||||
focus_tx.try_send(()).expect("Failed to send focus event");
|
||||
} else {
|
||||
// attempt to find desktop file and launch
|
||||
match find_desktop_file(&app_id) {
|
||||
Some(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?")
|
||||
);
|
||||
}
|
||||
}
|
||||
None => error!("Could not find desktop file for {}", app_id),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app_id = self.app_id.clone();
|
||||
let state = Arc::clone(&self.state);
|
||||
|
||||
spawn(async move {
|
||||
while focus_rx.recv().await == Some(()) {
|
||||
let state = state.read().expect("Failed to get read lock on state");
|
||||
if state.is_xwayland {
|
||||
tx_click
|
||||
.try_send(FocusEvent::Class(app_id.clone()))
|
||||
.expect("Failed to send focus event");
|
||||
} else {
|
||||
tx_click
|
||||
.try_send(FocusEvent::AppId(app_id.clone()))
|
||||
.expect("Failed to send focus event");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let popup = config.popup.clone();
|
||||
let popup2 = config.popup.clone();
|
||||
let windows = Rc::clone(&self.windows);
|
||||
let tx_hover = config.tx.clone();
|
||||
|
||||
button.connect_enter_notify_event(move |button, _| {
|
||||
let windows = windows.read().expect("Failed to get read lock on windows");
|
||||
if windows.len() > 1 {
|
||||
popup.set_windows(windows.as_slice(), &tx_hover);
|
||||
popup.show(button);
|
||||
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();
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
{}
|
||||
|
||||
button.connect_leave_notify_event(move |_, e| {
|
||||
let (_, y) = e.position();
|
||||
// hover boundary
|
||||
if y > 2.0 {
|
||||
popup2.hide();
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
let style = button.style_context();
|
||||
|
||||
style.add_class("launcher-item");
|
||||
self.update_button_classes(&self.state.read().expect("Failed to get read lock on state"));
|
||||
|
||||
button.show_all();
|
||||
}
|
||||
|
||||
pub fn set_title(&self, title: &str, config: &ButtonConfig) {
|
||||
if config.show_names {
|
||||
self.button.set_label(title);
|
||||
} else {
|
||||
self.button.set_tooltip_text(Some(title));
|
||||
};
|
||||
}
|
||||
|
||||
/// Updates the classnames on the GTK button
|
||||
/// based on its current state.
|
||||
///
|
||||
/// State must be passed as an arg here rather than
|
||||
/// using `self.state` to avoid a weird `RwLock` issue.
|
||||
pub fn update_button_classes(&self, state: &State) {
|
||||
let style = self.button.style_context();
|
||||
|
||||
if self.favorite {
|
||||
style.add_class("favorite");
|
||||
} else {
|
||||
style.remove_class("favorite");
|
||||
}
|
||||
|
||||
if state.open_state.is_open() {
|
||||
style.add_class("open");
|
||||
} else {
|
||||
style.remove_class("open");
|
||||
}
|
||||
|
||||
if state.open_state.is_focused() {
|
||||
style.add_class("focused");
|
||||
} else {
|
||||
style.remove_class("focused");
|
||||
}
|
||||
|
||||
if state.open_state.is_urgent() {
|
||||
style.add_class("urgent");
|
||||
} else {
|
||||
style.remove_class("urgent");
|
||||
window.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the open state for a specific window on the item
|
||||
/// and updates the item state based on all its windows.
|
||||
pub fn set_window_open_state(&self, window_id: i32, new_state: OpenState, state: &mut State) {
|
||||
let mut windows = self
|
||||
.windows
|
||||
.write()
|
||||
.expect("Failed to get write lock on windows");
|
||||
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)]);
|
||||
|
||||
let window = windows.iter_mut().find(|w| w.con_id == window_id);
|
||||
if let Some(window) = window {
|
||||
window.open_state = new_state;
|
||||
|
||||
state.open_state =
|
||||
OpenState::merge_states(windows.iter().map(|w| &w.open_state).collect());
|
||||
self.recalculate_open_state();
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the open state on the item and all its windows.
|
||||
/// This overrides the existing open states.
|
||||
pub fn set_open_state(&self, new_state: OpenState, state: &mut State) {
|
||||
state.open_state = new_state;
|
||||
let mut windows = self
|
||||
.windows
|
||||
.write()
|
||||
.expect("Failed to get write lock on windows");
|
||||
|
||||
windows
|
||||
.iter_mut()
|
||||
.for_each(|window| window.open_state = new_state);
|
||||
/// Sets this item's open state
|
||||
/// to the merged result of its windows' open states
|
||||
fn recalculate_open_state(&mut self) {
|
||||
let new_state = OpenState::merge_states(
|
||||
&self
|
||||
.windows
|
||||
.iter()
|
||||
.map(|(_, win)| &win.open_state)
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
self.open_state = new_state;
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToplevelInfo> for Item {
|
||||
fn from(toplevel: ToplevelInfo) -> Self {
|
||||
let open_state = OpenState::from_toplevel(&toplevel);
|
||||
let name = toplevel.title.clone();
|
||||
let app_id = toplevel.app_id.clone();
|
||||
|
||||
let mut windows = IndexMap::new();
|
||||
windows.insert(toplevel.id, toplevel.into());
|
||||
|
||||
Self {
|
||||
app_id,
|
||||
favorite: false,
|
||||
open_state,
|
||||
windows,
|
||||
name,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Window {
|
||||
pub id: usize,
|
||||
pub name: String,
|
||||
pub open_state: OpenState,
|
||||
}
|
||||
|
||||
impl From<ToplevelInfo> for Window {
|
||||
fn from(node: ToplevelInfo) -> Self {
|
||||
let open_state = OpenState::from_toplevel(&node);
|
||||
|
||||
Self {
|
||||
id: node.id,
|
||||
name: node.title,
|
||||
open_state,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct MenuState {
|
||||
pub num_windows: usize,
|
||||
}
|
||||
|
||||
pub struct ItemButton {
|
||||
pub button: Button,
|
||||
pub persistent: bool,
|
||||
pub show_names: bool,
|
||||
pub menu_state: Rc<RwLock<MenuState>>,
|
||||
}
|
||||
|
||||
impl ItemButton {
|
||||
pub fn new(
|
||||
item: &Item,
|
||||
show_names: bool,
|
||||
show_icons: bool,
|
||||
orientation: Orientation,
|
||||
icon_theme: &IconTheme,
|
||||
tx: &Sender<ModuleUpdateEvent<LauncherUpdate>>,
|
||||
controller_tx: &Sender<ItemEvent>,
|
||||
) -> Self {
|
||||
let mut button = Button::builder();
|
||||
|
||||
if show_names {
|
||||
button = button.label(&item.name);
|
||||
}
|
||||
|
||||
if show_icons {
|
||||
let icon = get_icon(icon_theme, &item.app_id, 32);
|
||||
if icon.is_some() {
|
||||
let image = Image::from_pixbuf(icon.as_ref());
|
||||
button = button.image(&image).always_show_image(true);
|
||||
}
|
||||
}
|
||||
|
||||
let button = button.build();
|
||||
|
||||
let style_context = button.style_context();
|
||||
style_context.add_class("item");
|
||||
|
||||
if item.favorite {
|
||||
style_context.add_class("favorite");
|
||||
}
|
||||
if item.open_state.is_open() {
|
||||
style_context.add_class("open");
|
||||
}
|
||||
if item.open_state.is_focused() {
|
||||
style_context.add_class("focused");
|
||||
}
|
||||
|
||||
{
|
||||
let app_id = item.app_id.clone();
|
||||
let tx = controller_tx.clone();
|
||||
button.connect_clicked(move |button| {
|
||||
// lazy check :|
|
||||
let style_context = button.style_context();
|
||||
if style_context.has_class("open") {
|
||||
tx.try_send(ItemEvent::FocusItem(app_id.clone()))
|
||||
.expect("Failed to send item focus event");
|
||||
} else {
|
||||
tx.try_send(ItemEvent::OpenItem(app_id.clone()))
|
||||
.expect("Failed to send item open event");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let menu_state = Rc::new(RwLock::new(MenuState {
|
||||
num_windows: item.windows.len(),
|
||||
}));
|
||||
|
||||
{
|
||||
let app_id = item.app_id.clone();
|
||||
let tx = tx.clone();
|
||||
let menu_state = menu_state.clone();
|
||||
|
||||
button.connect_enter_notify_event(move |button, _| {
|
||||
let menu_state = menu_state
|
||||
.read()
|
||||
.expect("Failed to get read lock on item menu state");
|
||||
|
||||
if menu_state.num_windows > 1 {
|
||||
tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::Hover(
|
||||
app_id.clone(),
|
||||
)))
|
||||
.expect("Failed to send item open popup event");
|
||||
|
||||
tx.try_send(ModuleUpdateEvent::OpenPopup(Popup::button_pos(
|
||||
button,
|
||||
orientation,
|
||||
)))
|
||||
.expect("Failed to send item open popup event");
|
||||
} else {
|
||||
tx.try_send(ModuleUpdateEvent::ClosePopup)
|
||||
.expect("Failed to send item close popup event");
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
}
|
||||
|
||||
button.show_all();
|
||||
|
||||
Self {
|
||||
button,
|
||||
persistent: item.favorite,
|
||||
show_names,
|
||||
menu_state,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_open(&self, open: bool) {
|
||||
self.update_class("open", open);
|
||||
|
||||
if !open {
|
||||
self.set_focused(false);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_focused(&self, focused: bool) {
|
||||
self.update_class("focused", focused);
|
||||
}
|
||||
|
||||
/// Adds or removes a class to the button based on `toggle`.
|
||||
fn update_class(&self, class: &str, toggle: bool) {
|
||||
let style_context = self.button.style_context();
|
||||
|
||||
if toggle {
|
||||
style_context.add_class(class);
|
||||
} else {
|
||||
style_context.remove_class(class);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
mod item;
|
||||
mod open_state;
|
||||
mod popup;
|
||||
|
||||
use crate::collection::Collection;
|
||||
use crate::modules::launcher::item::{ButtonConfig, LauncherItem, LauncherWindow};
|
||||
use crate::modules::launcher::open_state::OpenState;
|
||||
use crate::modules::launcher::popup::Popup;
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::sway::{get_client, SwayNode};
|
||||
use color_eyre::{Report, Result};
|
||||
use self::item::{Item, ItemButton, Window};
|
||||
use self::open_state::OpenState;
|
||||
use crate::clients::wayland::{self, ToplevelChange};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::icon::find_desktop_file;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::{Help, Report};
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconTheme, Orientation};
|
||||
use gtk::{Button, IconTheme, Orientation};
|
||||
use indexmap::IndexMap;
|
||||
use serde::Deserialize;
|
||||
use std::rc::Rc;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::debug;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LauncherModule {
|
||||
/// List of app IDs (or classes) to always show regardles of open state,
|
||||
/// List of app IDs (or classes) to always show regardless of open state,
|
||||
/// in the order specified.
|
||||
favorites: Option<Vec<String>>,
|
||||
/// Whether to show application names on the bar.
|
||||
@@ -32,279 +34,508 @@ pub struct LauncherModule {
|
||||
|
||||
/// Name of the GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LauncherUpdate {
|
||||
/// Adds item
|
||||
AddItem(Item),
|
||||
/// Adds window to item with `app_id`
|
||||
AddWindow(String, Window),
|
||||
/// Removes item with `app_id`
|
||||
RemoveItem(String),
|
||||
/// Removes window from item with `app_id`.
|
||||
RemoveWindow(String, usize),
|
||||
/// Sets title for `app_id`
|
||||
Title(String, usize, String),
|
||||
/// Marks the item with `app_id` as focused or not focused
|
||||
Focus(String, bool),
|
||||
/// Declares the item with `app_id` has been hovered over
|
||||
Hover(String),
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum FocusEvent {
|
||||
AppId(String),
|
||||
Class(String),
|
||||
ConId(i32),
|
||||
pub enum ItemEvent {
|
||||
FocusItem(String),
|
||||
FocusWindow(usize),
|
||||
OpenItem(String),
|
||||
}
|
||||
|
||||
type AppId = String;
|
||||
|
||||
struct Launcher {
|
||||
items: Collection<AppId, LauncherItem>,
|
||||
container: gtk::Box,
|
||||
button_config: ButtonConfig,
|
||||
enum ItemOrWindow {
|
||||
Item(Item),
|
||||
Window(Window),
|
||||
}
|
||||
|
||||
impl Launcher {
|
||||
fn new(favorites: Vec<String>, container: gtk::Box, button_config: ButtonConfig) -> Self {
|
||||
let items = favorites
|
||||
.into_iter()
|
||||
.map(|app_id| {
|
||||
(
|
||||
app_id.clone(),
|
||||
LauncherItem::new(app_id, true, &button_config),
|
||||
)
|
||||
})
|
||||
.collect::<Collection<_, _>>();
|
||||
|
||||
for item in &items {
|
||||
container.add(&item.button);
|
||||
}
|
||||
|
||||
Self {
|
||||
items,
|
||||
container,
|
||||
button_config,
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds a new window to the launcher.
|
||||
/// This gets added to an existing group
|
||||
/// if an instance of the program is already open.
|
||||
fn add_window(&mut self, node: SwayNode) {
|
||||
let id = node.get_id().to_string();
|
||||
|
||||
debug!("Adding window with ID {}", id);
|
||||
|
||||
if let Some(item) = self.items.get_mut(&id) {
|
||||
let mut state = item
|
||||
.state
|
||||
.write()
|
||||
.expect("Failed to get write lock on state");
|
||||
let new_open_state = OpenState::from_node(&node);
|
||||
state.open_state = OpenState::merge_states(vec![&state.open_state, &new_open_state]);
|
||||
state.is_xwayland = node.is_xwayland();
|
||||
|
||||
item.update_button_classes(&state);
|
||||
|
||||
let mut windows = item
|
||||
.windows
|
||||
.write()
|
||||
.expect("Failed to get write lock on windows");
|
||||
|
||||
windows.insert(
|
||||
node.id,
|
||||
LauncherWindow {
|
||||
con_id: node.id,
|
||||
name: node.name,
|
||||
open_state: new_open_state,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
let item = LauncherItem::from_node(&node, &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();
|
||||
|
||||
debug!("Removing window with ID {}", id);
|
||||
|
||||
let item = self.items.get_mut(&id);
|
||||
|
||||
let remove = if let Some(item) = item {
|
||||
let windows = Rc::clone(&item.windows);
|
||||
let mut windows = windows
|
||||
.write()
|
||||
.expect("Failed to get write lock on windows");
|
||||
|
||||
windows.remove(&window.id);
|
||||
|
||||
if windows.is_empty() {
|
||||
let mut state = item.state.write().expect("Failed to get lock on windows");
|
||||
state.open_state = OpenState::Closed;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/// Unfocuses the currently focused window
|
||||
/// and focuses the newly focused one.
|
||||
fn set_window_focused(&mut self, node: &SwayNode) {
|
||||
let id = node.get_id().to_string();
|
||||
|
||||
debug!("Setting window with ID {} focused", id);
|
||||
|
||||
let prev_focused = self.items.iter_mut().find(|item| {
|
||||
item.state
|
||||
.read()
|
||||
.expect("Failed to get read lock on state")
|
||||
.open_state
|
||||
.is_focused()
|
||||
});
|
||||
|
||||
if let Some(prev_focused) = prev_focused {
|
||||
let mut state = prev_focused
|
||||
.state
|
||||
.write()
|
||||
.expect("Failed to get write lock on state");
|
||||
|
||||
// if a window from the same item took focus,
|
||||
// we don't need to unfocus the item.
|
||||
if prev_focused.app_id != id {
|
||||
prev_focused.set_open_state(OpenState::open(), &mut state);
|
||||
prev_focused.update_button_classes(&state);
|
||||
}
|
||||
}
|
||||
|
||||
let item = self.items.get_mut(&id);
|
||||
if let Some(item) = item {
|
||||
let mut state = item
|
||||
.state
|
||||
.write()
|
||||
.expect("Failed to get write lock on state");
|
||||
item.set_window_open_state(node.id, OpenState::focused(), &mut state);
|
||||
item.update_button_classes(&state);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the window title for the given node.
|
||||
fn set_window_title(&mut self, window: SwayNode) {
|
||||
let id = window.get_id().to_string();
|
||||
let item = self.items.get_mut(&id);
|
||||
|
||||
debug!("Updating title for window with ID {}", id);
|
||||
|
||||
if let (Some(item), Some(name)) = (item, window.name) {
|
||||
let mut windows = item
|
||||
.windows
|
||||
.write()
|
||||
.expect("Failed to get write lock on windows");
|
||||
if windows.len() == 1 {
|
||||
item.set_title(&name, &self.button_config);
|
||||
} else if let Some(window) = windows.get_mut(&window.id) {
|
||||
window.name = Some(name);
|
||||
} else {
|
||||
// This should never happen
|
||||
// But makes more sense to wipe title than keep old one in case of error
|
||||
item.set_title("", &self.button_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the window urgency based on the given node.
|
||||
fn set_window_urgent(&mut self, node: &SwayNode) {
|
||||
let id = node.get_id().to_string();
|
||||
let item = self.items.get_mut(&id);
|
||||
|
||||
debug!(
|
||||
"Setting urgency to {} for window with ID {}",
|
||||
node.urgent, id
|
||||
);
|
||||
|
||||
if let Some(item) = item {
|
||||
let mut state = item
|
||||
.state
|
||||
.write()
|
||||
.expect("Failed to get write lock on state");
|
||||
|
||||
item.set_window_open_state(node.id, OpenState::urgent(node.urgent), &mut state);
|
||||
item.update_button_classes(&state);
|
||||
}
|
||||
}
|
||||
enum ItemOrWindowId {
|
||||
Item,
|
||||
Window,
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for LauncherModule {
|
||||
fn into_widget(self, info: &ModuleInfo) -> Result<gtk::Box> {
|
||||
let icon_theme = IconTheme::new();
|
||||
type SendMessage = LauncherUpdate;
|
||||
type ReceiveMessage = ItemEvent;
|
||||
|
||||
if let Some(theme) = self.icon_theme {
|
||||
icon_theme.set_custom_theme(Some(&theme));
|
||||
}
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
mut rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> crate::Result<()> {
|
||||
let items = self
|
||||
.favorites
|
||||
.as_ref()
|
||||
.map_or_else(IndexMap::new, |favorites| {
|
||||
favorites
|
||||
.iter()
|
||||
.map(|app_id| {
|
||||
(
|
||||
app_id.to_string(),
|
||||
Item::new(app_id.to_string(), OpenState::Closed, true),
|
||||
)
|
||||
})
|
||||
.collect::<IndexMap<_, _>>()
|
||||
});
|
||||
|
||||
let popup = Popup::new(
|
||||
"popup-launcher",
|
||||
info.app,
|
||||
info.monitor,
|
||||
Orientation::Vertical,
|
||||
info.bar_position,
|
||||
);
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||
|
||||
let (ui_tx, mut ui_rx) = mpsc::channel(32);
|
||||
|
||||
let button_config = ButtonConfig {
|
||||
icon_theme,
|
||||
show_names: self.show_names,
|
||||
show_icons: self.show_icons,
|
||||
popup,
|
||||
tx: ui_tx,
|
||||
};
|
||||
|
||||
let mut launcher = Launcher::new(
|
||||
self.favorites.unwrap_or_default(),
|
||||
container.clone(),
|
||||
button_config,
|
||||
);
|
||||
|
||||
let open_windows = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
sway.get_open_windows()
|
||||
}?;
|
||||
|
||||
for window in open_windows {
|
||||
launcher.add_window(window);
|
||||
}
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn_blocking(move || {
|
||||
let srx = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
sway.subscribe_window()
|
||||
};
|
||||
|
||||
while let Ok(payload) = srx.recv() {
|
||||
tx.send(payload)
|
||||
.expect("Failed to send window event payload");
|
||||
}
|
||||
});
|
||||
let items = Arc::new(Mutex::new(items));
|
||||
|
||||
{
|
||||
let items = Arc::clone(&items);
|
||||
let tx = tx.clone();
|
||||
spawn(async move {
|
||||
let wl = wayland::get_client().await;
|
||||
let open_windows = 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 = IndexMap::<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();
|
||||
|
||||
// we need some content to force the container to have a size
|
||||
let placeholder = Button::with_label("PLACEHOLDER");
|
||||
placeholder.set_width_request(MAX_WIDTH);
|
||||
container.add(&placeholder);
|
||||
|
||||
let mut buttons = IndexMap::<String, IndexMap<usize, Button>>::new();
|
||||
|
||||
{
|
||||
let container = container.clone();
|
||||
rx.attach(None, move |event| {
|
||||
match event.change.as_str() {
|
||||
"new" => launcher.add_window(event.container),
|
||||
"close" => launcher.remove_window(&event.container),
|
||||
"focus" => launcher.set_window_focused(&event.container),
|
||||
"title" => launcher.set_window_title(event.container),
|
||||
"urgent" => launcher.set_window_urgent(&event.container),
|
||||
match event {
|
||||
LauncherUpdate::AddItem(item) => {
|
||||
let app_id = item.app_id.clone();
|
||||
trace!("Adding item with id '{app_id}' to the popup: {item:?}");
|
||||
|
||||
let window_buttons = item
|
||||
.windows
|
||||
.into_iter()
|
||||
.map(|(_, win)| {
|
||||
let button = Button::builder()
|
||||
.label(&clamp(&win.name))
|
||||
.height_request(40)
|
||||
.build();
|
||||
|
||||
{
|
||||
let tx = controller_tx.clone();
|
||||
button.connect_clicked(move |button| {
|
||||
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) => {
|
||||
debug!(
|
||||
"Adding new window to popup for '{app_id}': '{}' ({})",
|
||||
win.name, win.id
|
||||
);
|
||||
|
||||
if let Some(buttons) = buttons.get_mut(&app_id) {
|
||||
let button = Button::builder()
|
||||
.height_request(40)
|
||||
.label(&clamp(&win.name))
|
||||
.build();
|
||||
|
||||
{
|
||||
let tx = controller_tx.clone();
|
||||
button.connect_clicked(move |button| {
|
||||
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) => {
|
||||
debug!("Removing window from popup for '{app_id}': {win_id}");
|
||||
|
||||
if let Some(buttons) = buttons.get_mut(&app_id) {
|
||||
buttons.remove(&win_id);
|
||||
}
|
||||
}
|
||||
LauncherUpdate::Title(app_id, win_id, title) => {
|
||||
debug!(
|
||||
"Updating window title on popup for '{app_id}'/{win_id} to '{title}'"
|
||||
);
|
||||
|
||||
if let Some(buttons) = buttons.get_mut(&app_id) {
|
||||
if let Some(button) = buttons.get(&win_id) {
|
||||
button.set_label(&title);
|
||||
}
|
||||
}
|
||||
}
|
||||
LauncherUpdate::Hover(app_id) => {
|
||||
// empty current buttons
|
||||
for child in container.children() {
|
||||
container.remove(&child);
|
||||
}
|
||||
|
||||
// add app's buttons
|
||||
if let Some(buttons) = buttons.get(&app_id) {
|
||||
for (_, button) in buttons {
|
||||
button.style_context().add_class("popup-item");
|
||||
container.add(button);
|
||||
}
|
||||
|
||||
container.show_all();
|
||||
container.set_width_request(MAX_WIDTH);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -312,22 +543,21 @@ impl Module<gtk::Box> for LauncherModule {
|
||||
});
|
||||
}
|
||||
|
||||
spawn(async move {
|
||||
let sway = get_client();
|
||||
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
let selector = match event {
|
||||
FocusEvent::AppId(app_id) => format!("[app_id={}]", app_id),
|
||||
FocusEvent::Class(class) => format!("[class={}]", class),
|
||||
FocusEvent::ConId(id) => format!("[con_id={}]", id),
|
||||
};
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
sway.run(format!("{} focus", selector))?;
|
||||
}
|
||||
|
||||
Ok::<(), Report>(())
|
||||
});
|
||||
|
||||
Ok(container)
|
||||
Some(container)
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamps a string at 24 characters.
|
||||
///
|
||||
/// This is a hacky number derived from
|
||||
/// "what fits inside the 250px popup"
|
||||
/// and probably won't hold up with wide fonts.
|
||||
fn clamp(str: &str) -> String {
|
||||
const MAX_CHARS: usize = 24;
|
||||
|
||||
if str.len() > MAX_CHARS {
|
||||
str.chars().take(MAX_CHARS - 3).collect::<String>() + "..."
|
||||
} else {
|
||||
str.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,23 @@
|
||||
use crate::sway::SwayNode;
|
||||
use crate::clients::wayland::ToplevelInfo;
|
||||
|
||||
/// Open state for a launcher item, or item window.
|
||||
#[derive(Debug, Clone, Eq, PartialEq, Copy)]
|
||||
pub enum OpenState {
|
||||
Closed,
|
||||
Open { focused: bool, urgent: bool },
|
||||
Open { focused: bool },
|
||||
}
|
||||
|
||||
impl OpenState {
|
||||
/// Creates from `SwayNode`
|
||||
pub const fn from_node(node: &SwayNode) -> Self {
|
||||
pub const fn from_toplevel(toplevel: &ToplevelInfo) -> Self {
|
||||
Self::Open {
|
||||
focused: node.focused,
|
||||
urgent: node.urgent,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates open without focused/urgent
|
||||
pub const fn open() -> Self {
|
||||
Self::Open {
|
||||
focused: false,
|
||||
urgent: false,
|
||||
focused: toplevel.active,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates open with focused
|
||||
pub const fn focused() -> Self {
|
||||
Self::Open {
|
||||
focused: true,
|
||||
urgent: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates open with urgent
|
||||
pub const fn urgent(urgent: bool) -> Self {
|
||||
Self::Open {
|
||||
focused: false,
|
||||
urgent,
|
||||
}
|
||||
pub const fn focused(focused: bool) -> Self {
|
||||
Self::Open { focused }
|
||||
}
|
||||
|
||||
/// Checks if open
|
||||
@@ -47,24 +27,18 @@ impl OpenState {
|
||||
|
||||
/// Checks if open with focus
|
||||
pub const fn is_focused(self) -> bool {
|
||||
matches!(self, Self::Open { focused: true, .. })
|
||||
}
|
||||
|
||||
/// check if open with urgent
|
||||
pub const fn is_urgent(self) -> bool {
|
||||
matches!(self, Self::Open { urgent: true, .. })
|
||||
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: Vec<&Self>) -> Self {
|
||||
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(),
|
||||
urgent: merged.is_urgent() || current.is_urgent(),
|
||||
}
|
||||
} else {
|
||||
Self::Closed
|
||||
|
||||
@@ -1,36 +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))
|
||||
.expect("Failed to send focus event");
|
||||
window.hide();
|
||||
});
|
||||
|
||||
self.container.add(&button);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
/// Clicking the widget opens a popup containing the current time
|
||||
/// with second-level precision and a calendar.
|
||||
pub mod clock;
|
||||
pub mod custom;
|
||||
pub mod focused;
|
||||
pub mod launcher;
|
||||
pub mod mpd;
|
||||
@@ -14,12 +15,13 @@ pub mod tray;
|
||||
pub mod workspaces;
|
||||
|
||||
use crate::config::BarPosition;
|
||||
use crate::popup::ButtonGeometry;
|
||||
use color_eyre::Result;
|
||||
/// Shamelessly stolen from here:
|
||||
/// <https://github.com/zeroeightysix/rustbar/blob/master/src/modules/module.rs>
|
||||
use derive_builder::Builder;
|
||||
use glib::IsA;
|
||||
use gtk::gdk::Monitor;
|
||||
use gtk::{Application, Widget};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum ModuleLocation {
|
||||
@@ -28,19 +30,70 @@ pub enum ModuleLocation {
|
||||
Right,
|
||||
}
|
||||
|
||||
#[derive(Builder)]
|
||||
pub struct ModuleInfo<'a> {
|
||||
pub app: &'a Application,
|
||||
pub location: ModuleLocation,
|
||||
pub bar_position: &'a BarPosition,
|
||||
pub bar_position: BarPosition,
|
||||
pub monitor: &'a Monitor,
|
||||
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>
|
||||
where
|
||||
W: IsA<Widget>,
|
||||
{
|
||||
/// Consumes the module config
|
||||
/// and produces a GTK widget of type `W`
|
||||
fn into_widget(self, info: &ModuleInfo) -> Result<W>;
|
||||
type SendMessage;
|
||||
type ReceiveMessage;
|
||||
|
||||
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
|
||||
Self: Sized,
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
492
src/modules/mpd.rs
Normal file
492
src/modules/mpd.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
use crate::clients::mpd::{get_client, get_duration, get_elapsed, MpdConnectionError};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use color_eyre::Result;
|
||||
use dirs::{audio_dir, home_dir};
|
||||
use glib::Continue;
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Image, Label, Orientation, Scale};
|
||||
use mpd_client::commands;
|
||||
use mpd_client::responses::{PlayState, Song, Status};
|
||||
use mpd_client::tag::Tag;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum PlayerCommand {
|
||||
Previous,
|
||||
Toggle,
|
||||
Next,
|
||||
Volume(u8),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Icons {
|
||||
/// Icon to display when playing.
|
||||
#[serde(default = "default_icon_play")]
|
||||
play: String,
|
||||
/// Icon to display when paused.
|
||||
#[serde(default = "default_icon_pause")]
|
||||
pause: String,
|
||||
/// Icon to display under volume slider
|
||||
#[serde(default = "default_icon_volume")]
|
||||
volume: String,
|
||||
}
|
||||
|
||||
impl Default for Icons {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pause: default_icon_pause(),
|
||||
play: default_icon_play(),
|
||||
volume: default_icon_volume(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MpdModule {
|
||||
/// TCP or Unix socket address.
|
||||
#[serde(default = "default_socket")]
|
||||
host: String,
|
||||
/// Format of current song info to display on the bar.
|
||||
#[serde(default = "default_format")]
|
||||
format: String,
|
||||
|
||||
/// Player state icons
|
||||
#[serde(default)]
|
||||
icons: Icons,
|
||||
|
||||
/// Path to root of music directory.
|
||||
#[serde(default = "default_music_dir")]
|
||||
music_dir: PathBuf,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
fn default_socket() -> String {
|
||||
String::from("localhost:6600")
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
String::from("{icon} {title} / {artist}")
|
||||
}
|
||||
|
||||
fn default_icon_play() -> String {
|
||||
String::from("")
|
||||
}
|
||||
|
||||
fn default_icon_pause() -> String {
|
||||
String::from("")
|
||||
}
|
||||
|
||||
fn default_icon_volume() -> String {
|
||||
String::from("墳")
|
||||
}
|
||||
|
||||
fn default_music_dir() -> PathBuf {
|
||||
audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Attempts to read the first value for a tag
|
||||
/// (since the MPD client returns a vector of tags, or None)
|
||||
pub fn try_get_first_tag(vec: Option<&Vec<String>>) -> Option<&str> {
|
||||
vec.and_then(|vec| vec.first().map(String::as_str))
|
||||
}
|
||||
|
||||
/// Formats a duration given in seconds
|
||||
/// in hh:mm format
|
||||
fn format_time(time: u64) -> String {
|
||||
let minutes = (time / 60) % 60;
|
||||
let seconds = time % 60;
|
||||
|
||||
format!("{:0>2}:{:0>2}", minutes, seconds)
|
||||
}
|
||||
|
||||
/// Extracts the formatting tokens from a formatting string
|
||||
fn get_tokens(re: &Regex, format_string: &str) -> Vec<String> {
|
||||
re.captures_iter(format_string)
|
||||
.map(|caps| caps[1].to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct SongUpdate {
|
||||
song: Song,
|
||||
status: Status,
|
||||
display_string: String,
|
||||
}
|
||||
|
||||
impl Module<Button> for MpdModule {
|
||||
type SendMessage = Option<SongUpdate>;
|
||||
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());
|
||||
|
||||
// poll mpd server
|
||||
spawn(async move {
|
||||
let client = get_client(&host1).await.expect("Failed to connect to MPD");
|
||||
let mut mpd_rx = client.subscribe();
|
||||
|
||||
loop {
|
||||
let current_song = client.command(commands::CurrentSong).await;
|
||||
let status = client.command(commands::Status).await;
|
||||
|
||||
if let (Ok(Some(song)), Ok(status)) = (current_song, status) {
|
||||
let display_string =
|
||||
replace_tokens(format.as_str(), &tokens, &song.song, &status, &icons);
|
||||
|
||||
let update = SongUpdate {
|
||||
song: song.song,
|
||||
status,
|
||||
display_string,
|
||||
};
|
||||
|
||||
tx.send(ModuleUpdateEvent::Update(Some(update))).await?;
|
||||
} else {
|
||||
tx.send(ModuleUpdateEvent::Update(None)).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 {
|
||||
let client = get_client(&host2).await?;
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
let res = match event {
|
||||
PlayerCommand::Previous => client.command(commands::Previous).await,
|
||||
PlayerCommand::Toggle => match client.command(commands::Status).await {
|
||||
Ok(status) => match status.state {
|
||||
PlayState::Playing => client.command(commands::SetPause(true)).await,
|
||||
PlayState::Paused => client.command(commands::SetPause(false)).await,
|
||||
PlayState::Stopped => Ok(()),
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
PlayerCommand::Next => client.command(commands::Next).await,
|
||||
PlayerCommand::Volume(vol) => client.command(commands::SetVolume(vol)).await,
|
||||
};
|
||||
|
||||
if let Err(err) = res {
|
||||
error!("Failed to send command to MPD server: {:?}", err);
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
{
|
||||
let tx = context.tx.clone();
|
||||
|
||||
button.connect_clicked(move |button| {
|
||||
tx.try_send(ModuleUpdateEvent::TogglePopup(Popup::button_pos(
|
||||
button,
|
||||
orientation,
|
||||
)))
|
||||
.expect("Failed to send MPD popup open event");
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let button = button.clone();
|
||||
let tx = context.tx.clone();
|
||||
|
||||
context.widget_rx.attach(None, move |mut event| {
|
||||
if let Some(event) = event.take() {
|
||||
label.set_label(&event.display_string);
|
||||
button.show();
|
||||
} else {
|
||||
button.hide();
|
||||
tx.try_send(ModuleUpdateEvent::ClosePopup)
|
||||
.expect("Failed to send close popup message");
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
};
|
||||
|
||||
let popup = self.into_popup(context.controller_tx, context.popup_rx);
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: button,
|
||||
popup,
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
tx: Sender<Self::ReceiveMessage>,
|
||||
rx: glib::Receiver<Self::SendMessage>,
|
||||
) -> Option<gtk::Box> {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(Orientation::Horizontal)
|
||||
.spacing(10)
|
||||
.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);
|
||||
|
||||
let volume_box = gtk::Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(5)
|
||||
.name("volume")
|
||||
.build();
|
||||
|
||||
let volume_slider = Scale::with_range(Orientation::Vertical, 0.0, 100.0, 5.0);
|
||||
volume_slider.set_inverted(true);
|
||||
volume_slider.set_widget_name("scale");
|
||||
|
||||
let volume_icon = Label::new(Some(&self.icons.volume));
|
||||
volume_icon.style_context().add_class("icon");
|
||||
|
||||
volume_box.pack_start(&volume_slider, true, true, 0);
|
||||
volume_box.pack_end(&volume_icon, false, false, 0);
|
||||
|
||||
container.add(&album_image);
|
||||
container.add(&info_box);
|
||||
container.add(&volume_box);
|
||||
|
||||
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.clone();
|
||||
btn_next.connect_clicked(move |_| {
|
||||
tx_next
|
||||
.try_send(PlayerCommand::Next)
|
||||
.expect("Failed to send next track message");
|
||||
});
|
||||
|
||||
let tx_vol = tx;
|
||||
volume_slider.connect_change_value(move |_, _, val| {
|
||||
tx_vol
|
||||
.try_send(PlayerCommand::Volume(val as u8))
|
||||
.expect("Failed to send volume message");
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
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"),
|
||||
);
|
||||
|
||||
Pixbuf::from_file_at_scale(cover_path, 128, 128, true).map_or_else(
|
||||
|_| {
|
||||
album_image.set_from_pixbuf(None);
|
||||
},
|
||||
|pixbuf| {
|
||||
album_image.set_from_pixbuf(Some(&pixbuf));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
volume_slider.set_value(update.status.volume as f64);
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Some(container)
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces each of the formatting tokens in the formatting string
|
||||
/// with actual data pulled from MPD
|
||||
fn replace_tokens(
|
||||
format_string: &str,
|
||||
tokens: &Vec<String>,
|
||||
song: &Song,
|
||||
status: &Status,
|
||||
icons: &Icons,
|
||||
) -> String {
|
||||
let mut compiled_string = format_string.to_string();
|
||||
for token in tokens {
|
||||
let value = get_token_value(song, status, icons, token);
|
||||
compiled_string =
|
||||
compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str());
|
||||
}
|
||||
compiled_string
|
||||
}
|
||||
|
||||
/// Converts a string format token value
|
||||
/// into its respective MPD value.
|
||||
fn get_token_value(song: &Song, status: &Status, icons: &Icons, token: &str) -> String {
|
||||
let s = match token {
|
||||
"icon" => {
|
||||
let icon = match status.state {
|
||||
PlayState::Stopped => None,
|
||||
PlayState::Playing => Some(&icons.play),
|
||||
PlayState::Paused => Some(&icons.pause),
|
||||
};
|
||||
icon.map(String::as_str)
|
||||
}
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
use lazy_static::lazy_static;
|
||||
use mpd_client::client::Connection;
|
||||
use mpd_client::protocol::MpdProtocolError;
|
||||
use mpd_client::responses::Status;
|
||||
use mpd_client::Client;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{TcpStream, UnixStream};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENTS: Arc<Mutex<HashMap<String, Arc<Client>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
pub async fn get_connection(host: &str) -> Option<Arc<Client>> {
|
||||
let mut clients = CLIENTS.lock().await;
|
||||
|
||||
match clients.get(host) {
|
||||
Some(client) => Some(Arc::clone(client)),
|
||||
None => {
|
||||
let client = wait_for_connection(host, Duration::from_secs(5), None).await?;
|
||||
let client = Arc::new(client);
|
||||
clients.insert(host.to_string(), Arc::clone(&client));
|
||||
Some(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_connection(
|
||||
host: &str,
|
||||
interval: Duration,
|
||||
max_retries: Option<usize>,
|
||||
) -> Option<Client> {
|
||||
let mut retries = 0;
|
||||
let max_retries = max_retries.unwrap_or(usize::MAX);
|
||||
|
||||
loop {
|
||||
if retries == max_retries {
|
||||
break None;
|
||||
}
|
||||
|
||||
if let Some(conn) = try_get_mpd_conn(host).await {
|
||||
break Some(conn.0);
|
||||
}
|
||||
|
||||
retries += 1;
|
||||
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) -> Option<Connection> {
|
||||
let connection = if is_unix_socket(host) {
|
||||
connect_unix(host).await
|
||||
} else {
|
||||
connect_tcp(host).await
|
||||
};
|
||||
|
||||
connection.ok()
|
||||
}
|
||||
|
||||
fn is_unix_socket(host: &str) -> bool {
|
||||
PathBuf::from(host).is_file()
|
||||
}
|
||||
|
||||
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = UnixStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = TcpStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
/// Gets the duration of the current song
|
||||
pub fn get_duration(status: &Status) -> Option<u64> {
|
||||
status.duration.map(|duration| duration.as_secs())
|
||||
}
|
||||
|
||||
/// Gets the elapsed time of the current song
|
||||
pub fn get_elapsed(status: &Status) -> Option<u64> {
|
||||
status.elapsed.map(|duration| duration.as_secs())
|
||||
}
|
||||
@@ -1,251 +0,0 @@
|
||||
mod client;
|
||||
mod popup;
|
||||
|
||||
use self::popup::Popup;
|
||||
use crate::modules::mpd::client::{get_connection, get_duration, get_elapsed};
|
||||
use crate::modules::mpd::popup::{MpdPopup, PopupEvent};
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use color_eyre::Result;
|
||||
use dirs::{audio_dir, home_dir};
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Orientation};
|
||||
use mpd_client::commands;
|
||||
use mpd_client::responses::{PlayState, Song, Status};
|
||||
use mpd_client::tag::Tag;
|
||||
use regex::Regex;
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MpdModule {
|
||||
/// TCP or Unix socket address.
|
||||
#[serde(default = "default_socket")]
|
||||
host: String,
|
||||
/// Format of current song info to display on the bar.
|
||||
#[serde(default = "default_format")]
|
||||
format: String,
|
||||
/// Icon to display when playing.
|
||||
#[serde(default = "default_icon_play")]
|
||||
icon_play: Option<String>,
|
||||
/// Icon to display when paused.
|
||||
#[serde(default = "default_icon_pause")]
|
||||
icon_pause: Option<String>,
|
||||
|
||||
/// Path to root of music directory.
|
||||
#[serde(default = "default_music_dir")]
|
||||
music_dir: PathBuf,
|
||||
}
|
||||
|
||||
fn default_socket() -> String {
|
||||
String::from("localhost:6600")
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
String::from("{icon} {title} / {artist}")
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn default_icon_play() -> Option<String> {
|
||||
Some(String::from(""))
|
||||
}
|
||||
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn default_icon_pause() -> Option<String> {
|
||||
Some(String::from(""))
|
||||
}
|
||||
|
||||
fn default_music_dir() -> PathBuf {
|
||||
audio_dir().unwrap_or_else(|| home_dir().map(|dir| dir.join("Music")).unwrap_or_default())
|
||||
}
|
||||
|
||||
/// Attempts to read the first value for a tag
|
||||
/// (since the MPD client returns a vector of tags, or None)
|
||||
pub fn try_get_first_tag(vec: Option<&Vec<String>>) -> Option<&str> {
|
||||
match vec {
|
||||
Some(vec) => vec.first().map(String::as_str),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Formats a duration given in seconds
|
||||
/// in hh:mm format
|
||||
fn format_time(time: u64) -> String {
|
||||
let minutes = (time / 60) % 60;
|
||||
let seconds = time % 60;
|
||||
|
||||
format!("{:0>2}:{:0>2}", minutes, seconds)
|
||||
}
|
||||
|
||||
/// Extracts the formatting tokens from a formatting string
|
||||
fn get_tokens(re: &Regex, format_string: &str) -> Vec<String> {
|
||||
re.captures_iter(format_string)
|
||||
.map(|caps| caps[1].to_string())
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
|
||||
enum Event {
|
||||
Open,
|
||||
Update(Box<Option<(Song, Status, String)>>),
|
||||
}
|
||||
|
||||
impl Module<Button> for MpdModule {
|
||||
fn into_widget(self, info: &ModuleInfo) -> Result<Button> {
|
||||
let re = Regex::new(r"\{([\w-]+)}")?;
|
||||
let tokens = get_tokens(&re, self.format.as_str());
|
||||
|
||||
let button = Button::new();
|
||||
|
||||
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)
|
||||
.expect("Failed to send popup open event");
|
||||
});
|
||||
|
||||
let host = self.host.clone();
|
||||
let host2 = self.host.clone();
|
||||
spawn(async move {
|
||||
let client = get_connection(&host)
|
||||
.await
|
||||
.expect("Unexpected error when trying to connect to MPD server");
|
||||
|
||||
loop {
|
||||
let current_song = client.command(commands::CurrentSong).await;
|
||||
let status = client.command(commands::Status).await;
|
||||
|
||||
if let (Ok(Some(song)), Ok(status)) = (current_song, status) {
|
||||
let string = self
|
||||
.replace_tokens(self.format.as_str(), &tokens, &song.song, &status)
|
||||
.await;
|
||||
|
||||
tx.send(Event::Update(Box::new(Some((song.song, status, string)))))
|
||||
.expect("Failed to send update event");
|
||||
} else {
|
||||
tx.send(Event::Update(Box::new(None)))
|
||||
.expect("Failed to send update event");
|
||||
}
|
||||
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
spawn(async move {
|
||||
let client = get_connection(&host2)
|
||||
.await
|
||||
.expect("Unexpected error when trying to connect to MPD server");
|
||||
|
||||
while let Some(event) = ui_rx.recv().await {
|
||||
let res = match event {
|
||||
PopupEvent::Previous => client.command(commands::Previous).await,
|
||||
PopupEvent::Toggle => match client.command(commands::Status).await {
|
||||
Ok(status) => match status.state {
|
||||
PlayState::Playing => client.command(commands::SetPause(true)).await,
|
||||
PlayState::Paused => client.command(commands::SetPause(false)).await,
|
||||
PlayState::Stopped => Ok(()),
|
||||
},
|
||||
Err(err) => Err(err),
|
||||
},
|
||||
PopupEvent::Next => client.command(commands::Next).await,
|
||||
};
|
||||
|
||||
if let Err(err) = res {
|
||||
error!("Failed to send command to MPD server: {:?}", err);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let button = button.clone();
|
||||
|
||||
rx.attach(None, move |event| {
|
||||
match event {
|
||||
Event::Open => {
|
||||
mpd_popup.popup.show(&button);
|
||||
}
|
||||
Event::Update(mut msg) => {
|
||||
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)
|
||||
});
|
||||
};
|
||||
|
||||
Ok(button)
|
||||
}
|
||||
}
|
||||
|
||||
impl MpdModule {
|
||||
/// Replaces each of the formatting tokens in the formatting string
|
||||
/// with actual data pulled from MPD
|
||||
async fn replace_tokens(
|
||||
&self,
|
||||
format_string: &str,
|
||||
tokens: &Vec<String>,
|
||||
song: &Song,
|
||||
status: &Status,
|
||||
) -> String {
|
||||
let mut compiled_string = format_string.to_string();
|
||||
for token in tokens {
|
||||
let value = self.get_token_value(song, status, token).await;
|
||||
compiled_string =
|
||||
compiled_string.replace(format!("{{{}}}", token).as_str(), value.as_str());
|
||||
}
|
||||
compiled_string
|
||||
}
|
||||
|
||||
/// Converts a string format token value
|
||||
/// into its respective MPD value.
|
||||
pub async fn get_token_value(&self, song: &Song, status: &Status, token: &str) -> String {
|
||||
let s = match token {
|
||||
"icon" => {
|
||||
let icon = match status.state {
|
||||
PlayState::Stopped => None,
|
||||
PlayState::Playing => self.icon_play.as_ref(),
|
||||
PlayState::Paused => self.icon_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()
|
||||
}
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
pub use crate::popup::Popup;
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Image, Label, Orientation};
|
||||
use mpd_client::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)
|
||||
.expect("Failed to send prev track message");
|
||||
});
|
||||
|
||||
let tx_toggle = tx.clone();
|
||||
btn_play_pause.connect_clicked(move |_| {
|
||||
tx_toggle
|
||||
.try_send(PopupEvent::Toggle)
|
||||
.expect("Failed to send play/pause track message");
|
||||
});
|
||||
|
||||
let tx_next = tx;
|
||||
btn_next.connect_clicked(move |_| {
|
||||
tx_next
|
||||
.try_send(PopupEvent::Next)
|
||||
.expect("Failed to send next track message");
|
||||
});
|
||||
|
||||
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()
|
||||
.expect("Song path should not be root")
|
||||
.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);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,32 @@
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use color_eyre::{eyre::Report, eyre::Result, eyre::WrapErr, Section};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::script::{OutputStream, Script, ScriptMode};
|
||||
use color_eyre::{Help, Report, Result};
|
||||
use gtk::prelude::*;
|
||||
use gtk::Label;
|
||||
use serde::Deserialize;
|
||||
use std::process::Command;
|
||||
use tokio::spawn;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{error, instrument};
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ScriptModule {
|
||||
/// Path to script to execute.
|
||||
path: String,
|
||||
cmd: String,
|
||||
/// Script execution mode
|
||||
#[serde(default = "default_mode")]
|
||||
mode: ScriptMode,
|
||||
/// Time in milliseconds between executions.
|
||||
#[serde(default = "default_interval")]
|
||||
interval: u64,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// `Mode::Poll`
|
||||
const fn default_mode() -> ScriptMode {
|
||||
ScriptMode::Poll
|
||||
}
|
||||
|
||||
/// 5000ms
|
||||
@@ -22,58 +34,65 @@ const fn default_interval() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
impl From<&ScriptModule> for Script {
|
||||
fn from(module: &ScriptModule) -> Self {
|
||||
Self {
|
||||
mode: module.mode,
|
||||
cmd: module.cmd.clone(),
|
||||
interval: module.interval,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Module<Label> for ScriptModule {
|
||||
fn into_widget(self, _info: &ModuleInfo) -> Result<Label> {
|
||||
let label = Label::builder().use_markup(true).build();
|
||||
type SendMessage = String;
|
||||
type ReceiveMessage = ();
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
_rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
let script: Script = self.into();
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
spawn(async move {
|
||||
loop {
|
||||
match self.run_script() {
|
||||
Ok(stdout) => tx.send(stdout).expect("Failed to send stdout"),
|
||||
Err(err) => error!("{:?}", err),
|
||||
}
|
||||
|
||||
sleep(tokio::time::Duration::from_millis(self.interval)).await;
|
||||
}
|
||||
script.run(move |(out, _)| match out {
|
||||
OutputStream::Stdout(stdout) => {
|
||||
tx.try_send(ModuleUpdateEvent::Update(stdout))
|
||||
.expect("Failed to send stdout"); }
|
||||
OutputStream::Stderr(stderr) => {
|
||||
error!("{:?}", Report::msg(stderr)
|
||||
.wrap_err("Watched script error:")
|
||||
.suggestion("Check the path to your script")
|
||||
.suggestion("Check the script for errors")
|
||||
.suggestion("If you expect the script to write to stderr, consider redirecting its output to /dev/null to suppress these messages"));
|
||||
}
|
||||
}).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();
|
||||
rx.attach(None, move |s| {
|
||||
label.set_label(s.as_str());
|
||||
context.widget_rx.attach(None, move |s| {
|
||||
label.set_markup(s.as_str());
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(label)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScriptModule {
|
||||
#[instrument]
|
||||
fn run_script(&self) -> Result<String> {
|
||||
let output = Command::new("sh")
|
||||
.arg("-c")
|
||||
.arg(&self.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"))
|
||||
}
|
||||
Ok(ModuleWidget {
|
||||
widget: label,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +1,456 @@
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::Result;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Label, Orientation};
|
||||
use gtk::Label;
|
||||
use regex::{Captures, Regex};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use sysinfo::{CpuExt, System, SystemExt};
|
||||
use std::time::Duration;
|
||||
use sysinfo::{ComponentExt, CpuExt, DiskExt, NetworkExt, RefreshKind, System, SystemExt};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tokio::time::sleep;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SysInfoModule {
|
||||
/// List of formatting strings.
|
||||
format: Vec<String>,
|
||||
/// Number of seconds between refresh
|
||||
#[serde(default = "Interval::default")]
|
||||
interval: Interval,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Copy, Clone)]
|
||||
pub struct Intervals {
|
||||
#[serde(default = "default_interval")]
|
||||
memory: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
cpu: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
temps: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
disks: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
networks: u64,
|
||||
#[serde(default = "default_interval")]
|
||||
system: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Copy, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum Interval {
|
||||
All(u64),
|
||||
Individual(Intervals),
|
||||
}
|
||||
|
||||
impl Default for Interval {
|
||||
fn default() -> Self {
|
||||
Self::All(default_interval())
|
||||
}
|
||||
}
|
||||
|
||||
impl Interval {
|
||||
const fn memory(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.memory,
|
||||
}
|
||||
}
|
||||
|
||||
const fn cpu(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.cpu,
|
||||
}
|
||||
}
|
||||
|
||||
const fn temps(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.temps,
|
||||
}
|
||||
}
|
||||
|
||||
const fn disks(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.disks,
|
||||
}
|
||||
}
|
||||
|
||||
const fn networks(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.networks,
|
||||
}
|
||||
}
|
||||
|
||||
const fn system(self) -> u64 {
|
||||
match self {
|
||||
Self::All(n) => n,
|
||||
Self::Individual(intervals) => intervals.system,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fn default_interval() -> u64 {
|
||||
5
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum RefreshType {
|
||||
Memory,
|
||||
Cpu,
|
||||
Temps,
|
||||
Disks,
|
||||
Network,
|
||||
System,
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for SysInfoModule {
|
||||
fn into_widget(self, _info: &ModuleInfo) -> Result<gtk::Box> {
|
||||
let re = Regex::new(r"\{([\w-]+)}")?;
|
||||
type SendMessage = HashMap<String, String>;
|
||||
type ReceiveMessage = ();
|
||||
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 10);
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
_rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
let interval = self.interval;
|
||||
|
||||
let refresh_kind = RefreshKind::everything()
|
||||
.without_processes()
|
||||
.without_users_list();
|
||||
|
||||
let mut sys = System::new_with_specifics(refresh_kind);
|
||||
sys.refresh_components_list();
|
||||
sys.refresh_disks_list();
|
||||
sys.refresh_networks_list();
|
||||
|
||||
let (refresh_tx, mut refresh_rx) = mpsc::channel(16);
|
||||
|
||||
// memory refresh
|
||||
{
|
||||
let tx = refresh_tx.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::Memory)
|
||||
.await
|
||||
.expect("Failed to send memory refresh");
|
||||
sleep(Duration::from_secs(interval.memory())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// cpu refresh
|
||||
{
|
||||
let tx = refresh_tx.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::Cpu)
|
||||
.await
|
||||
.expect("Failed to send cpu refresh");
|
||||
sleep(Duration::from_secs(interval.cpu())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// temp refresh
|
||||
{
|
||||
let tx = refresh_tx.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::Temps)
|
||||
.await
|
||||
.expect("Failed to send temperature refresh");
|
||||
sleep(Duration::from_secs(interval.temps())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// disk refresh
|
||||
{
|
||||
let tx = refresh_tx.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::Disks)
|
||||
.await
|
||||
.expect("Failed to send disk refresh");
|
||||
sleep(Duration::from_secs(interval.disks())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// network refresh
|
||||
{
|
||||
let tx = refresh_tx.clone();
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::Network)
|
||||
.await
|
||||
.expect("Failed to send network refresh");
|
||||
sleep(Duration::from_secs(interval.networks())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// system refresh
|
||||
{
|
||||
let tx = refresh_tx;
|
||||
spawn(async move {
|
||||
loop {
|
||||
tx.send(RefreshType::System)
|
||||
.await
|
||||
.expect("Failed to send system refresh");
|
||||
sleep(Duration::from_secs(interval.system())).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
spawn(async move {
|
||||
let mut format_info = HashMap::new();
|
||||
|
||||
while let Some(refresh) = refresh_rx.recv().await {
|
||||
match refresh {
|
||||
RefreshType::Memory => refresh_memory_tokens(&mut format_info, &mut sys),
|
||||
RefreshType::Cpu => refresh_cpu_tokens(&mut format_info, &mut sys),
|
||||
RefreshType::Temps => refresh_temp_tokens(&mut format_info, &mut sys),
|
||||
RefreshType::Disks => refresh_disk_tokens(&mut format_info, &mut sys),
|
||||
RefreshType::Network => {
|
||||
refresh_network_tokens(&mut format_info, &mut sys, interval.networks());
|
||||
}
|
||||
RefreshType::System => refresh_system_tokens(&mut format_info, &sys),
|
||||
};
|
||||
|
||||
tx.send(ModuleUpdateEvent::Update(format_info.clone()))
|
||||
.await
|
||||
.expect("Failed to send system info map");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> Result<ModuleWidget<gtk::Box>> {
|
||||
let re = Regex::new(r"\{([^}]+)}")?;
|
||||
|
||||
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();
|
||||
let label = Label::builder()
|
||||
.label(format)
|
||||
.use_markup(true)
|
||||
.name("item")
|
||||
.build();
|
||||
label.set_angle(info.bar_position.get_angle());
|
||||
container.add(&label);
|
||||
labels.push(label);
|
||||
}
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
spawn(async move {
|
||||
let mut sys = System::new_all();
|
||||
|
||||
loop {
|
||||
sys.refresh_all();
|
||||
|
||||
let mut format_info = HashMap::new();
|
||||
|
||||
let actual_used_memory = sys.total_memory() - sys.available_memory();
|
||||
let memory_percent = actual_used_memory as f64 / sys.total_memory() as f64 * 100.0;
|
||||
|
||||
let cpu_percent = sys.global_cpu_info().cpu_usage();
|
||||
|
||||
// TODO: Add remaining format info
|
||||
|
||||
format_info.insert("memory-percent", format!("{:0>2.0}", memory_percent));
|
||||
format_info.insert("cpu-percent", format!("{:0>2.0}", cpu_percent));
|
||||
|
||||
tx.send(format_info)
|
||||
.expect("Failed to send system info map");
|
||||
|
||||
sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
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()) {
|
||||
let format_compiled = re.replace(format, |caps: &Captures| {
|
||||
let format_compiled = re.replace_all(format, |caps: &Captures| {
|
||||
info.get(&caps[1])
|
||||
.unwrap_or(&caps[0].to_string())
|
||||
.to_string()
|
||||
});
|
||||
|
||||
label.set_text(format_compiled.as_ref());
|
||||
label.set_markup(format_compiled.as_ref());
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(container)
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_memory_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
|
||||
sys.refresh_memory();
|
||||
|
||||
let total_memory = sys.total_memory();
|
||||
let available_memory = sys.available_memory();
|
||||
|
||||
let actual_used_memory = total_memory - available_memory;
|
||||
let memory_percent = actual_used_memory as f64 / total_memory as f64 * 100.0;
|
||||
|
||||
format_info.insert(
|
||||
String::from("memory_free"),
|
||||
(bytes_to_gigabytes(available_memory)).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("memory_used"),
|
||||
(bytes_to_gigabytes(actual_used_memory)).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("memory_total"),
|
||||
(bytes_to_gigabytes(total_memory)).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("memory_percent"),
|
||||
format!("{:0>2.0}", memory_percent),
|
||||
);
|
||||
|
||||
let used_swap = sys.used_swap();
|
||||
let total_swap = sys.total_swap();
|
||||
|
||||
format_info.insert(
|
||||
String::from("swap_free"),
|
||||
(bytes_to_gigabytes(sys.free_swap())).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("swap_used"),
|
||||
(bytes_to_gigabytes(used_swap)).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("swap_total"),
|
||||
(bytes_to_gigabytes(total_swap)).to_string(),
|
||||
);
|
||||
format_info.insert(
|
||||
String::from("swap_percent"),
|
||||
format!("{:0>2.0}", used_swap as f64 / total_swap as f64 * 100.0),
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_cpu_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
|
||||
sys.refresh_cpu();
|
||||
|
||||
let cpu_info = sys.global_cpu_info();
|
||||
let cpu_percent = cpu_info.cpu_usage();
|
||||
|
||||
format_info.insert(
|
||||
String::from("cpu_percent"),
|
||||
format!("{:0>2.0}", cpu_percent),
|
||||
);
|
||||
}
|
||||
|
||||
fn refresh_temp_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
|
||||
sys.refresh_components();
|
||||
|
||||
let components = sys.components();
|
||||
for component in components {
|
||||
let key = component.label().replace(' ', "-");
|
||||
let temp = component.temperature();
|
||||
|
||||
format_info.insert(format!("temp_c:{key}"), format!("{temp:.0}"));
|
||||
format_info.insert(format!("temp_f:{key}"), format!("{:.0}", c_to_f(temp)));
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_disk_tokens(format_info: &mut HashMap<String, String>, sys: &mut System) {
|
||||
sys.refresh_disks();
|
||||
|
||||
for disk in sys.disks() {
|
||||
// replace braces to avoid conflict with regex
|
||||
let key = disk
|
||||
.mount_point()
|
||||
.to_str()
|
||||
.map(|s| s.replace(['{', '}'], ""));
|
||||
|
||||
if let Some(key) = key {
|
||||
let total = disk.total_space();
|
||||
let available = disk.available_space();
|
||||
let used = total - available;
|
||||
|
||||
format_info.insert(
|
||||
format!("disk_free:{key}"),
|
||||
bytes_to_gigabytes(available).to_string(),
|
||||
);
|
||||
|
||||
format_info.insert(
|
||||
format!("disk_used:{key}"),
|
||||
bytes_to_gigabytes(used).to_string(),
|
||||
);
|
||||
|
||||
format_info.insert(
|
||||
format!("disk_total:{key}"),
|
||||
bytes_to_gigabytes(total).to_string(),
|
||||
);
|
||||
|
||||
format_info.insert(
|
||||
format!("disk_percent:{key}"),
|
||||
format!("{:0>2.0}", used as f64 / total as f64 * 100.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_network_tokens(
|
||||
format_info: &mut HashMap<String, String>,
|
||||
sys: &mut System,
|
||||
interval: u64,
|
||||
) {
|
||||
sys.refresh_networks();
|
||||
|
||||
for (iface, network) in sys.networks() {
|
||||
format_info.insert(
|
||||
format!("net_down:{iface}"),
|
||||
format!("{:0>2.0}", bytes_to_megabits(network.received()) / interval),
|
||||
);
|
||||
|
||||
format_info.insert(
|
||||
format!("net_up:{iface}"),
|
||||
format!(
|
||||
"{:0>2.0}",
|
||||
bytes_to_megabits(network.transmitted()) / interval
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_system_tokens(format_info: &mut HashMap<String, String>, sys: &System) {
|
||||
// no refresh required for these tokens
|
||||
|
||||
let load_average = sys.load_average();
|
||||
format_info.insert(String::from("load_average:1"), load_average.one.to_string());
|
||||
|
||||
format_info.insert(
|
||||
String::from("load_average:5"),
|
||||
load_average.five.to_string(),
|
||||
);
|
||||
|
||||
format_info.insert(
|
||||
String::from("load_average:15"),
|
||||
load_average.fifteen.to_string(),
|
||||
);
|
||||
|
||||
let uptime = Duration::from_secs(sys.uptime()).as_secs();
|
||||
let hours = uptime / 3600;
|
||||
format_info.insert(
|
||||
String::from("uptime"),
|
||||
format!("{:0>2}:{:0>2}", hours, (uptime % 3600) / 60),
|
||||
);
|
||||
}
|
||||
|
||||
/// Converts celsius to fahrenheit.
|
||||
fn c_to_f(c: f32) -> f32 {
|
||||
c * 9.0 / 5.0 + 32.0
|
||||
}
|
||||
|
||||
const fn bytes_to_gigabytes(b: u64) -> u64 {
|
||||
const BYTES_IN_GIGABYTE: u64 = 1_000_000_000;
|
||||
b / BYTES_IN_GIGABYTE
|
||||
}
|
||||
|
||||
const fn bytes_to_megabits(b: u64) -> u64 {
|
||||
const BYTES_IN_MEGABIT: u64 = 125_000;
|
||||
b / BYTES_IN_MEGABIT
|
||||
}
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::await_sync;
|
||||
use crate::clients::system_tray::get_tray_event_client;
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::Result;
|
||||
use futures_util::StreamExt;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconLookupFlags, IconTheme, Image, Menu, MenuBar, MenuItem, SeparatorMenuItem};
|
||||
use serde::Deserialize;
|
||||
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::{NotifierItemCommand, NotifierItemMessage};
|
||||
use stray::SystemTray;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct TrayModule;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum TrayUpdate {
|
||||
Update(String, Box<StatusNotifierItem>, Option<TrayMenu>),
|
||||
Remove(String),
|
||||
pub struct TrayModule {
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
/// Gets a GTK `Image` component
|
||||
@@ -26,7 +25,7 @@ enum TrayUpdate {
|
||||
fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
|
||||
item.icon_theme_path.as_ref().and_then(|path| {
|
||||
let theme = IconTheme::new();
|
||||
theme.append_search_path(&path);
|
||||
theme.append_search_path(path);
|
||||
|
||||
item.icon_name.as_ref().and_then(|icon_name| {
|
||||
let icon_info = theme.lookup_icon(icon_name, 16, IconLookupFlags::empty());
|
||||
@@ -39,7 +38,7 @@ fn get_icon(item: &StatusNotifierItem) -> Option<Image> {
|
||||
/// for the provided submenu array.
|
||||
fn get_menu_items(
|
||||
menu: &[MenuItemInfo],
|
||||
tx: &mpsc::Sender<NotifierItemCommand>,
|
||||
tx: &Sender<NotifierItemCommand>,
|
||||
id: &str,
|
||||
path: &str,
|
||||
) -> Vec<MenuItem> {
|
||||
@@ -90,72 +89,88 @@ fn get_menu_items(
|
||||
}
|
||||
|
||||
impl Module<MenuBar> for TrayModule {
|
||||
fn into_widget(self, _info: &ModuleInfo) -> Result<MenuBar> {
|
||||
let container = MenuBar::new();
|
||||
type SendMessage = NotifierItemMessage;
|
||||
type ReceiveMessage = NotifierItemCommand;
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
let (ui_tx, ui_rx) = mpsc::channel(32);
|
||||
fn spawn_controller(
|
||||
&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 {
|
||||
// FIXME: Can only spawn one of these at a time - means cannot have tray on multiple bars
|
||||
let mut tray = SystemTray::new(ui_rx).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))
|
||||
.expect("Failed to send tray update event");
|
||||
}
|
||||
NotifierItemMessage::Remove { address: id } => {
|
||||
tx.send(TrayUpdate::Remove(id))
|
||||
.expect("Failed to send tray remove event");
|
||||
}
|
||||
}
|
||||
while let Ok(message) = tray_rx.recv().await {
|
||||
tx.send(ModuleUpdateEvent::Update(message)).await?;
|
||||
}
|
||||
|
||||
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 mut widgets = HashMap::new();
|
||||
|
||||
// listen for UI updates
|
||||
rx.attach(None, move |update| {
|
||||
context.widget_rx.attach(None, move |update| {
|
||||
match update {
|
||||
TrayUpdate::Update(id, item, menu) => {
|
||||
let menu_item = widgets.remove(id.as_str()).unwrap_or_else(|| {
|
||||
NotifierItemMessage::Update {
|
||||
item,
|
||||
address,
|
||||
menu,
|
||||
} => {
|
||||
let menu_item = widgets.remove(address.as_str()).unwrap_or_else(|| {
|
||||
let menu_item = MenuItem::new();
|
||||
menu_item.style_context().add_class("item");
|
||||
if let Some(image) = get_icon(&item) {
|
||||
image.set_widget_name(id.as_str());
|
||||
image.set_widget_name(address.as_str());
|
||||
menu_item.add(&image);
|
||||
}
|
||||
|
||||
container.add(&menu_item);
|
||||
menu_item.show_all();
|
||||
|
||||
menu_item
|
||||
});
|
||||
|
||||
if let (Some(menu_opts), Some(menu_path)) = (menu, item.menu) {
|
||||
let submenus = menu_opts.submenus;
|
||||
if !submenus.is_empty() {
|
||||
let menu = Menu::new();
|
||||
get_menu_items(&submenus, &ui_tx.clone(), &id, &menu_path)
|
||||
.iter()
|
||||
.for_each(|item| menu.add(item));
|
||||
get_menu_items(
|
||||
&submenus,
|
||||
&context.controller_tx.clone(),
|
||||
&address,
|
||||
&menu_path,
|
||||
)
|
||||
.iter()
|
||||
.for_each(|item| menu.add(item));
|
||||
menu_item.set_submenu(Some(&menu));
|
||||
}
|
||||
}
|
||||
|
||||
widgets.insert(id, menu_item);
|
||||
widgets.insert(address, menu_item);
|
||||
}
|
||||
TrayUpdate::Remove(id) => {
|
||||
if let Some(widget) = widgets.get(&id) {
|
||||
NotifierItemMessage::Remove { address } => {
|
||||
if let Some(widget) = widgets.get(&address) {
|
||||
container.remove(widget);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +180,9 @@ impl Module<MenuBar> for TrayModule {
|
||||
});
|
||||
};
|
||||
|
||||
Ok(container)
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,62 +1,83 @@
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::sway::{get_client, Workspace};
|
||||
use crate::await_sync;
|
||||
use crate::clients::sway::{get_client, get_sub_client};
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use color_eyre::{Report, Result};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Orientation};
|
||||
use ksway::IpcCommand;
|
||||
use gtk::Button;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use swayipc_async::{Workspace, WorkspaceChange, WorkspaceEvent};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::{debug, trace};
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::trace;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WorkspacesModule {
|
||||
/// Map of actual workspace names to custom names.
|
||||
name_map: Option<HashMap<String, String>>,
|
||||
|
||||
/// Whether to display icons for all monitors.
|
||||
/// Whether to display buttons for all monitors.
|
||||
#[serde(default = "crate::config::default_false")]
|
||||
all_monitors: bool,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: CommonConfig,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn as_button(&self, name_map: &HashMap<String, String>, tx: &mpsc::Sender<String>) -> Button {
|
||||
let button = Button::builder()
|
||||
.label(name_map.get(self.name.as_str()).unwrap_or(&self.name))
|
||||
.build();
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum WorkspaceUpdate {
|
||||
Init(Vec<Workspace>),
|
||||
Update(Box<WorkspaceEvent>),
|
||||
}
|
||||
|
||||
let style_context = button.style_context();
|
||||
style_context.add_class("item");
|
||||
/// Creates a button from a workspace
|
||||
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 {
|
||||
style_context.add_class("focused");
|
||||
}
|
||||
let style_context = button.style_context();
|
||||
style_context.add_class("item");
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let name = self.name.clone();
|
||||
button.connect_clicked(move |_item| {
|
||||
tx.try_send(name.clone())
|
||||
.expect("Failed to send workspace click event");
|
||||
});
|
||||
}
|
||||
|
||||
button
|
||||
if focused {
|
||||
style_context.add_class("focused");
|
||||
}
|
||||
|
||||
{
|
||||
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 {
|
||||
fn into_widget(self, info: &ModuleInfo) -> Result<gtk::Box> {
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||
type SendMessage = WorkspaceUpdate;
|
||||
type ReceiveMessage = String;
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
mut rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
let workspaces = {
|
||||
trace!("Getting current workspaces");
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
let raw = sway.ipc(IpcCommand::GetWorkspaces)?;
|
||||
let workspaces = serde_json::from_slice::<Vec<Workspace>>(&raw)?;
|
||||
let workspaces = await_sync(async {
|
||||
let sway = get_client().await;
|
||||
let mut sway = sway.lock().await;
|
||||
sway.get_workspaces().await
|
||||
})?;
|
||||
|
||||
if self.all_monitors {
|
||||
workspaces
|
||||
@@ -69,108 +90,154 @@ impl Module<gtk::Box> for WorkspacesModule {
|
||||
}
|
||||
};
|
||||
|
||||
let name_map = self.name_map.unwrap_or_default();
|
||||
|
||||
let mut button_map: HashMap<String, Button> = HashMap::new();
|
||||
|
||||
let (ui_tx, mut ui_rx) = mpsc::channel(32);
|
||||
|
||||
trace!("Creating workspace buttons");
|
||||
for workspace in workspaces {
|
||||
let item = workspace.as_button(&name_map, &ui_tx);
|
||||
container.add(&item);
|
||||
button_map.insert(workspace.name, item);
|
||||
}
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn_blocking(move || {
|
||||
trace!("Starting workspace event listener task");
|
||||
let srx = {
|
||||
let sway = get_client();
|
||||
let mut sway = sway.lock().expect("Failed to get lock on Sway IPC client");
|
||||
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()
|
||||
};
|
||||
|
||||
while let Ok(payload) = srx.recv() {
|
||||
tx.send(payload).expect("Failed to send workspace event");
|
||||
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");
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
trace!("Setting up sway event handler");
|
||||
let menubar = container.clone();
|
||||
let output_name = info.output_name.to_string();
|
||||
rx.attach(None, move |event| {
|
||||
debug!("Received workspace event {:?}", event);
|
||||
match event.change.as_str() {
|
||||
"focus" => {
|
||||
let old = event.old.and_then(|old| button_map.get(&old.name));
|
||||
if let Some(old) = old {
|
||||
old.style_context().remove_class("focused");
|
||||
}
|
||||
|
||||
let new = event.current.and_then(|new| button_map.get(&new.name));
|
||||
if let Some(new) = new {
|
||||
new.style_context().add_class("focused");
|
||||
}
|
||||
|
||||
trace!("{:?} {:?}", old, new);
|
||||
}
|
||||
"init" => {
|
||||
if let Some(workspace) = event.current {
|
||||
if self.all_monitors || workspace.output == output_name {
|
||||
let item = workspace.as_button(&name_map, &ui_tx);
|
||||
|
||||
item.show();
|
||||
menubar.add(&item);
|
||||
button_map.insert(workspace.name, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
"move" => {
|
||||
if let Some(workspace) = event.current {
|
||||
if !self.all_monitors {
|
||||
if workspace.output == output_name {
|
||||
let item = workspace.as_button(&name_map, &ui_tx);
|
||||
|
||||
item.show();
|
||||
menubar.add(&item);
|
||||
button_map.insert(workspace.name, item);
|
||||
} else if let Some(item) = button_map.get(&workspace.name) {
|
||||
menubar.remove(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"empty" => {
|
||||
if let Some(workspace) = event.current {
|
||||
if let Some(item) = button_map.get(&workspace.name) {
|
||||
menubar.remove(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
// Change workspace focus
|
||||
spawn(async move {
|
||||
trace!("Setting up UI event handler");
|
||||
let sway = get_client();
|
||||
while let Some(name) = ui_rx.recv().await {
|
||||
let mut sway = sway
|
||||
.lock()
|
||||
.expect("Failed to get write lock on Sway IPC client");
|
||||
sway.run(format!("workspace {}", name))?;
|
||||
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(container)
|
||||
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 mut button_map: HashMap<String, Button> = HashMap::new();
|
||||
|
||||
{
|
||||
let container = container.clone();
|
||||
let output_name = info.output_name.to_string();
|
||||
|
||||
context.widget_rx.attach(None, move |event| {
|
||||
match event {
|
||||
WorkspaceUpdate::Init(workspaces) => {
|
||||
trace!("Creating workspace buttons");
|
||||
for workspace in workspaces {
|
||||
let item = create_button(
|
||||
&workspace.name,
|
||||
workspace.focused,
|
||||
&name_map,
|
||||
&context.controller_tx,
|
||||
);
|
||||
container.add(&item);
|
||||
button_map.insert(workspace.name, item);
|
||||
}
|
||||
container.show_all();
|
||||
}
|
||||
WorkspaceUpdate::Update(event) if event.change == WorkspaceChange::Focus => {
|
||||
let old = event
|
||||
.old
|
||||
.and_then(|old| old.name)
|
||||
.and_then(|name| button_map.get(&name));
|
||||
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)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
218
src/popup.rs
218
src/popup.rs
@@ -1,27 +1,31 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::config::BarPosition;
|
||||
use crate::modules::ModuleInfo;
|
||||
use gtk::gdk::Monitor;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Application, ApplicationWindow, Button, Orientation};
|
||||
use gtk::{ApplicationWindow, Button, Orientation};
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Popup {
|
||||
pub window: ApplicationWindow,
|
||||
pub container: gtk::Box,
|
||||
pub cache: HashMap<usize, gtk::Box>,
|
||||
monitor: Monitor,
|
||||
pos: BarPosition,
|
||||
}
|
||||
|
||||
impl Popup {
|
||||
/// Creates a new popup window.
|
||||
/// This includes setting up gtk-layer-shell
|
||||
/// and an empty `gtk::Box` container.
|
||||
pub fn new(
|
||||
name: &str,
|
||||
app: &Application,
|
||||
monitor: &Monitor,
|
||||
orientation: Orientation,
|
||||
bar_position: &BarPosition,
|
||||
) -> Self {
|
||||
let win = ApplicationWindow::builder().application(app).build();
|
||||
pub fn new(module_info: &ModuleInfo) -> Self {
|
||||
let pos = module_info.bar_position;
|
||||
let orientation = pos.get_orientation();
|
||||
|
||||
let win = ApplicationWindow::builder()
|
||||
.application(module_info.app)
|
||||
.build();
|
||||
|
||||
gtk_layer_shell::init_for_window(&win);
|
||||
gtk_layer_shell::set_layer(&win, gtk_layer_shell::Layer::Overlay);
|
||||
@@ -29,57 +33,69 @@ impl Popup {
|
||||
gtk_layer_shell::set_margin(
|
||||
&win,
|
||||
gtk_layer_shell::Edge::Top,
|
||||
if bar_position == &BarPosition::Top {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
},
|
||||
if pos == BarPosition::Top { 5 } else { 0 },
|
||||
);
|
||||
gtk_layer_shell::set_margin(
|
||||
&win,
|
||||
gtk_layer_shell::Edge::Bottom,
|
||||
if bar_position == &BarPosition::Bottom {
|
||||
5
|
||||
} else {
|
||||
0
|
||||
},
|
||||
if pos == BarPosition::Bottom { 5 } else { 0 },
|
||||
);
|
||||
gtk_layer_shell::set_margin(
|
||||
&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(
|
||||
&win,
|
||||
gtk_layer_shell::Edge::Top,
|
||||
bar_position == &BarPosition::Top,
|
||||
pos == BarPosition::Top || orientation == Orientation::Vertical,
|
||||
);
|
||||
gtk_layer_shell::set_anchor(
|
||||
&win,
|
||||
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()
|
||||
.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| {
|
||||
win.connect_leave_notify_event(move |win, ev| {
|
||||
const THRESHOLD: f64 = 3.0;
|
||||
|
||||
let (w, _h) = win.size();
|
||||
let (w, h) = win.size();
|
||||
let (x, y) = ev.position();
|
||||
|
||||
// some child widgets trigger this event
|
||||
// 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();
|
||||
}
|
||||
|
||||
@@ -88,15 +104,37 @@ impl Popup {
|
||||
|
||||
Self {
|
||||
window: win,
|
||||
container: content,
|
||||
monitor: monitor.clone(),
|
||||
cache: HashMap::new(),
|
||||
monitor: module_info.monitor.clone(),
|
||||
pos,
|
||||
}
|
||||
}
|
||||
|
||||
/// Shows the popover
|
||||
pub fn show(&self, button: &Button) {
|
||||
pub fn register_content(&mut self, key: usize, content: gtk::Box) {
|
||||
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.set_pos(button);
|
||||
self.set_pos(geometry);
|
||||
}
|
||||
|
||||
/// Hides the popover
|
||||
@@ -104,27 +142,89 @@ impl Popup {
|
||||
self.window.hide();
|
||||
}
|
||||
|
||||
/// Sets the popover's X position relative to the left border of the screen
|
||||
fn set_pos(&self, button: &Button) {
|
||||
let widget_width = button.allocation().width();
|
||||
let screen_width = self.monitor.workarea().width();
|
||||
let popup_width = self.window.allocated_width();
|
||||
/// Checks if the popup is currently visible
|
||||
pub fn is_visible(&self) -> bool {
|
||||
self.window.is_visible()
|
||||
}
|
||||
|
||||
let top_level = button.toplevel().expect("Failed to get top-level widget");
|
||||
let (widget_x, _) = button
|
||||
.translate_coordinates(&top_level, 0, 0)
|
||||
.unwrap_or((0, 0));
|
||||
/// Sets the popup's X/Y position relative to the left or border of the screen
|
||||
/// (depending on orientation).
|
||||
fn set_pos(&self, geometry: ButtonGeometry) {
|
||||
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 {
|
||||
offset = 5.0;
|
||||
} else if offset > f64::from(screen_width - popup_width) - 5.0 {
|
||||
offset = f64::from(screen_width - popup_width) - 5.0;
|
||||
} else if offset > f64::from(screen_size - popup_size) - 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,
|
||||
}
|
||||
|
||||
354
src/script.rs
Normal file
354
src/script.rs
Normal file
@@ -0,0 +1,354 @@
|
||||
use color_eyre::eyre::WrapErr;
|
||||
use color_eyre::{Report, Result};
|
||||
use serde::Deserialize;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::process::Stdio;
|
||||
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||
use tokio::process::Command;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::time::sleep;
|
||||
use tokio::{select, spawn};
|
||||
use tracing::{error, warn};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum ScriptInput {
|
||||
String(String),
|
||||
Struct(Script),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy, Eq, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ScriptMode {
|
||||
Poll,
|
||||
Watch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum OutputStream {
|
||||
Stdout(String),
|
||||
Stderr(String),
|
||||
}
|
||||
|
||||
impl From<&str> for ScriptMode {
|
||||
fn from(str: &str) -> Self {
|
||||
match str {
|
||||
"poll" | "p" => Self::Poll,
|
||||
"watch" | "w" => Self::Watch,
|
||||
_ => {
|
||||
warn!("Invalid script mode: '{str}', falling back to polling");
|
||||
ScriptMode::Poll
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ScriptMode {
|
||||
fn default() -> Self {
|
||||
Self::Poll
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ScriptMode {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
ScriptMode::Poll => "poll",
|
||||
ScriptMode::Watch => "watch",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScriptMode {
|
||||
fn try_parse(str: &str) -> Result<Self> {
|
||||
match str {
|
||||
"poll" | "p" => Ok(Self::Poll),
|
||||
"watch" | "w" => Ok(Self::Watch),
|
||||
_ => Err(Report::msg(format!("Invalid script mode: {str}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Script {
|
||||
#[serde(default = "ScriptMode::default")]
|
||||
pub(crate) mode: ScriptMode,
|
||||
pub cmd: String,
|
||||
#[serde(default = "default_interval")]
|
||||
pub(crate) interval: u64,
|
||||
}
|
||||
|
||||
const fn default_interval() -> u64 {
|
||||
5000
|
||||
}
|
||||
|
||||
impl Default for Script {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: ScriptMode::default(),
|
||||
interval: default_interval(),
|
||||
cmd: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ScriptInput> for Script {
|
||||
fn from(input: ScriptInput) -> Self {
|
||||
match input {
|
||||
ScriptInput::String(string) => Self::from(string.as_str()),
|
||||
ScriptInput::Struct(script) => script,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ScriptInputToken {
|
||||
Mode(ScriptMode),
|
||||
Interval(u64),
|
||||
Cmd(String),
|
||||
Colon,
|
||||
}
|
||||
|
||||
impl From<&str> for Script {
|
||||
fn from(str: &str) -> Self {
|
||||
let mut script = Self::default();
|
||||
let mut tokens = vec![];
|
||||
|
||||
let mut chars = str.chars().collect::<Vec<_>>();
|
||||
while !chars.is_empty() {
|
||||
let char = chars[0];
|
||||
|
||||
let (token, skip) = match char {
|
||||
':' => (ScriptInputToken::Colon, 1),
|
||||
// interval
|
||||
'0'..='9' => {
|
||||
let interval_str = chars
|
||||
.iter()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>();
|
||||
(
|
||||
ScriptInputToken::Interval(
|
||||
interval_str.parse::<u64>().expect("Invalid interval"),
|
||||
),
|
||||
interval_str.len(),
|
||||
)
|
||||
}
|
||||
// watching or polling
|
||||
'w' | 'p' => {
|
||||
let mode_str = chars.iter().take_while(|&c| c != &':').collect::<String>();
|
||||
let len = mode_str.len();
|
||||
|
||||
let token = ScriptMode::try_parse(&mode_str)
|
||||
.map_or(ScriptInputToken::Cmd(mode_str), |mode| {
|
||||
ScriptInputToken::Mode(mode)
|
||||
});
|
||||
|
||||
(token, len)
|
||||
}
|
||||
_ => {
|
||||
let cmd_str = chars.iter().take_while(|_| true).collect::<String>();
|
||||
let len = cmd_str.len();
|
||||
(ScriptInputToken::Cmd(cmd_str), len)
|
||||
}
|
||||
};
|
||||
|
||||
tokens.push(token);
|
||||
chars.drain(..skip);
|
||||
}
|
||||
|
||||
for token in tokens {
|
||||
match token {
|
||||
ScriptInputToken::Mode(mode) => script.mode = mode,
|
||||
ScriptInputToken::Interval(interval) => script.interval = interval,
|
||||
ScriptInputToken::Cmd(cmd) => script.cmd = cmd,
|
||||
ScriptInputToken::Colon => {}
|
||||
}
|
||||
}
|
||||
|
||||
script
|
||||
}
|
||||
}
|
||||
|
||||
impl Script {
|
||||
pub fn new_polling(input: ScriptInput) -> Self {
|
||||
let mut script = Self::from(input);
|
||||
script.mode = ScriptMode::Poll;
|
||||
script
|
||||
}
|
||||
|
||||
pub async fn run<F>(&self, callback: F)
|
||||
where
|
||||
F: Fn((OutputStream, bool)),
|
||||
{
|
||||
loop {
|
||||
match self.mode {
|
||||
ScriptMode::Poll => match self.get_output().await {
|
||||
Ok(output) => callback(output),
|
||||
Err(err) => error!("{err:?}"),
|
||||
},
|
||||
ScriptMode::Watch => match self.spawn().await {
|
||||
Ok(mut rx) => {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
callback((msg, true));
|
||||
}
|
||||
}
|
||||
Err(err) => error!("{err:?}"),
|
||||
},
|
||||
};
|
||||
|
||||
sleep(tokio::time::Duration::from_millis(self.interval)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to execute a given command,
|
||||
/// waiting for it to finish.
|
||||
/// If the command returns status 0,
|
||||
/// the `stdout` is returned.
|
||||
/// Otherwise, an `Err` variant
|
||||
/// containing the `stderr` is returned.
|
||||
pub async fn get_output(&self) -> Result<(OutputStream, bool)> {
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", &self.cmd])
|
||||
.output()
|
||||
.await
|
||||
.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((OutputStream::Stdout(stdout), true))
|
||||
} else {
|
||||
let stderr = String::from_utf8(output.stderr)
|
||||
.map(|output| output.trim().to_string())
|
||||
.wrap_err("Script stderr not valid UTF-8")?;
|
||||
|
||||
Ok((OutputStream::Stderr(stderr), false))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn spawn(&self) -> Result<mpsc::Receiver<OutputStream>> {
|
||||
let mut handle = Command::new("sh")
|
||||
.args(["-c", &self.cmd])
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
let mut stdout_lines = BufReader::new(
|
||||
handle
|
||||
.stdout
|
||||
.take()
|
||||
.expect("Failed to take script handle stdout"),
|
||||
)
|
||||
.lines();
|
||||
|
||||
let mut stderr_lines = BufReader::new(
|
||||
handle
|
||||
.stderr
|
||||
.take()
|
||||
.expect("Failed to take script handle stderr"),
|
||||
)
|
||||
.lines();
|
||||
|
||||
let (tx, rx) = mpsc::channel(32);
|
||||
|
||||
spawn(async move {
|
||||
loop {
|
||||
select! {
|
||||
_ = handle.wait() => break,
|
||||
Ok(Some(line)) = stdout_lines.next_line() => {
|
||||
tx.send(OutputStream::Stdout(line)).await.expect("Failed to send stdout");
|
||||
}
|
||||
Ok(Some(line)) = stderr_lines.next_line() => {
|
||||
tx.send(OutputStream::Stderr(line)).await.expect("Failed to send stderr");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_basic() {
|
||||
let cmd = "echo 'hello'";
|
||||
let script = Script::from(cmd);
|
||||
|
||||
assert_eq!(script.cmd, cmd);
|
||||
assert_eq!(script.interval, default_interval());
|
||||
assert_eq!(script.mode, ScriptMode::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full() {
|
||||
let cmd = "echo 'hello'";
|
||||
let mode = ScriptMode::Watch;
|
||||
let interval = 300;
|
||||
|
||||
let full_cmd = format!("{mode}:{interval}:{cmd}");
|
||||
let script = Script::from(full_cmd.as_str());
|
||||
|
||||
assert_eq!(script.cmd, cmd);
|
||||
assert_eq!(script.mode, mode);
|
||||
assert_eq!(script.interval, interval);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_interval_and_cmd() {
|
||||
let cmd = "echo 'hello'";
|
||||
let interval = 300;
|
||||
|
||||
let full_cmd = format!("{interval}:{cmd}");
|
||||
let script = Script::from(full_cmd.as_str());
|
||||
|
||||
assert_eq!(script.cmd, cmd);
|
||||
assert_eq!(script.interval, interval);
|
||||
assert_eq!(script.mode, ScriptMode::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_mode_and_cmd() {
|
||||
let cmd = "echo 'hello'";
|
||||
let mode = ScriptMode::Watch;
|
||||
|
||||
let full_cmd = format!("{mode}:{cmd}");
|
||||
let script = Script::from(full_cmd.as_str());
|
||||
|
||||
assert_eq!(script.cmd, cmd);
|
||||
assert_eq!(script.interval, default_interval());
|
||||
assert_eq!(script.mode, mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cmd_with_colon() {
|
||||
let cmd = "uptime | awk '{print \"Uptime: \" $1}'";
|
||||
let script = Script::from(cmd);
|
||||
|
||||
assert_eq!(script.cmd, cmd);
|
||||
assert_eq!(script.interval, default_interval());
|
||||
assert_eq!(script.mode, ScriptMode::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_cmd() {
|
||||
let mode = ScriptMode::Watch;
|
||||
let interval = 300;
|
||||
|
||||
let full_cmd = format!("{mode}:{interval}");
|
||||
let script = Script::from(full_cmd.as_str());
|
||||
|
||||
assert_eq!(script.cmd, ""); // TODO: Probably better handle this case
|
||||
assert_eq!(script.interval, interval);
|
||||
assert_eq!(script.mode, mode);
|
||||
}
|
||||
}
|
||||
43
src/style.rs
43
src/style.rs
@@ -2,10 +2,13 @@ use color_eyre::{Help, Report};
|
||||
use glib::Continue;
|
||||
use gtk::prelude::CssProviderExt;
|
||||
use gtk::{gdk, gio, CssProvider, StyleContext};
|
||||
use notify::{Event, RecursiveMode, Result, Watcher};
|
||||
use notify::event::{DataChange, ModifyKind};
|
||||
use notify::{recommended_watcher, Event, EventKind, RecursiveMode, Result, Watcher};
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tokio::spawn;
|
||||
use tracing::{error, info};
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
/// Attempts to load CSS file at the given path
|
||||
/// and attach if to the current GTK application.
|
||||
@@ -15,13 +18,14 @@ use tracing::{error, info};
|
||||
pub fn load_css(style_path: PathBuf) {
|
||||
let provider = CssProvider::new();
|
||||
|
||||
if let Err(err) = provider.load_from_file(&gio::File::for_path(&style_path)) {
|
||||
error!("{:?}", Report::new(err)
|
||||
match provider.load_from_file(&gio::File::for_path(&style_path)) {
|
||||
Ok(()) => debug!("Loaded css from '{}'", style_path.display()),
|
||||
Err(err) => 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);
|
||||
@@ -29,24 +33,27 @@ pub fn load_css(style_path: PathBuf) {
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
match notify::recommended_watcher(move |res: Result<Event>| match res {
|
||||
Ok(event) => {
|
||||
let mut watcher = recommended_watcher(move |res: Result<Event>| match res {
|
||||
Ok(event) if event.kind == EventKind::Modify(ModifyKind::Data(DataChange::Any)) => {
|
||||
debug!("{event:?}");
|
||||
if let Some(path) = event.paths.first() {
|
||||
tx.send(path.clone())
|
||||
.expect("Failed to send style changed message");
|
||||
}
|
||||
}
|
||||
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")
|
||||
),
|
||||
_ => {}
|
||||
})
|
||||
.expect("Failed to create CSS file watcher");
|
||||
|
||||
watcher
|
||||
.watch(&style_path, RecursiveMode::NonRecursive)
|
||||
.expect("Failed to start CSS file watcher");
|
||||
debug!("Installed CSS file watcher on '{}'", style_path.display());
|
||||
|
||||
// avoid watcher from dropping
|
||||
loop {
|
||||
sleep(Duration::from_secs(1)).await;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
224
src/sway/mod.rs
224
src/sway/mod.rs
@@ -1,224 +0,0 @@
|
||||
use color_eyre::{Report, Result};
|
||||
use crossbeam_channel::Receiver;
|
||||
use ksway::{Error, IpcCommand, IpcEvent};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::Deserialize;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::spawn;
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
pub mod node;
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct WorkspaceEvent {
|
||||
pub change: String,
|
||||
pub old: Option<Workspace>,
|
||||
pub current: Option<Workspace>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Debug, Clone)]
|
||||
pub struct Workspace {
|
||||
pub name: String,
|
||||
pub focused: bool,
|
||||
// pub num: i32,
|
||||
pub output: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WindowEvent {
|
||||
pub change: String,
|
||||
pub container: SwayNode,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
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, Clone)]
|
||||
pub struct WindowProperties {
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SwayOutput {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
type Broadcaster<T> = Arc<Mutex<UnboundedBroadcast<T>>>;
|
||||
|
||||
pub struct SwayClient {
|
||||
client: ksway::Client,
|
||||
|
||||
workspace_bc: Broadcaster<WorkspaceEvent>,
|
||||
window_bc: Broadcaster<WindowEvent>,
|
||||
}
|
||||
|
||||
impl SwayClient {
|
||||
fn connect() -> Result<Self> {
|
||||
let client = match ksway::Client::connect() {
|
||||
Ok(client) => Ok(client),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}?;
|
||||
info!("Sway IPC client connected");
|
||||
|
||||
let workspace_bc = Arc::new(Mutex::new(UnboundedBroadcast::new()));
|
||||
let window_bc = Arc::new(Mutex::new(UnboundedBroadcast::new()));
|
||||
|
||||
let workspace_bc2 = workspace_bc.clone();
|
||||
let window_bc2 = window_bc.clone();
|
||||
spawn(async move {
|
||||
let mut sub_client = match ksway::Client::connect() {
|
||||
Ok(client) => Ok(client),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}
|
||||
.expect("Failed to connect to Sway IPC server");
|
||||
info!("Sway IPC subscription client connected");
|
||||
|
||||
let event_types = vec![IpcEvent::Window, IpcEvent::Workspace];
|
||||
let rx = match sub_client.subscribe(event_types) {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}
|
||||
.expect("Failed to subscribe to Sway IPC server");
|
||||
|
||||
loop {
|
||||
while let Ok((ev_type, payload)) = rx.try_recv() {
|
||||
debug!("Received sway event {:?}", ev_type);
|
||||
match ev_type {
|
||||
IpcEvent::Workspace => {
|
||||
let json = serde_json::from_slice::<WorkspaceEvent>(&payload).expect(
|
||||
"Received invalid workspace event payload from Sway IPC server",
|
||||
);
|
||||
workspace_bc
|
||||
.lock()
|
||||
.expect("Failed to get lock on workspace event bus")
|
||||
.send(json)
|
||||
.expect("Failed to broadcast workspace event");
|
||||
}
|
||||
IpcEvent::Window => {
|
||||
let json = serde_json::from_slice::<WindowEvent>(&payload).expect(
|
||||
"Received invalid window event payload from Sway IPC server",
|
||||
);
|
||||
window_bc
|
||||
.lock()
|
||||
.expect("Failed to get lock on window event bus")
|
||||
.send(json)
|
||||
.expect("Failed to broadcast window event");
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
match sub_client.poll() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}
|
||||
.expect("Failed to poll Sway IPC client");
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
workspace_bc: workspace_bc2,
|
||||
window_bc: window_bc2,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ipc(&mut self, command: IpcCommand) -> Result<Vec<u8>> {
|
||||
debug!("Sending command: {:?}", command);
|
||||
match self.client.ipc(command) {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn run(&mut self, cmd: String) -> Result<Vec<u8>> {
|
||||
debug!("Sending command: {}", cmd);
|
||||
match self.client.run(cmd) {
|
||||
Ok(res) => Ok(res),
|
||||
Err(err) => Err(get_client_error(err)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_workspace(&mut self) -> Receiver<WorkspaceEvent> {
|
||||
trace!("Adding new workspace subscriber");
|
||||
self.workspace_bc
|
||||
.lock()
|
||||
.expect("Failed to get lock on workspace event bus")
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
pub fn subscribe_window(&mut self) -> Receiver<WindowEvent> {
|
||||
trace!("Adding new window subscriber");
|
||||
self.window_bc
|
||||
.lock()
|
||||
.expect("Failed to get lock on window event bus")
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets an error report from a `ksway` error enum variant
|
||||
pub fn get_client_error(error: Error) -> Report {
|
||||
match error {
|
||||
Error::SockPathNotFound => Report::msg("Sway socket path not found"),
|
||||
Error::SubscriptionError => Report::msg("Sway IPC subscription error"),
|
||||
Error::AlreadySubscribed => Report::msg("Already subscribed to Sway IPC server"),
|
||||
Error::Io(err) => Report::new(err),
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: Arc<Mutex<SwayClient>> = {
|
||||
let client = SwayClient::connect();
|
||||
match client {
|
||||
Ok(client) => Arc::new(Mutex::new(client)),
|
||||
Err(err) => panic!("{:?}", err),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_client() -> Arc<Mutex<SwayClient>> {
|
||||
Arc::clone(&CLIENT)
|
||||
}
|
||||
|
||||
/// Crossbeam channel wrapper
|
||||
/// which sends messages to all receivers.
|
||||
pub struct UnboundedBroadcast<T> {
|
||||
channels: Vec<crossbeam_channel::Sender<T>>,
|
||||
}
|
||||
|
||||
impl<T: 'static + Clone + Send + Sync> UnboundedBroadcast<T> {
|
||||
/// Creates a new broadcaster.
|
||||
pub const fn new() -> Self {
|
||||
Self { channels: vec![] }
|
||||
}
|
||||
|
||||
/// Creates a new sender/receiver pair.
|
||||
/// The sender is stored locally and the receiver is returned.
|
||||
pub fn subscribe(&mut self) -> Receiver<T> {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
|
||||
self.channels.push(tx);
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
/// Attempts to send a messsge to all receivers.
|
||||
pub fn send(&self, message: T) -> Result<(), crossbeam_channel::SendError<T>> {
|
||||
for c in &self.channels {
|
||||
c.send(message.clone())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
use crate::sway::{SwayClient, SwayNode};
|
||||
use color_eyre::Result;
|
||||
use ksway::IpcCommand;
|
||||
|
||||
impl SwayNode {
|
||||
/// Gets either the `app_id` or `class`
|
||||
/// depending on whether this is a native Wayland
|
||||
/// or xwayland application.
|
||||
pub fn get_id(&self) -> &str {
|
||||
self.app_id.as_ref().map_or_else(
|
||||
|| {
|
||||
self.window_properties
|
||||
.as_ref()
|
||||
.expect("Cannot find node window properties")
|
||||
.class
|
||||
.as_ref()
|
||||
.expect("Cannot find node name")
|
||||
},
|
||||
|app_id| app_id,
|
||||
)
|
||||
}
|
||||
|
||||
/// Checks whether this application
|
||||
/// is running under xwayland.
|
||||
pub fn is_xwayland(&self) -> bool {
|
||||
self.shell == Some(String::from("xwayland"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Recursively checks the provided node for any child application nodes.
|
||||
/// Returns a list of any found application nodes.
|
||||
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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl SwayClient {
|
||||
/// Gets a flat vector of all currently open windows.
|
||||
pub fn get_open_windows(&mut self) -> Result<Vec<SwayNode>> {
|
||||
let root_node = self.ipc(IpcCommand::GetTree)?;
|
||||
let root_node = serde_json::from_slice(&root_node)?;
|
||||
|
||||
let mut window_nodes = vec![];
|
||||
check_node(root_node, &mut window_nodes);
|
||||
|
||||
Ok(window_nodes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user