Compare commits
257 Commits
v0.5.1
...
refactor/g
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73585324c | ||
|
|
0e3102de8c | ||
|
|
ad3c171eca | ||
|
|
e5bc44168f | ||
|
|
cc62927f15 | ||
|
|
76e2b7ba3e | ||
|
|
033d0f7e6e | ||
|
|
dc16b1e15a | ||
|
|
03cd263095 | ||
|
|
db0868a3fc | ||
|
|
0382b50cf4 | ||
|
|
338f5a0e1b | ||
|
|
20949a7744 | ||
|
|
2da28b9bf5 | ||
|
|
618e97f1e8 | ||
|
|
dd7c9f30db | ||
|
|
1fa0c0e977 | ||
|
|
74d18aedfb | ||
|
|
2c88c99cb6 | ||
|
|
236bb09170 | ||
|
|
83f44fd92f | ||
|
|
1855416db4 | ||
|
|
e63509a3a7 | ||
|
|
4a09b70854 | ||
|
|
9d09855fce | ||
|
|
e9d0273176 | ||
|
|
7926bb07eb | ||
|
|
6fd69d657c | ||
|
|
27d11de661 | ||
|
|
07df51c249 | ||
|
|
b038e7671a | ||
|
|
e5ab9f33b5 | ||
|
|
68bc8230dd | ||
|
|
246313136f | ||
|
|
15a9d8d42c | ||
|
|
a87d8d5c30 | ||
|
|
8e99fd4d0f | ||
|
|
1e1d65ae49 | ||
|
|
2815cef440 | ||
|
|
138b5b3903 | ||
|
|
7355db74ec | ||
|
|
c214f65ecb | ||
|
|
3d308ab572 | ||
|
|
b770ae716c | ||
|
|
3613aef5c5 | ||
|
|
a9d1233909 | ||
|
|
72b14b6c4e | ||
|
|
910945306c | ||
|
|
dfe1964abf | ||
|
|
e928b30f99 | ||
|
|
2ab06f044e | ||
|
|
4b4f1ffc21 | ||
|
|
0691db3b87 | ||
|
|
cac064f479 | ||
|
|
6c622864b3 | ||
|
|
55c06c4766 | ||
|
|
1b0287becc | ||
|
|
7bf44ca75d | ||
|
|
fb04ceab7d | ||
|
|
102d2478a9 | ||
|
|
80a414ab67 | ||
|
|
72ba17add3 | ||
|
|
2b07620847 | ||
|
|
ba488ad38f | ||
|
|
d0b7bdbafc | ||
|
|
0f5ec1fe34 | ||
|
|
6221f7454a | ||
|
|
ecdd71a43d | ||
|
|
01a36a9476 | ||
|
|
d4dd8c41ea | ||
|
|
83c5dceaa7 | ||
|
|
711644e190 | ||
|
|
8cbb73b75e | ||
|
|
7212bbcf61 | ||
|
|
0125ce5916 | ||
|
|
2b26eaf410 | ||
|
|
33676fc4dc | ||
|
|
7978c48d5c | ||
|
|
1d37e010c8 | ||
|
|
54b9b28c75 | ||
|
|
3a44d74cf3 | ||
|
|
b1475a1aff | ||
|
|
b2749fee92 | ||
|
|
9984b638b5 | ||
|
|
207b60db7e | ||
|
|
7779c33e0c | ||
|
|
575d6cc30f | ||
|
|
5bbe64bb86 | ||
|
|
83a49165c4 | ||
|
|
d84139a914 | ||
|
|
ca4fe422f2 | ||
|
|
1ad1961396 | ||
|
|
d253c4bd7f | ||
|
|
fbee6e8bd4 | ||
|
|
7c36f5cb0c | ||
|
|
7dff3e6f8b | ||
|
|
2ac507144b | ||
|
|
82875cde68 | ||
|
|
d40b3b7d80 | ||
|
|
181561fe2a | ||
|
|
7b23e61e7d | ||
|
|
6a39905b43 | ||
|
|
2780d98ee0 | ||
|
|
51d2c2279f | ||
|
|
c347b6c944 | ||
|
|
e83618b1d6 | ||
|
|
90f57d61b9 | ||
|
|
0b9af6bb26 | ||
|
|
11a65d4fbc | ||
|
|
054262365e | ||
|
|
058c8f4228 | ||
|
|
d78d851858 | ||
|
|
db72bc09b4 | ||
|
|
5fb412572f | ||
|
|
400ac00d23 | ||
|
|
80a4b1d177 | ||
|
|
96141d4990 | ||
|
|
b054c17d14 | ||
|
|
3cf9be89fd | ||
|
|
393800aaa2 | ||
|
|
5772711192 | ||
|
|
15f0857859 | ||
|
|
8ba9826cd9 | ||
|
|
07dbf78010 | ||
|
|
97502559b3 | ||
|
|
2b0eb6506a | ||
|
|
012762e102 | ||
|
|
8691824db1 | ||
|
|
ad97550583 | ||
|
|
1ed3220733 | ||
|
|
c906dd40fb | ||
|
|
eb30105fc2 | ||
|
|
90cd078973 | ||
|
|
1cdfebf8db | ||
|
|
0cefcbd02b | ||
|
|
08cfbbc2ea | ||
|
|
e1f523cf2a | ||
|
|
c223892a57 | ||
|
|
9ba28fe7fa | ||
|
|
0d7ab54160 | ||
|
|
6e5d0c1e8c | ||
|
|
a79900d842 | ||
|
|
6d8e647f12 | ||
|
|
1949d07721 | ||
|
|
f779520545 | ||
|
|
df7c447e9c | ||
|
|
90b9d70941 | ||
|
|
da806d38c6 | ||
|
|
8076412bfc | ||
|
|
fa67d077b1 | ||
|
|
b2afe78c07 | ||
|
|
1dd5863431 | ||
|
|
0a341f6673 | ||
|
|
bb81f8e583 | ||
|
|
a45ebfc1f5 | ||
|
|
ea2c84d1bd | ||
|
|
5e21cbcca6 | ||
|
|
9d5049dde0 | ||
|
|
fd2d7e5c7a | ||
|
|
2c1b2924d4 | ||
|
|
490f3f3f65 | ||
|
|
843e40ef45 | ||
|
|
d8c60d9d47 | ||
|
|
b97f018e81 | ||
|
|
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 | ||
|
|
b801751bda | ||
|
|
ee67b3be28 | ||
|
|
6442667961 | ||
|
|
68574d4327 |
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
50
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Report an issue with the bar not working as expected
|
||||
title: ''
|
||||
labels: bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
> A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
> A clear and concise description of what you expected to happen.
|
||||
|
||||
**System information:**
|
||||
- Distro: [e.g. Arch Linux, Ubuntu 22.10]
|
||||
- Compositor: [e.g. Sway]
|
||||
- Ironbar version: [e.g. 0.8.0]
|
||||
|
||||
**Configuration**
|
||||
> Share your bar configuration and stylesheet as applicable:
|
||||
|
||||
<details><summary>Config</summary>
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details><summary>Styles</summary>
|
||||
|
||||
```css
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
**Additional context**
|
||||
> Add any other context about the problem here.
|
||||
|
||||
**Screenshots**
|
||||
> If applicable, add screenshots to help explain your problem.
|
||||
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
22
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: enhancement
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
> A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
|
||||
**Describe the solution you'd like**
|
||||
> A clear and concise description of what you want to happen.
|
||||
> The more info here about what you are trying to achieve, the better - there's likely more than one way to go about implementing a solution.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
> A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
> Add any other context or screenshots about the feature request here.
|
||||
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Other
|
||||
about: Any other issue type
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
|
||||
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"
|
||||
48
.github/workflows/build.yml
vendored
48
.github/workflows/build.yml
vendored
@@ -1,6 +1,7 @@
|
||||
name: Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
pull_request:
|
||||
@@ -22,16 +23,51 @@ jobs:
|
||||
override: true
|
||||
|
||||
- name: Install build deps
|
||||
run: sudo apt install libgtk-3-dev libgtk-layer-shell-dev
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libgtk-3-dev libgtk-layer-shell-dev
|
||||
|
||||
- name: Build
|
||||
run: cargo build --verbose
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Clippy
|
||||
- name: Clippy (base features)
|
||||
uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
args: --no-default-features --features config+json
|
||||
|
||||
- name: Clippy (all features)
|
||||
uses: actions-rs/clippy-check@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
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@v20
|
||||
with:
|
||||
install_url: https://nixos.org/nix/install
|
||||
extra_nix_config: |
|
||||
auto-optimise-store = true
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- uses: cachix/cachix-action@v12
|
||||
with:
|
||||
name: jakestanger
|
||||
signingKey: '${{ secrets.CACHIX_SIGNING_KEY }}'
|
||||
|
||||
- run: nix build --print-build-logs
|
||||
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -18,7 +18,9 @@ jobs:
|
||||
override: true
|
||||
|
||||
- name: Install build deps
|
||||
run: sudo apt install libgtk-3-dev libgtk-layer-shell-dev
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install libgtk-3-dev libgtk-layer-shell-dev
|
||||
|
||||
- name: Update CHANGELOG
|
||||
id: changelog
|
||||
@@ -26,6 +28,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@v20
|
||||
with:
|
||||
extra_nix_config: |
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update flake.lock
|
||||
uses: DeterminateSystems/update-flake-lock@v15
|
||||
with:
|
||||
pr-title: "Update flake.lock" # Title of PR to be created
|
||||
pr-labels: | # Labels to be set on the PR
|
||||
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="" />
|
||||
|
||||
245
CHANGELOG.md
245
CHANGELOG.md
@@ -4,6 +4,241 @@ All notable changes to this project will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [v0.11.0] - 2023-04-01
|
||||
### :boom: BREAKING CHANGES
|
||||
- due to [`ca4fe42`](https://github.com/JakeStanger/ironbar/commit/ca4fe422f22866748f2cb6239b31170a974d254b) - ability to set fixed length *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
This changes the behaviour of `truncate.length`. A new property, `truncate.max_length`, has been introduced that uses the old behaviour.
|
||||
|
||||
|
||||
### :sparkles: New Features
|
||||
- [`d253c4b`](https://github.com/JakeStanger/ironbar/commit/d253c4bd7f306c7b8fef223d1beb7b1f6e77629b) - add configurable margins around bar *(commit by [@ttoino](https://github.com/ttoino))*
|
||||
- [`ca4fe42`](https://github.com/JakeStanger/ironbar/commit/ca4fe422f22866748f2cb6239b31170a974d254b) - **truncate**: ability to set fixed length *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`575d6cc`](https://github.com/JakeStanger/ironbar/commit/575d6cc30f9e28079aed8425566048abd3d9e022) - new clipboard manager module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`9984b63`](https://github.com/JakeStanger/ironbar/commit/9984b638b55adea11ba90412346fbb8220f05682) - **nix**: initial nix feature flags impl *(commit by [@yavko](https://github.com/yavko))*
|
||||
- [`b1475a1`](https://github.com/JakeStanger/ironbar/commit/b1475a1affd2f101f1f707ab1a0e8e5509a1d99f) - **nix**: use cargo default features *(commit by [@yavko](https://github.com/yavko))*
|
||||
- [`102d247`](https://github.com/JakeStanger/ironbar/commit/102d2478a9d0ecc8be12c5ea6019a5a5411cc6ab) - module hover options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :bug: Bug Fixes
|
||||
- [`2ac5071`](https://github.com/JakeStanger/ironbar/commit/2ac507144b42a80507f8d2df214889c114c069df) - not setting layer shell namespace *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`7dff3e6`](https://github.com/JakeStanger/ironbar/commit/7dff3e6f8b989132ff0c4406caa72f063dd57c9f) - **image**: widgets missing names *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`54b9b28`](https://github.com/JakeStanger/ironbar/commit/54b9b28c75b2fe300e2bad1436d315da1950953e) - make readme more concise *(commit by [@yavko](https://github.com/yavko))*
|
||||
- [`8cbb73b`](https://github.com/JakeStanger/ironbar/commit/8cbb73b75e7aca1aa163406f4583273e6ff4bac2) - **dynamic string**: dynamic sections not respecting ordering *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`d0b7bdb`](https://github.com/JakeStanger/ironbar/commit/d0b7bdbafcc34967dd5b048ea12e6267ba293566) - **nix**: home manager module, and features *(commit by [@yavko](https://github.com/yavko))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`d84139a`](https://github.com/JakeStanger/ironbar/commit/d84139a914f9b35054dc6048715e1ed7e79d7441) - general tidy up *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`7212bbc`](https://github.com/JakeStanger/ironbar/commit/7212bbcf61e097b35a7ab341e19e9daefd2edf95) - **dynamic string**: use vec instead of indexmap *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`ecdd71a`](https://github.com/JakeStanger/ironbar/commit/ecdd71a43d267161f84e3c4a3c22e9454c0f7184) - **config**: use `universal-config` crate. *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`6221f74`](https://github.com/JakeStanger/ironbar/commit/6221f7454a2da2ec8a5a7f84e6fd35a8dc1a1548) - fix new clippy warnings *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`82875cd`](https://github.com/JakeStanger/ironbar/commit/82875cde687628f3ee3436343068825440128599) - update CHANGELOG.md for v0.10.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`7c36f5c`](https://github.com/JakeStanger/ironbar/commit/7c36f5cb0cf03191c9b03e2455b63829a64e402e) - fix a couple of issues *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`83a4916`](https://github.com/JakeStanger/ironbar/commit/83a49165c42fa793ef1224f93cbc147bc69de894) - **compiling**: add info about build deps *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`5bbe64b`](https://github.com/JakeStanger/ironbar/commit/5bbe64bb86fb2db0921e284a1560db2f6c1a1920) - **clock**: format table *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`2b26eaf`](https://github.com/JakeStanger/ironbar/commit/2b26eaf41036609be4dfc57689ca8d770dcb6b9b) - **clipboard**: fix incorrect setting description *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`0125ce5`](https://github.com/JakeStanger/ironbar/commit/0125ce5916c003d1ea9a141fe5a0f6a54b2778ab) - **examples**: update styles example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.10.0] - 2023-02-01
|
||||
### :boom: BREAKING CHANGES
|
||||
- due to [`3cf9be8`](https://github.com/JakeStanger/ironbar/commit/3cf9be89fd74face31806165f66b68052b093bab) - global icon theme setting *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
This removes the `icon_theme` option from `launcher` and `focused`. You will need to set this at the top of your config instead.
|
||||
|
||||
- due to [`90f57d6`](https://github.com/JakeStanger/ironbar/commit/90f57d61b94c50c98a6f55de18c6edf3d18aa3fa) - remove irrelevant `icon` format token *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
(Missed from #96141d4) The `{icon}` token has been removed from the `music` module due to incompatibility with the new image/icon support. The icon now always displays as a separate widget before the label and should be removed from your formatting string.
|
||||
|
||||
|
||||
### :sparkles: New Features
|
||||
- [`8691824`](https://github.com/JakeStanger/ironbar/commit/8691824db1a12c3f3589ff8b5315b8dba5cb8aec) - **music**: ability to truncate button text *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`07dbf78`](https://github.com/JakeStanger/ironbar/commit/07dbf780105027b533b0bb34c9ae3e4e96f29f4a) - **focused**: ability to truncate label text *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`393800a`](https://github.com/JakeStanger/ironbar/commit/393800aaa2093b9257c43fde8bdb8399f26ebc74) - **custom**: image widget *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`3cf9be8`](https://github.com/JakeStanger/ironbar/commit/3cf9be89fd74face31806165f66b68052b093bab) - global icon theme setting *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`b054c17`](https://github.com/JakeStanger/ironbar/commit/b054c17d14628c9188bfa9aed506ea1de3051f9c) - **workspaces**: support for using images in `name_map` *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`96141d4`](https://github.com/JakeStanger/ironbar/commit/96141d49907412ea26d23ef30c10ade8b32b89b9) - **music**: support for using images in `name_map`, additional icon options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`c347b6c`](https://github.com/JakeStanger/ironbar/commit/c347b6c9449ce4e16e2e133d7dd35544ab9a533c) - add feature flags *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :bug: Bug Fixes
|
||||
- [`5772711`](https://github.com/JakeStanger/ironbar/commit/57727111923a419f9b7613103283aa4cf6bd082c) - **music**: remote mpris album art not showing *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`5fb4125`](https://github.com/JakeStanger/ironbar/commit/5fb412572f3da60ac482a1960d891f70bc29287b) - **tray**: some init issues *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`058c8f4`](https://github.com/JakeStanger/ironbar/commit/058c8f4228f9f7faa66cda9dd1636ea32e9de68b) - **hyprland**: issues with tracking workspaces *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`51d2c22`](https://github.com/JakeStanger/ironbar/commit/51d2c2279f50add992def0d58cfaa9890ea3d041) - **images**: incorrectly resolving non-files *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`012762e`](https://github.com/JakeStanger/ironbar/commit/012762e10203fb2d58160acdae4dc7ca7689b131) - swap out some code for existing macros *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`9750255`](https://github.com/JakeStanger/ironbar/commit/97502559b30c51e77c1dd9a7d794a88756294c83) - **music**: split config code into separate file *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`15f0857`](https://github.com/JakeStanger/ironbar/commit/15f0857859d5d4a590b60b6b1a4347b4b84a58a1) - replace icon loading with improved general image loading *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`1ed3220`](https://github.com/JakeStanger/ironbar/commit/1ed3220733c2dcb7c5e5cbf377b3324d3183609e) - update CHANGELOG.md for v0.9.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`90f57d6`](https://github.com/JakeStanger/ironbar/commit/90f57d61b94c50c98a6f55de18c6edf3d18aa3fa) - **music**: remove irrelevant `icon` format token *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`6a39905`](https://github.com/JakeStanger/ironbar/commit/6a39905b4333582fbcda81a66a9b91055333d698) - **compiling**: add missing full stop *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`7b23e61`](https://github.com/JakeStanger/ironbar/commit/7b23e61e7dedf2736a30580b6c1aa84e002c462c) - **wiki**: update screenshots and examples *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.9.0] - 2023-01-28
|
||||
### :boom: BREAKING CHANGES
|
||||
- due to [`fa67d07`](https://github.com/JakeStanger/ironbar/commit/fa67d077b136b109edf6dbaa11a33aebf3e044b4) - mouse event config options *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
`on_click` is now called `on_click_left` for consistency with new options.
|
||||
|
||||
- due to [`6d8e647`](https://github.com/JakeStanger/ironbar/commit/6d8e647f123e54ba389c5ab2fe908200aa5e4cf6) - mpris support *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
The `mpd` module has been renamed to `music`. You will need to update the `type` value in your config and add `player_type` to continue using MPD. You will also need to update your styles.
|
||||
|
||||
|
||||
### :sparkles: New Features
|
||||
- [`1dd5863`](https://github.com/JakeStanger/ironbar/commit/1dd586343143bfd501a44c6556719fac9d582d6b) - better surface some config error messages *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`fa67d07`](https://github.com/JakeStanger/ironbar/commit/fa67d077b136b109edf6dbaa11a33aebf3e044b4) - mouse event config options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`6d8e647`](https://github.com/JakeStanger/ironbar/commit/6d8e647f123e54ba389c5ab2fe908200aa5e4cf6) - mpris support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`6e5d0c1`](https://github.com/JakeStanger/ironbar/commit/6e5d0c1e8c0b5d7e330608fc835e1e9733f156de) - **workspaces**: hyprland support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`9ba28fe`](https://github.com/JakeStanger/ironbar/commit/9ba28fe7faf84e06febc2ffea089442f8f5b90a2) - **workspaces**: better ordering *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :bug: Bug Fixes
|
||||
- [`e1f523c`](https://github.com/JakeStanger/ironbar/commit/e1f523cf2a15b74a5c570dd7440db4c1b476d782) - **music**: popup artist label using wrong name *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`08cfbbc`](https://github.com/JakeStanger/ironbar/commit/08cfbbc2eaf6e74780dd7196efcc15ea6d2e7d12) - **music**: unable to go to prev with mpris *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`0cefcbd`](https://github.com/JakeStanger/ironbar/commit/0cefcbd02b0af518352e35060644f281da249d3e) - **music**: wrong widget name on vol slider *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`90cd078`](https://github.com/JakeStanger/ironbar/commit/90cd078973b23b2291cf156e46729842f33c1806) - **mpd**: stops working if connection lost *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`2c1b292`](https://github.com/JakeStanger/ironbar/commit/2c1b2924d4a103183d3974ac066623a80277a79a) - move most of the horrible `add_module` macro content into proper functions *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`fd2d7e5`](https://github.com/JakeStanger/ironbar/commit/fd2d7e5c7ab8de50c4621b19d07d8b012a451564) - move startup logging code to logging module *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`9d5049d`](https://github.com/JakeStanger/ironbar/commit/9d5049dde01cdb76f4772f8ce8f61a8b5bad3a50) - standardise error messages *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`5e21cbc`](https://github.com/JakeStanger/ironbar/commit/5e21cbcca6cc30d725acdea0f6561cfd6acdcc3c) - macros to reduce repeated code *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`ea2c84d`](https://github.com/JakeStanger/ironbar/commit/ea2c84d1bd15798e32496397c4a6aa42fab39d95) - general code tidy-up *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`0d7ab54`](https://github.com/JakeStanger/ironbar/commit/0d7ab541604691455ed39c73e039ac0635307bc8) - remove redundant clone *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`b97f018`](https://github.com/JakeStanger/ironbar/commit/b97f018e81aa55a871a12aa3e1e4b07b1f8eb50f) - update CHANGELOG.md for v0.8.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`c223892`](https://github.com/JakeStanger/ironbar/commit/c223892a57b29ae56431fc585b8cec503f3206c7) - **workspaces**: update for hyprland/new ordering option *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.8.0] - 2022-11-30
|
||||
### :boom: BREAKING CHANGES
|
||||
- due to [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
This renames the module from `sys-info` to `sys_info`, and almost every formatting token from `kebab-case` to `snake_case`. Any use of this module will need to be updated.
|
||||
|
||||
- due to [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
This changes the option in the `script` module. Any uses of the module must be updated to use the new option name.
|
||||
|
||||
- due to [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*:
|
||||
|
||||
This changes the option on buttons in the `custom` module. Any uses of the module must be updated to use the new custom widget attribute name.
|
||||
|
||||
|
||||
### :sparkles: New Features
|
||||
- [`73158c2`](https://github.com/JakeStanger/ironbar/commit/73158c2fce2880347b88d58541dea000534996c8) - **script**: new watch mode *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`a3f90ad`](https://github.com/JakeStanger/ironbar/commit/a3f90adaf19aebed7020eeb44b91250af080d313) - add nix flake support *(commit by [@yavko](https://github.com/yavko))*
|
||||
- [`c9e66d4`](https://github.com/JakeStanger/ironbar/commit/c9e66d4664137c50aba4aecdc3a3ba43d3da11fe) - common module options (`show_if`, `on_click`, `tooltip`) *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`5d153a0`](https://github.com/JakeStanger/ironbar/commit/5d153a02fc9b113bb77a04596b806edd182fc5d3) - **custom**: ability to embed scripts in labels for dynamic content *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`d20972c`](https://github.com/JakeStanger/ironbar/commit/d20972cb32714627d0cca947021453979c76dd03) - dynamic tooltips *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :recycle: Refactors
|
||||
- [`ff17ec1`](https://github.com/JakeStanger/ironbar/commit/ff17ec1996cf344663e84e79d11b08dc84b97635) - various changes based on rust 1.65 clippy *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`4662f60`](https://github.com/JakeStanger/ironbar/commit/4662f60ac54165be6fb7aea12c245309db0fe5d6) - move various clients to own folder *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`0fb5fa8`](https://github.com/JakeStanger/ironbar/commit/0fb5fa8c2a166c3d46b006ceb0d53af076824ff4) - use latest `libcorn` with serde support *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`df77020`](https://github.com/JakeStanger/ironbar/commit/df77020c5277ae9e379bb4fd67c221be5cb20426) - **sys_info**: use snake_case for module tokens for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`8c75bc4`](https://github.com/JakeStanger/ironbar/commit/8c75bc46ac2885a748d31df9261d988cc797e916) - **script**: rename `path` to `cmd` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`e274ba3`](https://github.com/JakeStanger/ironbar/commit/e274ba39cd6d8f1c73033ac1e60e5bce89205ce2) - **custom**: rename `exec` to `on_click` for consistency *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`64f5404`](https://github.com/JakeStanger/ironbar/commit/64f54040ef626157af6b6a9ce5258507a10a23fb) - move dynamic_label.rs to dynamic_string.rs and fix failing test *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :white_check_mark: Tests
|
||||
- [`907a565`](https://github.com/JakeStanger/ironbar/commit/907a565f3d418a276dfb454e1189ddede1814291) - **dynamic label**: do not run if cannot initialise gtk *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :memo: Documentation Changes
|
||||
- [`1c032ae`](https://github.com/JakeStanger/ironbar/commit/1c032ae8e3a38b82c286bab7d102842f14b708e1) - update CHANGELOG.md for v0.7.0 [skip ci] *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`58d55db`](https://github.com/JakeStanger/ironbar/commit/58d55db6600fe2f9b23ae8ec6a50a686d2acaf65) - migrate wiki into main repo *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`c480296`](https://github.com/JakeStanger/ironbar/commit/c48029664d5f58bf73faa2931f34b38b8b184d25) - **script**: improve doc comment *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`8c77410`](https://github.com/JakeStanger/ironbar/commit/8c774100f1c8ea051284c6950339a2c8ed59a52a) - **script**: add information on new mode options *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`c4cdf4b`](https://github.com/JakeStanger/ironbar/commit/c4cdf4be8ba83f3669158a1552eab4a840085204) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`ec69649`](https://github.com/JakeStanger/ironbar/commit/ec69649a04f6199953836e51c2efe1fe2a19e320) - update example configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`1320639`](https://github.com/JakeStanger/ironbar/commit/1320639d4e6b7c8cd8f861b26b2b854504775ef0) - add custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`afedf02`](https://github.com/JakeStanger/ironbar/commit/afedf0214d3a71f6283c70bd3a110d24f68d2fdf) - add link to new custom power menu example *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.7.0] - 2022-11-05
|
||||
### :sparkles: New Features
|
||||
- [`fad90fd`](https://github.com/JakeStanger/ironbar/commit/fad90fdad683a612497ac7822a66a90f43fce0a2) - **sys-info**: add loads more formatting tokens *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`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))*
|
||||
- [`a35d255`](https://github.com/JakeStanger/ironbar/commit/a35d25520cd3fd235cdc77ec6209d88499ca3639) - **launcher**: item state changes not handled correctly *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
### :wrench: Chores
|
||||
- [`481adfc`](https://github.com/JakeStanger/ironbar/commit/481adfcaa41c0d3a1ba7d61edb68db49d959c78f) - **intellij**: update run configs *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
- [`6871126`](https://github.com/JakeStanger/ironbar/commit/6871126bd8def89ccbf2934180d615e781ec32c7) - **release**: v0.5.1 *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
|
||||
|
||||
## [v0.5.0] - 2022-08-25
|
||||
### :sparkles: New Features
|
||||
- [`1e38719`](https://github.com/JakeStanger/ironbar/commit/1e387199962b81caeb40ffbd99a956f24abdf4e3) - introduce logging in some areas *(commit by [@JakeStanger](https://github.com/JakeStanger))*
|
||||
@@ -28,4 +263,12 @@ 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.0]: https://github.com/JakeStanger/ironbar/compare/v0.4.0...v0.5.0
|
||||
[v0.5.1]: https://github.com/JakeStanger/ironbar/compare/v0.5.0...v0.5.1
|
||||
[v0.5.2]: https://github.com/JakeStanger/ironbar/compare/v0.5.1...v0.5.2
|
||||
[v0.6.0]: https://github.com/JakeStanger/ironbar/compare/v0.5.2...v0.6.0
|
||||
[v0.7.0]: https://github.com/JakeStanger/ironbar/compare/v0.6.0...v0.7.0
|
||||
[v0.8.0]: https://github.com/JakeStanger/ironbar/compare/v0.7.0...v0.8.0
|
||||
[v0.9.0]: https://github.com/JakeStanger/ironbar/compare/v0.8.0...v0.9.0
|
||||
[v0.10.0]: https://github.com/JakeStanger/ironbar/compare/v0.9.0...v0.10.0
|
||||
[v0.11.0]: https://github.com/JakeStanger/ironbar/compare/v0.10.0...v0.11.0
|
||||
@@ -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`.
|
||||
|
||||
2888
Cargo.lock
generated
2888
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
115
Cargo.toml
115
Cargo.toml
@@ -1,38 +1,103 @@
|
||||
[package]
|
||||
name = "ironbar"
|
||||
version = "0.5.1"
|
||||
version = "0.11.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "Customisable wlroots/sway bar"
|
||||
description = "Customisable GTK Layer Shell wlroots/sway bar"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
[features]
|
||||
default = [
|
||||
"http",
|
||||
"config+all",
|
||||
"clipboard",
|
||||
"clock",
|
||||
"music+all",
|
||||
"sys_info",
|
||||
"tray",
|
||||
"upower",
|
||||
"workspaces+all"
|
||||
]
|
||||
http = ["dep:reqwest"]
|
||||
upower = ["upower_dbus", "zbus", "futures-lite"]
|
||||
|
||||
"config+all" = ["config+json", "config+yaml", "config+toml", "config+corn"]
|
||||
"config+json" = ["universal-config/json"]
|
||||
"config+yaml" = ["universal-config/yaml"]
|
||||
"config+toml" = ["universal-config/toml"]
|
||||
"config+corn" = ["universal-config/corn"]
|
||||
|
||||
clipboard = ["nix"]
|
||||
|
||||
clock = ["chrono"]
|
||||
|
||||
music = ["regex"]
|
||||
"music+all" = ["music", "music+mpris", "music+mpd"]
|
||||
"music+mpris" = ["music", "mpris"]
|
||||
"music+mpd" = ["music", "mpd_client"]
|
||||
|
||||
sys_info = ["sysinfo", "regex"]
|
||||
|
||||
tray = ["stray"]
|
||||
|
||||
workspaces = ["futures-util"]
|
||||
"workspaces+all" = ["workspaces", "workspaces+sway", "workspaces+hyprland"]
|
||||
"workspaces+sway" = ["workspaces", "swayipc-async"]
|
||||
"workspaces+hyprland" = ["workspaces", "hyprland"]
|
||||
|
||||
[dependencies]
|
||||
gtk = "0.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"] }
|
||||
# core
|
||||
gtk = { package = "gtk4", version = "0.6.6" }
|
||||
gtk-layer-shell = { package = "gtk4-layer-shell", version = "0.0.3" }
|
||||
glib = "0.17.9"
|
||||
tokio = { version = "1.21.2", features = ["macros", "rt-multi-thread", "time", "process", "sync", "io-util", "net"] }
|
||||
tracing = "0.1.37"
|
||||
tracing-subscriber = { version = "0.3.16", features = ["env-filter"] }
|
||||
tracing-error = "0.2.0"
|
||||
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.2.0"
|
||||
lazy_static = "1.4.0"
|
||||
regex = "1.6.0"
|
||||
stray = "0.1.1"
|
||||
dirs = "4.0.0"
|
||||
indexmap = "1.9.1"
|
||||
dirs = "5.0.0"
|
||||
walkdir = "2.3.2"
|
||||
notify = "4.0.17"
|
||||
mpd_client = "0.7.5"
|
||||
ksway = "0.1.0"
|
||||
sysinfo = "0.25.1"
|
||||
# required for wrapping ksway
|
||||
crossbeam-channel = "0.3.9"
|
||||
notify = { version = "5.0.0", default-features = false }
|
||||
wayland-client = "0.29.5"
|
||||
wayland-protocols = { version = "0.29.5", features = ["unstable_protocols", "client"] }
|
||||
smithay-client-toolkit = { version = "0.16.0", default-features = false, features = ["calloop"] }
|
||||
universal-config = { version = "0.3.0", default_features = false }
|
||||
|
||||
lazy_static = "1.4.0"
|
||||
async_once = "0.2.6"
|
||||
cfg-if = "1.0.0"
|
||||
|
||||
# http
|
||||
reqwest = { version = "0.11.14", optional = true }
|
||||
|
||||
# clipboard
|
||||
nix = { version = "0.26.2", optional = true }
|
||||
|
||||
# clock
|
||||
chrono = { version = "0.4.19", optional = true }
|
||||
|
||||
# music
|
||||
mpd_client = { version = "1.0.0", optional = true }
|
||||
mpris = { version = "2.0.0", optional = true }
|
||||
|
||||
# sys_info
|
||||
sysinfo = { version = "0.28.4", optional = true }
|
||||
|
||||
# tray
|
||||
stray = { version = "0.1.3", optional = true }
|
||||
|
||||
# upower
|
||||
upower_dbus = { version = "0.3.2", optional = true }
|
||||
futures-lite = { version = "1.12.0", optional = true }
|
||||
zbus = { version = "3.11.0", optional = true }
|
||||
|
||||
# workspaces
|
||||
swayipc-async = { version = "2.0.1", optional = true }
|
||||
hyprland = { version = "0.3.1", optional = true }
|
||||
futures-util = { version = "0.3.21", optional = true }
|
||||
|
||||
# shared
|
||||
regex = { version = "1.6.0", default-features = false, features = ["std"], optional = true } # music, sys_info
|
||||
|
||||
101
README.md
101
README.md
@@ -1,19 +1,28 @@
|
||||
# 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).
|
||||
|
||||

|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- First-class support for Sway and Hyprland, but should (mostly) work on any wlroots compositor.
|
||||
- Fully themeable with CSS and hot-loaded styles.
|
||||
- Support for multiple configuration languages.
|
||||
- Popups used by widgets to show rich content and controls on click.
|
||||
- Out of the box widgets which can be used to create anything from a lightweight to a more traditional desktop experience.
|
||||
- Ability to create custom widgets (including popups), run scripts and inject dynamic content.
|
||||
|
||||
## Installation
|
||||
|
||||
Run using `ironbar`.
|
||||
|
||||
### Cargo
|
||||
|
||||
Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed.
|
||||
|
||||
```sh
|
||||
cargo install ironbar
|
||||
```
|
||||
@@ -26,8 +35,60 @@ cargo install ironbar
|
||||
yay -S ironbar-git
|
||||
```
|
||||
|
||||
[aur package](https://aur.archlinux.org/packages/ironbar-git)
|
||||
|
||||
### Nix Flake
|
||||
|
||||
A flake is included with the repo which can be used with home-manager.
|
||||
|
||||
#### Example
|
||||
|
||||
Here is an example nix flake that uses Ironbar.
|
||||
|
||||
```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 = "";
|
||||
package = inputs.ironbar;
|
||||
features = ["feature" "another_feature"];
|
||||
};
|
||||
}
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Binary Caching
|
||||
|
||||
There is a Cachix cache available at `https://app.cachix.org/cache/jakestanger`
|
||||
in case you don't want to compile Ironbar.
|
||||
|
||||
### Source
|
||||
|
||||
Ensure you have the [build dependencies](https://github.com/JakeStanger/ironbar/wiki/compiling#Build-requirements) installed.
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jakestanger/ironbar.git
|
||||
cd ironbar
|
||||
@@ -36,33 +97,46 @@ cargo build --release
|
||||
install target/release/ironbar ~/.local/bin/ironbar
|
||||
```
|
||||
|
||||
[aur package](https://aur.archlinux.org/packages/ironbar-git)
|
||||
By default, all features are enabled.
|
||||
See [here](https://github.com/JakeStanger/ironbar/wiki/compiling#features) for controlling which features are included.
|
||||
|
||||
[repo](https://github.com/jakestanger/ironbar)
|
||||
|
||||
## Running
|
||||
|
||||
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.
|
||||
@@ -75,3 +149,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
|
||||
|
||||
74
docs/Compiling.md
Normal file
74
docs/Compiling.md
Normal file
@@ -0,0 +1,74 @@
|
||||
You can compile Ironbar from source using `cargo`.
|
||||
Just clone the repo and build:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/jakestanger/ironbar.git
|
||||
cd ironbar
|
||||
cargo build --release
|
||||
# change path to wherever you want to install
|
||||
install target/release/ironbar ~/.local/bin/ironbar
|
||||
```
|
||||
|
||||
## Build requirements
|
||||
|
||||
To build from source, you must have GTK (>= 3.22) and GTK Layer Shell installed.
|
||||
|
||||
### Arch
|
||||
|
||||
```shell
|
||||
pacman -S gtk3 gtk-layer-shell
|
||||
```
|
||||
|
||||
### Ubuntu/Debian
|
||||
|
||||
```shell
|
||||
apt install libgtk-3-dev libgtk-layer-shell-dev
|
||||
```
|
||||
|
||||
### Fedora
|
||||
|
||||
```shell
|
||||
dnf install gtk3 gtk-layer-shell
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
By default, all features are enabled for convenience. This can result in a significant compile time.
|
||||
If you know you are not going to need all the features, you can compile with only the features you need.
|
||||
|
||||
As of `v0.10.0`, compiling with no features is about 33% faster.
|
||||
On a 3800X, it takes about 60 seconds for no features and 90 seconds for all.
|
||||
This difference is expected to increase as the bar develops.
|
||||
|
||||
Features containing a `+` can be stacked, for example `config+json` and `config+yaml` could both be enabled.
|
||||
|
||||
To build using only specific features, disable default features and pass a comma separated list to `cargo build`:
|
||||
|
||||
```shell
|
||||
cargo build --release --no-default-features \
|
||||
--features http,config+json,clock
|
||||
```
|
||||
|
||||
> ⚠ Make sure you enable at least one `config` feature otherwise you will not be able to start the bar!
|
||||
|
||||
| Feature | Description |
|
||||
|---------------------|-----------------------------------------------------------------------------------|
|
||||
| **Core** | |
|
||||
| http | Enables HTTP features. Currently this includes the ability to load remote images. |
|
||||
| config+all | Enables support for all configuration languages. |
|
||||
| config+json | Enables configuration support for JSON. |
|
||||
| config+yaml | Enables configuration support for YAML. |
|
||||
| config+toml | Enables configuration support for TOML. |
|
||||
| config+corn | Enables configuration support for [Corn](https://github.com/jakestanger.corn). |
|
||||
| **Modules** | |
|
||||
| clipboard | Enables the `clipboard` module. |
|
||||
| clock | Enables the `clock` module. |
|
||||
| music+all | Enables the `music` module with support for all player types. |
|
||||
| music+mpris | Enables the `music` module with MPRIS support. |
|
||||
| music+mpd | Enables the `music` module with MPD support. |
|
||||
| sys_info | Enables the `sys_info` module. |
|
||||
| tray | Enables the `tray` module. |
|
||||
| workspaces+all | Enables the `workspaces` module with support for all compositors. |
|
||||
| workspaces+sway | Enables the `workspaces` module with support for Sway. |
|
||||
| workspaces+hyprland | Enables the `workspaces` module with support for Hyprland. |
|
||||
|
||||
318
docs/Configuration guide.md
Normal file
318
docs/Configuration guide.md
Normal file
@@ -0,0 +1,318 @@
|
||||
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>
|
||||
|
||||
```corn
|
||||
{
|
||||
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. |
|
||||
| `popup_gap` | `integer` | `5` | The gap between the bar and popup window. |
|
||||
| `margin.top` | `integer` | `0` | The margin on the top of the bar |
|
||||
| `margin.bottom` | `integer` | `0` | The margin on the bottom of the bar |
|
||||
| `margin.left` | `integer` | `0` | The margin on the left of the bar |
|
||||
| `margin.right` | `integer` | `0` | The margin on the right of the bar |
|
||||
| `icon_theme` | `string` | `null` | Name of the GTK icon theme to use. Leave blank to use default. |
|
||||
| `start` | `Module[]` | `[]` | Array of left or top modules. |
|
||||
| `center` | `Module[]` | `[]` | Array of center modules. |
|
||||
| `end` | `Module[]` | `[]` | Array of right or bottom modules. |
|
||||
|
||||
### 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).
|
||||
|
||||
#### Events
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `on_click_left` | `Script [oneshot]` | `null` | Runs the script when the module is left clicked. |
|
||||
| `on_click_middle` | `Script [oneshot]` | `null` | Runs the script when the module is middle clicked. |
|
||||
| `on_click_right` | `Script [oneshot]` | `null` | Runs the script when the module is right clicked. |
|
||||
| `on_scroll_up` | `Script [oneshot]` | `null` | Runs the script when the module is scroll up on. |
|
||||
| `on_scroll_down` | `Script [oneshot]` | `null` | Runs the script when the module is scrolled down on. |
|
||||
| `on_mouse_enter` | `Script [oneshot]` | `null` | Runs the script when the module is hovered over. |
|
||||
| `on_mouse_exit` | `Script [oneshot]` | `null` | Runs the script when the module is no longer hovered over. |
|
||||
|
||||
#### Visibility
|
||||
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-----------------------|-------------------------------------------------------|---------------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `show_if` | `Script [polling]` | `null` | Polls the script to check its exit code. If exit code is zero, the module is shown. For other codes, it is hidden. |
|
||||
| `transition_type` | `slide_start` or `slide_end` or `crossfade` or `none` | `slide_start` | The transition animation to use when showing/hiding the widget. |
|
||||
| `transition_duration` | `Integer` | `250` | The length of the transition animation to use when showing/hiding the widget. |
|
||||
|
||||
#### Other
|
||||
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-------------------|--------------------|---------|--------------------------------------------------------------------------------------------------------------------|
|
||||
| `tooltip` | `string` | `null` | Shows this text on hover. Supports embedding scripts between `{{double braces}}`. |
|
||||
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.
|
||||
15
docs/Images.md
Normal file
15
docs/Images.md
Normal file
@@ -0,0 +1,15 @@
|
||||
Ironbar is capable of loading images from multiple sources.
|
||||
In any situation where an option takes text or an icon,
|
||||
you can use a string in any of the following formats, and it will automatically be detected as an image:
|
||||
|
||||
| Source | Example |
|
||||
|-------------------------------|---------------------------------|
|
||||
| GTK icon theme | `icon:firefox` |
|
||||
| Local file | `file:///path/to/file.jpg` |
|
||||
| Remote file (over HTTP/HTTPS) | `https://example.com/image.jpg` |
|
||||
|
||||
Remote images are loaded asynchronously to avoid blocking the UI thread.
|
||||
Be aware this can cause elements to change size upon load if the image is large enough.
|
||||
|
||||
Note that mixing text and images is not supported.
|
||||
Your best option here is to use Nerd Font icons instead.
|
||||
107
docs/Scripts.md
Normal file
107
docs/Scripts.md
Normal file
@@ -0,0 +1,107 @@
|
||||
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`.
|
||||
|
||||
Three types of scripts exist: polling, oneshot 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.
|
||||
- **Oneshot** scripts are a variant of polling scripts.
|
||||
They wait for script to exit, and may do something with the output, but are only fired by user events instead of the interval.
|
||||
Generally options that accept oneshot scripts do not support the other types.
|
||||
- **Watching** scripts start a long-running process. Every time the process writes to `stdout`, the last line is captured
|
||||
and used.
|
||||
|
||||
One should prefer to use watch-mode where possible, as it removes the overhead of regularly spawning processes.
|
||||
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.
|
||||
|
||||
For oneshot scripts, both the mode and interval are ignored.
|
||||
|
||||
### 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 |
|
||||
30
docs/_Sidebar.md
Normal file
30
docs/_Sidebar.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Guides
|
||||
|
||||
- [Compiling from source](compiling)
|
||||
- [Configuration guide](configuration-guide)
|
||||
- [Scripts](scripts)
|
||||
- [Images](images)
|
||||
- [Styling guide](styling-guide)
|
||||
|
||||
# Examples
|
||||
|
||||
- [Config](config)
|
||||
- [Stylesheet](https://github.com/JakeStanger/ironbar/blob/master/examples/style.css)
|
||||
|
||||
## Custom
|
||||
|
||||
- [Power Menu](power-menu)
|
||||
|
||||
# Modules
|
||||
|
||||
- [Clipboard](clipboard)
|
||||
- [Clock](clock)
|
||||
- [Custom](custom)
|
||||
- [Focused](focused)
|
||||
- [Label](label)
|
||||
- [Launcher](launcher)
|
||||
- [Music](music)
|
||||
- [Script](script)
|
||||
- [Sys_Info](sys-info)
|
||||
- [Tray](tray)
|
||||
- [Workspaces](workspaces)
|
||||
10
docs/examples/Config.md
Normal file
10
docs/examples/Config.md
Normal file
@@ -0,0 +1,10 @@
|
||||
The configs linked below show a module of each type being used.
|
||||
|
||||
The Corn format makes heavy use of variables
|
||||
to show how module configs can be easily referenced to improve readability
|
||||
and reduce config length when using multiple bars.
|
||||
|
||||
- [JSON](https://github.com/JakeStanger/ironbar/blob/master/examples/config.json)
|
||||
- [TOML](https://github.com/JakeStanger/ironbar/blob/master/examples/config.toml)
|
||||
- [YAML](https://github.com/JakeStanger/ironbar/blob/master/examples/config.yaml)
|
||||
- [Corn](https://github.com/JakeStanger/ironbar/blob/master/examples/config.corn)
|
||||
241
docs/examples/custom/Power-Menu.md
Normal file
241
docs/examples/custom/Power-Menu.md
Normal file
@@ -0,0 +1,241 @@
|
||||
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": [
|
||||
{
|
||||
"label": "",
|
||||
"name": "power-btn",
|
||||
"on_click": "popup:toggle",
|
||||
"type": "button"
|
||||
}
|
||||
],
|
||||
"class": "power-menu",
|
||||
"popup": [
|
||||
{
|
||||
"orientation": "vertical",
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"label": "Power menu",
|
||||
"name": "header",
|
||||
"type": "label"
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"class": "power-btn",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"on_click": "!shutdown now",
|
||||
"type": "button"
|
||||
},
|
||||
{
|
||||
"class": "power-btn",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"on_click": "!reboot",
|
||||
"type": "button"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
|
||||
"name": "uptime",
|
||||
"type": "label"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tooltip": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
|
||||
"type": "custom"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = 'clock'
|
||||
|
||||
[[end]]
|
||||
class = 'power-menu'
|
||||
tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
|
||||
type = 'custom'
|
||||
|
||||
[[end.bar]]
|
||||
label = ''
|
||||
name = 'power-btn'
|
||||
on_click = 'popup:toggle'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup]]
|
||||
orientation = 'vertical'
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = 'Power menu'
|
||||
name = 'header'
|
||||
type = 'label'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
on_click = '!shutdown now'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
on_click = '!reboot'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
|
||||
name = 'uptime'
|
||||
type = 'label'
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: clock
|
||||
|
||||
- bar:
|
||||
- label:
|
||||
name: power-btn
|
||||
on_click: popup:toggle
|
||||
type: button
|
||||
class: power-menu
|
||||
popup:
|
||||
- orientation: vertical
|
||||
type: box
|
||||
widgets:
|
||||
- label: Power menu
|
||||
name: header
|
||||
type: label
|
||||
- type: box
|
||||
widgets:
|
||||
- class: power-btn
|
||||
label: <span font-size='40pt'></span>
|
||||
on_click: '!shutdown now'
|
||||
type: button
|
||||
- class: power-btn
|
||||
label: <span font-size='40pt'></span>
|
||||
on_click: '!reboot'
|
||||
type: button
|
||||
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
|
||||
name: uptime
|
||||
type: label
|
||||
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
|
||||
type: custom
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
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 ]
|
||||
|
||||
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
|
||||
}
|
||||
|
||||
$clock = { type = "clock" }
|
||||
} in {
|
||||
end = [ $power_menu $clock ]
|
||||
}
|
||||
```
|
||||
</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;
|
||||
}
|
||||
```
|
||||
94
docs/modules/Clipboard.md
Normal file
94
docs/modules/Clipboard.md
Normal file
@@ -0,0 +1,94 @@
|
||||
Shows recent clipboard items, allowing you to switch between them to re-copy previous values.
|
||||
Clicking the icon button opens the popup containing all functionality.
|
||||
|
||||
Supports plain text and images.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `clipboard`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|-----------------------|---------------------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `icon` | `string/image` | `` | Icon to show on the widget button. |
|
||||
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
|
||||
| `max_items` | `integer` | `10` | Maximum number of items to show in the popup. |
|
||||
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
|
||||
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
|
||||
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
|
||||
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
|
||||
|
||||
See [here](images) for information on images.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": {
|
||||
"type": "clipboard",
|
||||
"max_items": 3,
|
||||
"truncate": {
|
||||
"mode": "end",
|
||||
"length": 50
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "clipboard"
|
||||
max_items = 3
|
||||
|
||||
[[end.truncate]]
|
||||
mode = "end"
|
||||
length = 50
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: 'clipboard'
|
||||
max_items: 3
|
||||
truncate:
|
||||
mode: 'end'
|
||||
length: 50
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [ {
|
||||
type = "clipboard"
|
||||
max_items = 3
|
||||
truncate.mode = "end"
|
||||
truncate.length = 50
|
||||
} ]
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|--------------------------------------|------------------------------------------------------|
|
||||
| `#clipboard` | Clipboard widget. |
|
||||
| `#clipboard .btn` | Clipboard widget button. |
|
||||
| `#popup-clipboard` | Clipboard popup box. |
|
||||
| `#popup-clipboard .item` | Clipboard row item inside the popup. |
|
||||
| `#popup-clipboard .item .btn` | Clipboard row item radio button. |
|
||||
| `#popup-clipboard .item .btn.text` | Clipboard row item radio button (text values only). |
|
||||
| `#popup-clipboard .item .btn.image` | Clipboard row item radio button (image values only). |
|
||||
| `#popup-clipboard .item .btn-remove` | Clipboard row item remove button. |
|
||||
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. |
|
||||
401
docs/modules/Custom.md
Normal file
401
docs/modules/Custom.md
Normal file
@@ -0,0 +1,401 @@
|
||||
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.
|
||||
|
||||
### `Widget`
|
||||
|
||||
There are many widget types, each with their own config options.
|
||||
You can think of these like HTML elements and their attributes.
|
||||
|
||||
Every widget has the following options available; `type` is mandatory.
|
||||
You can also add common [module-level options](https://github.com/JakeStanger/ironbar/wiki/configuration-guide#32-module-level-options) on a widget.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------|-------------------------------------------------------------------|---------|-------------------------------|
|
||||
| `type` | `box` or `label` or `button` or `image` or `slider` or `progress` | `null` | Type of GTK widget to create. |
|
||||
| `name` | `string` | `null` | Widget name. |
|
||||
| `class` | `string` | `null` | Widget class name. |
|
||||
|
||||
#### Box
|
||||
|
||||
A container to place nested widgets inside.
|
||||
|
||||
> Type: `box`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------------|----------------------------------------------------|--------------|-------------------------------------------------------------------|
|
||||
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Whether child widgets should be horizontally or vertically added. |
|
||||
| `widgets` | `Widget[]` | `[]` | List of widgets to add to this box. |
|
||||
|
||||
#### Label
|
||||
|
||||
A text label. Pango markup and embedded scripts are supported.
|
||||
|
||||
> Type `label`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------|----------|--------------|---------------------------------------------------------------------|
|
||||
| `label` | `string` | `horizontal` | Widget text label. Pango markup and embedded scripts are supported. |
|
||||
|
||||
#### Button
|
||||
|
||||
A clickable button, which can run a command when clicked.
|
||||
|
||||
> Type `button`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|------------|--------------------|--------------|---------------------------------------------------------------------|
|
||||
| `label` | `string` | `horizontal` | Widget text label. Pango markup and embedded scripts are supported. |
|
||||
| `on_click` | `string [command]` | `null` | Command to execute. More on this [below](#commands). |
|
||||
|
||||
#### Image
|
||||
|
||||
An image or icon from disk or http.
|
||||
|
||||
> Type `image`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|--------|-----------|---------|---------------------------------------------------------------------------------------------|
|
||||
| `src` | `image` | `null` | Image source. See [here](images) for information on images. Embedded scripts are supported. |
|
||||
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
|
||||
|
||||
#### Slider
|
||||
|
||||
A draggable slider.
|
||||
|
||||
> Type: `slider`
|
||||
|
||||
Note that `on_change` will provide the **floating point** value as an argument.
|
||||
If your input program requires an integer, you will need to round it.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------------|----------------------------------------------------|--------------|---------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `src` | `image` | `null` | Image source. See [here](images) for information on images. |
|
||||
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
|
||||
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Orientation of the slider. |
|
||||
| `value` | `Script` | `null` | Script to run to get the slider value. Output must be a valid number. |
|
||||
| `on_change` | `string [command]` | `null` | Command to execute when the slider changes. More on this [below](#commands). |
|
||||
| `min` | `float` | `0` | Minimum slider value. |
|
||||
| `max` | `float` | `100` | Maximum slider value. |
|
||||
| `step` | `float` | - | The increment to change when scrolling with the mouse wheel. If left blank, will use the default determined by the environment. |
|
||||
| `length` | `integer` | `null` | Slider length. GTK will automatically size if left unset. |
|
||||
| `show_label` | `boolean` | `true` | Whether to show the value label above the slider. |
|
||||
|
||||
The example slider widget below shows a volume control for MPC,
|
||||
which updates the server when changed, and polls the server for volume changes to keep the slider in sync.
|
||||
|
||||
```corn
|
||||
$slider = {
|
||||
type = "custom"
|
||||
bar = [
|
||||
{
|
||||
type = "slider"
|
||||
length = 100
|
||||
max = 100
|
||||
on_change="!mpc volume ${0%.*}"
|
||||
value = "200:mpc volume | cut -d ':' -f2 | cut -d '%' -f1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### Progress
|
||||
|
||||
A progress bar.
|
||||
|
||||
> Type: `progress`
|
||||
|
||||
Note that `value` expects a numeric value **between 0-`max`** as output.
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------------|----------------------------------------------------|--------------|---------------------------------------------------------------------------------|
|
||||
| `src` | `image` | `null` | Image source. See [here](images) for information on images. |
|
||||
| `size` | `integer` | `null` | Width/height of the image. Aspect ratio is preserved. |
|
||||
| `orientation` | `horizontal` or `vertical` (shorthand: `h` or `v`) | `horizontal` | Orientation of the slider. |
|
||||
| `value` | `Script` | `null` | Script to run to get the progress bar value. Output must be a valid percentage. |
|
||||
| `max` | `float` | `100` | Maximum progress bar value. |
|
||||
| `length` | `integer` | `null` | Slider length. GTK will automatically size if left unset. |
|
||||
|
||||
The example below shows progress for the current playing song in MPD,
|
||||
and displays the elapsed/length timestamps as a label above:
|
||||
|
||||
```corn
|
||||
$progress = {
|
||||
type = "custom"
|
||||
bar = [
|
||||
{
|
||||
type = "progress"
|
||||
value = "500:mpc | sed -n 2p | awk '{ print $4 }' | grep -Eo '[0-9]+' || echo 0"
|
||||
label = "{{500:mpc | sed -n 2p | awk '{ print $3 }'}} elapsed"
|
||||
length = 200
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Label Attributes
|
||||
|
||||
> ℹ This is different to the `label` widget, although applies to it.
|
||||
|
||||
Any widgets with a `label` attribute support embedded scripts,
|
||||
meaning you can interpolate text from scripts to dynamically show content.
|
||||
|
||||
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](scripts).
|
||||
|
||||
### 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`.
|
||||
|
||||
Some widgets provide a value when they run the command, such as `slider`.
|
||||
This is passed as an argument and can be accessed using `$0`.
|
||||
|
||||
The following bar commands are supported:
|
||||
|
||||
- `popup:toggle`
|
||||
- `popup:open`
|
||||
- `popup:close`
|
||||
|
||||
---
|
||||
|
||||
XML is arguably better-suited and easier to read for this sort of markup,
|
||||
but currently is not supported.
|
||||
Nonetheless, it may be worth comparing the examples to the below equivalent
|
||||
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 {
|
||||
$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 ]
|
||||
|
||||
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
|
||||
}
|
||||
} in {
|
||||
end = [ $power_menu ]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
Since the widgets are all custom, you can use the `name` and `class` attributes, then target them using `#name` and `.class`.
|
||||
|
||||
The following top-level selector is always available:
|
||||
|
||||
| Selector | Description |
|
||||
|-----------|-------------------------|
|
||||
| `#custom` | Custom widget container |
|
||||
93
docs/modules/Focused.md
Normal file
93
docs/modules/Focused.md
Normal file
@@ -0,0 +1,93 @@
|
||||
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 |
|
||||
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
|
||||
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
|
||||
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
|
||||
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "focused",
|
||||
"show_icon": true,
|
||||
"show_title": true,
|
||||
"icon_size": 32,
|
||||
"truncate": "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "focused"
|
||||
show_icon = true
|
||||
show_title = true
|
||||
icon_size = 32
|
||||
truncate = "end"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "focused"
|
||||
show_icon: true
|
||||
show_title: true
|
||||
icon_size: 32
|
||||
truncate: "end"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "focused"
|
||||
show_icon = true
|
||||
show_title = true
|
||||
icon_size = 32
|
||||
truncate = "end"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|--------------------------|--------------------|
|
||||
| `#focused` | Focused widget box |
|
||||
| `#focused #icon` | App icon |
|
||||
| `#focused #label` | App name |
|
||||
70
docs/modules/Label.md
Normal file
70
docs/modules/Label.md
Normal file
@@ -0,0 +1,70 @@
|
||||
Displays custom text, with the ability to embed [scripts](https://github.com/JakeStanger/ironbar/wiki/scripts#embedding).
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `label`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|---------|----------|---------|-----------------------------------------|
|
||||
| `label` | `string` | `null` | Text, optionally with embedded scripts. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "label",
|
||||
"label": "random num: {{500:echo $RANDOM}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "label"
|
||||
label = "random num: {{500:echo $RANDOM}}"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "label"
|
||||
label: "random num: {{500:echo $RANDOM}}"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "label"
|
||||
label = "random num: {{500:echo $RANDOM}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|--------------------------------|------------------------------------------------------------------------------------|
|
||||
| `#label` | Label widget |
|
||||
99
docs/modules/Launcher.md
Normal file
99
docs/modules/Launcher.md
Normal file
@@ -0,0 +1,99 @@
|
||||
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_size` | `integer` | `32` | Size to render icon at (image icons only). |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start": [
|
||||
{
|
||||
"type": "launcher",
|
||||
"favourites": [
|
||||
"firefox",
|
||||
"discord"
|
||||
],
|
||||
"show_names": false,
|
||||
"show_icons": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[start]]
|
||||
type = "launcher"
|
||||
favorites = ["firefox", "discord"]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
start:
|
||||
- type: "launcher"
|
||||
favorites:
|
||||
- firefox
|
||||
- discord
|
||||
show_names: false
|
||||
show_icons: true
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
start = [
|
||||
{
|
||||
type = "launcher"
|
||||
favorites = [ "firefox" "discord" ]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</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 |
|
||||
157
docs/modules/Music.md
Normal file
157
docs/modules/Music.md
Normal file
@@ -0,0 +1,157 @@
|
||||
Displays currently playing song from your music player.
|
||||
This module supports both MPRIS players and MPD servers.
|
||||
Clicking on the widget opens a popout displaying info about the current song, album art
|
||||
and playback controls.
|
||||
|
||||
in MPRIS mode, the widget will listen to all players and automatically detect/display the active one.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `music`
|
||||
|
||||
| | Type | Default | Description |
|
||||
|-----------------------|---------------------------------------|----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `player_type` | `mpris` or `mpd` | `mpris` | Whether to connect to MPRIS players or an MPD server. |
|
||||
| `format` | `string` | `{title} / {artist}` | Format string for the widget. More info below. |
|
||||
| `truncate` | `start` or `middle` or `end` or `Map` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. Use the long-hand `Map` version if specifying a length. |
|
||||
| `truncate.mode` | `start` or `middle` or `end` | `null` | The location of the ellipses and where to truncate text from. Leave null to avoid truncating. |
|
||||
| `truncate.length` | `integer` | `null` | The fixed width (in chars) of the widget. Leave blank to let GTK automatically handle. |
|
||||
| `truncate.max_length` | `integer` | `null` | The maximum number of characters before truncating. Leave blank to let GTK automatically handle. |
|
||||
| `icons.play` | `string/image` | `` | Icon to show when playing. |
|
||||
| `icons.pause` | `string/image` | `` | Icon to show when paused. |
|
||||
| `icons.prev` | `string/image` | `玲` | Icon to show on previous button. |
|
||||
| `icons.next` | `string/image` | `怜` | Icon to show on next button. |
|
||||
| `icons.volume` | `string/image` | `墳` | Icon to show under popup volume slider. |
|
||||
| `icons.track` | `string/image` | `` | Icon to show next to track title. |
|
||||
| `icons.album` | `string/image` | `` | Icon to show next to album name. |
|
||||
| `icons.artist` | `string/image` | `ﴁ` | Icon to show next to artist name. |
|
||||
| `show_status_icon` | `boolean` | `true` | Whether to show the play/pause icon on the widget. |
|
||||
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
|
||||
| `cover_image_size` | `integer` | `128` | Size to render album art image at inside popup. |
|
||||
| `host` | `string/image` | `localhost:6600` | [MPD Only] TCP or Unix socket for the MPD server. |
|
||||
| `music_dir` | `string/image` | `$HOME/Music` | [MPD Only] Path to MPD server's music directory on disc. Required for album art. |
|
||||
|
||||
See [here](images) for information on images.
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"start": [
|
||||
{
|
||||
"type": "music",
|
||||
"player_type": "mpd",
|
||||
"format": "{title} / {artist}",
|
||||
"truncate": "end",
|
||||
"icons": {
|
||||
"play": "",
|
||||
"pause": ""
|
||||
},
|
||||
"music_dir": "/home/jake/Music"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[start]]
|
||||
type = "music"
|
||||
player_type = "mpd"
|
||||
format = "{title} / {artist}"
|
||||
music_dir = "/home/jake/Music"
|
||||
truncate = "end"
|
||||
|
||||
[[start.icons]]
|
||||
play = ""
|
||||
pause = ""
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
start:
|
||||
- type: "music"
|
||||
player_type: "mpd"
|
||||
format: "{title} / {artist}"
|
||||
truncate: "end"
|
||||
icons:
|
||||
play: ""
|
||||
pause: ""
|
||||
music_dir: "/home/jake/Music"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
start = [
|
||||
{
|
||||
type = "music"
|
||||
player_type = "mpd"
|
||||
format = "{title} / {artist}"
|
||||
truncate = "end"
|
||||
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 |
|
||||
|--------------|--------------------------------------|
|
||||
| `{title}` | Title |
|
||||
| `{album}` | Album name |
|
||||
| `{artist}` | Artist name |
|
||||
| `{date}` | Release date |
|
||||
| `{track}` | Track number |
|
||||
| `{disc}` | Disc number |
|
||||
| `{genre}` | Genre |
|
||||
| `{duration}` | Duration in `mm:ss` |
|
||||
| `{elapsed}` | Time elapsed in `mm:ss` |
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|-------------------------------------|------------------------------------------|
|
||||
| `#music` | Tray widget button |
|
||||
| `#popup-music` | Popup box |
|
||||
| `#popup-music #album-art` | Album art image inside popup box |
|
||||
| `#popup-music #title` | Track title container inside popup box |
|
||||
| `#popup-music #title .icon` | Track title icon label inside popup box |
|
||||
| `#popup-music #title .label` | Track title label inside popup box |
|
||||
| `#popup-music #album` | Track album container inside popup box |
|
||||
| `#popup-music #album .icon` | Track album icon label inside popup box |
|
||||
| `#popup-music #album .label` | Track album label inside popup box |
|
||||
| `#popup-music #artist` | Track artist container inside popup box |
|
||||
| `#popup-music #artist .icon` | Track artist icon label inside popup box |
|
||||
| `#popup-music #artist .label` | Track artist label inside popup box |
|
||||
| `#popup-music #controls` | Controls container inside popup box |
|
||||
| `#popup-music #controls #btn-prev` | Previous button inside popup box |
|
||||
| `#popup-music #controls #btn-play` | Play button inside popup box |
|
||||
| `#popup-music #controls #btn-pause` | Pause button inside popup box |
|
||||
| `#popup-music #controls #btn-next` | Next button inside popup box |
|
||||
| `#popup-music #volume` | Volume container inside popup box |
|
||||
| `#popup-music #volume #slider` | Volume slider popup box |
|
||||
| `#popup-music #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 |
|
||||
80
docs/modules/Upower.md
Normal file
80
docs/modules/Upower.md
Normal file
@@ -0,0 +1,80 @@
|
||||
Displays system power information such as the battery percentage, and estimated time to empty.
|
||||
|
||||
`TODO: ADD SCREENSHOT`
|
||||
|
||||
[//]: # ()
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `upower`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|----------|----------|-----------------|---------------------------------------------------|
|
||||
| `format` | `string` | `{percentage}%` | Format string to use for the widget button label. |
|
||||
|
||||
<details>
|
||||
<summary>JSON</summary>
|
||||
|
||||
```json
|
||||
{
|
||||
"end": [
|
||||
{
|
||||
"type": "upower",
|
||||
"format": "{percentage}%"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>TOML</summary>
|
||||
|
||||
```toml
|
||||
[[end]]
|
||||
type = "upower"
|
||||
format = "{percentage}%"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>YAML</summary>
|
||||
|
||||
```yaml
|
||||
end:
|
||||
- type: "upower"
|
||||
format: "{percentage}%"
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Corn</summary>
|
||||
|
||||
```corn
|
||||
{
|
||||
end = [
|
||||
{
|
||||
type = "upower"
|
||||
format = "{percentage}%"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
## Styling
|
||||
|
||||
| Selector | Description |
|
||||
|---------------------------------|-----------------------------|
|
||||
| `#upower` | Upower widget container. |
|
||||
| `#upower #icon` | Upower widget battery icon. |
|
||||
| `#upower #button` | Upower widget button. |
|
||||
| `#upower #button #label` | Upower widget button label. |
|
||||
| `#popup-upower` | Clock popup box. |
|
||||
| `#popup-upower #upower-details` | Label inside the popup. |
|
||||
96
docs/modules/Workspaces.md
Normal file
96
docs/modules/Workspaces.md
Normal file
@@ -0,0 +1,96 @@
|
||||
> ⚠ **This module is currently only supported on Sway and Hyprland**
|
||||
|
||||
Shows all current workspaces. Clicking a workspace changes focus to it.
|
||||
|
||||

|
||||
|
||||
## Configuration
|
||||
|
||||
> Type: `workspaces`
|
||||
|
||||
| Name | Type | Default | Description |
|
||||
|----------------|-----------------------------|----------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `name_map` | `Map<string, string/image>` | `{}` | A map of actual workspace names to their display labels/images. Workspaces use their actual name if not present in the map. See [here](images) for information on images. |
|
||||
| `icon_size` | `integer` | `32` | Size to render icon at (image icons only). |
|
||||
| `all_monitors` | `boolean` | `false` | Whether to display workspaces from all monitors. When `false`, only shows workspaces on the current monitor. |
|
||||
| `sort` | `added` or `alphanumeric` | `alphanumeric` | The method used for sorting workspaces. `added` always appends to the end, `alphanumeric` sorts by number/name. |
|
||||
|
||||
<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) |
|
||||
@@ -4,40 +4,109 @@ let {
|
||||
all_monitors = false
|
||||
name_map = {
|
||||
1 = "ﭮ"
|
||||
2 = ""
|
||||
2 = "icon:firefox"
|
||||
3 = ""
|
||||
Games = ""
|
||||
Games = "icon:steam"
|
||||
Code = ""
|
||||
}
|
||||
}
|
||||
|
||||
$focused = { type = "focused" }
|
||||
|
||||
$launcher = {
|
||||
type = "launcher"
|
||||
favorites = ["firefox" "discord" "Steam"]
|
||||
show_names = false
|
||||
show_icons = true
|
||||
icon_theme = "Paper"
|
||||
}
|
||||
|
||||
$mpd_local = { type = "mpd" music_dir = "/home/jake/Music" }
|
||||
$mpd_server = { type = "mpd" host = "chloe:6600" }
|
||||
$mpris = {
|
||||
type = "music"
|
||||
player_type = "mpris"
|
||||
|
||||
on_click_middle = "playerctl play-pause"
|
||||
on_scroll_up = "playerctl volume +5"
|
||||
on_scroll_down = "playerctl volume -5"
|
||||
|
||||
}
|
||||
|
||||
$mpd_local = { type = "music" player_type = "mpd" music_dir = "/home/jake/Music" truncate.mode = "end" truncate.max_length = 100 }
|
||||
$mpd_server = { type = "music" player_type = "mpd" host = "chloe:6600" truncate = "end" }
|
||||
|
||||
$sys_info = {
|
||||
type = "sys-info"
|
||||
format = ["{cpu-percent}% " "{memory-percent}% "]
|
||||
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}"
|
||||
]
|
||||
}
|
||||
|
||||
$tray = { type = "tray" }
|
||||
|
||||
$clock = { type = "clock" }
|
||||
|
||||
$phone_battery = {
|
||||
type = "script"
|
||||
path = "/home/jake/bin/phone-battery"
|
||||
cmd = "/home/jake/bin/phone-battery"
|
||||
|
||||
show_if.cmd = "/home/jake/bin/phone-connected"
|
||||
show_if.interval = 500
|
||||
}
|
||||
|
||||
$left = [ $workspaces $launcher ]
|
||||
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clock ]
|
||||
$clipboard = { type = "clipboard" max_items = 3 truncate.mode = "end" truncate.length = 50 }
|
||||
|
||||
$label = { type = "label" label = "random num: {{500:echo $RANDOM}}" }
|
||||
|
||||
// -- begin custom --
|
||||
$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 ]
|
||||
|
||||
tooltip = "Up: {{30000:uptime -p | cut -d ' ' -f2-}}"
|
||||
}
|
||||
// -- end custom --
|
||||
|
||||
$left = [ $workspaces $launcher $label ]
|
||||
$right = [ $mpd_local $mpd_server $phone_battery $sys_info $clipboard $power_menu $clock ]
|
||||
}
|
||||
in {
|
||||
left = $left right = $right
|
||||
anchor_to_edges = true
|
||||
position = "bottom"
|
||||
icon_theme = "Paper"
|
||||
|
||||
start = $left
|
||||
end = $right
|
||||
}
|
||||
|
||||
@@ -1,18 +1,135 @@
|
||||
{
|
||||
"monitors": {
|
||||
"DP-1": [
|
||||
{
|
||||
"left": [{"type": "clock"}]
|
||||
"anchor_to_edges": true,
|
||||
"end": [
|
||||
{
|
||||
"music_dir": "/home/jake/Music",
|
||||
"player_type": "mpd",
|
||||
"truncate": {
|
||||
"max_length": 100,
|
||||
"mode": "end"
|
||||
},
|
||||
{
|
||||
"position": "top",
|
||||
"left": []
|
||||
}
|
||||
],
|
||||
"DP-2": {
|
||||
"position": "bottom",
|
||||
"height": 30,
|
||||
"left": []
|
||||
"type": "music"
|
||||
},
|
||||
{
|
||||
"host": "chloe:6600",
|
||||
"player_type": "mpd",
|
||||
"truncate": "end",
|
||||
"type": "music"
|
||||
},
|
||||
{
|
||||
"cmd": "/home/jake/bin/phone-battery",
|
||||
"show_if": {
|
||||
"cmd": "/home/jake/bin/phone-connected",
|
||||
"interval": 500
|
||||
},
|
||||
"type": "script"
|
||||
},
|
||||
{
|
||||
"format": [
|
||||
" {cpu_percent}% | {temp_c:k10temp_Tccd1}°C",
|
||||
" {memory_used} / {memory_total} GB ({memory_percent}%)",
|
||||
"| {swap_used} / {swap_total} GB ({swap_percent}%)",
|
||||
" {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)",
|
||||
"李 {net_down:enp39s0} / {net_up:enp39s0} Mbps",
|
||||
"猪 {load_average:1} | {load_average:5} | {load_average:15}",
|
||||
" {uptime}"
|
||||
],
|
||||
"interval": {
|
||||
"cpu": 1,
|
||||
"disks": 300,
|
||||
"memory": 30,
|
||||
"networks": 3,
|
||||
"temps": 5
|
||||
},
|
||||
"type": "sys_info"
|
||||
},
|
||||
{
|
||||
"max_items": 3,
|
||||
"truncate": {
|
||||
"length": 50,
|
||||
"mode": "end"
|
||||
},
|
||||
"type": "clipboard"
|
||||
},
|
||||
{
|
||||
"bar": [
|
||||
{
|
||||
"label": "",
|
||||
"name": "power-btn",
|
||||
"on_click": "popup:toggle",
|
||||
"type": "button"
|
||||
}
|
||||
],
|
||||
"class": "power-menu",
|
||||
"popup": [
|
||||
{
|
||||
"orientation": "vertical",
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"label": "Power menu",
|
||||
"name": "header",
|
||||
"type": "label"
|
||||
},
|
||||
{
|
||||
"type": "box",
|
||||
"widgets": [
|
||||
{
|
||||
"class": "power-btn",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"on_click": "!shutdown now",
|
||||
"type": "button"
|
||||
},
|
||||
{
|
||||
"class": "power-btn",
|
||||
"label": "<span font-size='40pt'></span>",
|
||||
"on_click": "!reboot",
|
||||
"type": "button"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"label": "Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}",
|
||||
"name": "uptime",
|
||||
"type": "label"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tooltip": "Up: {{30000:uptime -p | cut -d ' ' -f2-}}",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"type": "clock"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"icon_theme": "Paper",
|
||||
"position": "bottom",
|
||||
"start": [
|
||||
{
|
||||
"all_monitors": false,
|
||||
"name_map": {
|
||||
"1": "ﭮ",
|
||||
"2": "icon:firefox",
|
||||
"3": "",
|
||||
"Code": "",
|
||||
"Games": "icon:steam"
|
||||
},
|
||||
"type": "workspaces"
|
||||
},
|
||||
{
|
||||
"favorites": [
|
||||
"firefox",
|
||||
"discord",
|
||||
"Steam"
|
||||
],
|
||||
"show_icons": true,
|
||||
"show_names": false,
|
||||
"type": "launcher"
|
||||
},
|
||||
{
|
||||
"label": "random num: {{500:echo $RANDOM}}",
|
||||
"type": "label"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
122
examples/config.toml
Normal file
122
examples/config.toml
Normal file
@@ -0,0 +1,122 @@
|
||||
anchor_to_edges = true
|
||||
icon_theme = 'Paper'
|
||||
position = 'bottom'
|
||||
|
||||
[[end]]
|
||||
music_dir = '/home/jake/Music'
|
||||
player_type = 'mpd'
|
||||
type = 'music'
|
||||
|
||||
[end.truncate]
|
||||
max_length = 100
|
||||
mode = 'end'
|
||||
|
||||
[[end]]
|
||||
host = 'chloe:6600'
|
||||
player_type = 'mpd'
|
||||
truncate = 'end'
|
||||
type = 'music'
|
||||
|
||||
[[end]]
|
||||
cmd = '/home/jake/bin/phone-battery'
|
||||
type = 'script'
|
||||
|
||||
[end.show_if]
|
||||
cmd = '/home/jake/bin/phone-connected'
|
||||
interval = 500
|
||||
|
||||
[[end]]
|
||||
type = 'sys_info'
|
||||
format = [
|
||||
' {cpu_percent}% | {temp_c:k10temp_Tccd1}°C',
|
||||
' {memory_used} / {memory_total} GB ({memory_percent}%)',
|
||||
'| {swap_used} / {swap_total} GB ({swap_percent}%)',
|
||||
' {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)',
|
||||
'李 {net_down:enp39s0} / {net_up:enp39s0} Mbps',
|
||||
'猪 {load_average:1} | {load_average:5} | {load_average:15}',
|
||||
' {uptime}',
|
||||
]
|
||||
|
||||
[end.interval]
|
||||
cpu = 1
|
||||
disks = 300
|
||||
memory = 30
|
||||
networks = 3
|
||||
temps = 5
|
||||
|
||||
[[end]]
|
||||
max_items = 3
|
||||
type = 'clipboard'
|
||||
|
||||
[end.truncate]
|
||||
length = 50
|
||||
mode = 'end'
|
||||
|
||||
[[end]]
|
||||
class = 'power-menu'
|
||||
tooltip = '''Up: {{30000:uptime -p | cut -d ' ' -f2-}}'''
|
||||
type = 'custom'
|
||||
|
||||
[[end.bar]]
|
||||
label = ''
|
||||
name = 'power-btn'
|
||||
on_click = 'popup:toggle'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup]]
|
||||
orientation = 'vertical'
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = 'Power menu'
|
||||
name = 'header'
|
||||
type = 'label'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
type = 'box'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
on_click = '!shutdown now'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets.widgets]]
|
||||
class = 'power-btn'
|
||||
label = '''<span font-size='40pt'></span>'''
|
||||
on_click = '!reboot'
|
||||
type = 'button'
|
||||
|
||||
[[end.popup.widgets]]
|
||||
label = '''Uptime: {{30000:uptime -p | cut -d ' ' -f2-}}'''
|
||||
name = 'uptime'
|
||||
type = 'label'
|
||||
|
||||
[[end]]
|
||||
type = 'clock'
|
||||
|
||||
[[start]]
|
||||
all_monitors = false
|
||||
type = 'workspaces'
|
||||
|
||||
[start.name_map]
|
||||
1 = 'ﭮ'
|
||||
2 = 'icon:firefox'
|
||||
3 = ''
|
||||
Code = ''
|
||||
Games = 'icon:steam'
|
||||
|
||||
[[start]]
|
||||
show_icons = true
|
||||
show_names = false
|
||||
type = 'launcher'
|
||||
favorites = [
|
||||
'firefox',
|
||||
'discord',
|
||||
'Steam',
|
||||
]
|
||||
|
||||
[[start]]
|
||||
label = 'random num: {{500:echo $RANDOM}}'
|
||||
type = 'label'
|
||||
|
||||
87
examples/config.yaml
Normal file
87
examples/config.yaml
Normal file
@@ -0,0 +1,87 @@
|
||||
anchor_to_edges: true
|
||||
end:
|
||||
- music_dir: /home/jake/Music
|
||||
player_type: mpd
|
||||
truncate:
|
||||
max_length: 100
|
||||
mode: end
|
||||
type: music
|
||||
- host: chloe:6600
|
||||
player_type: mpd
|
||||
truncate: end
|
||||
type: music
|
||||
- cmd: /home/jake/bin/phone-battery
|
||||
show_if:
|
||||
cmd: /home/jake/bin/phone-connected
|
||||
interval: 500
|
||||
type: script
|
||||
- format:
|
||||
- {cpu_percent}% | {temp_c:k10temp_Tccd1}°C
|
||||
- {memory_used} / {memory_total} GB ({memory_percent}%)
|
||||
- '| {swap_used} / {swap_total} GB ({swap_percent}%)'
|
||||
- {disk_used:/} / {disk_total:/} GB ({disk_percent:/}%)
|
||||
- 李 {net_down:enp39s0} / {net_up:enp39s0} Mbps
|
||||
- 猪 {load_average:1} | {load_average:5} | {load_average:15}
|
||||
- {uptime}
|
||||
interval:
|
||||
cpu: 1
|
||||
disks: 300
|
||||
memory: 30
|
||||
networks: 3
|
||||
temps: 5
|
||||
type: sys_info
|
||||
- max_items: 3
|
||||
truncate:
|
||||
length: 50
|
||||
mode: end
|
||||
type: clipboard
|
||||
- bar:
|
||||
- label:
|
||||
name: power-btn
|
||||
on_click: popup:toggle
|
||||
type: button
|
||||
class: power-menu
|
||||
popup:
|
||||
- orientation: vertical
|
||||
type: box
|
||||
widgets:
|
||||
- label: Power menu
|
||||
name: header
|
||||
type: label
|
||||
- type: box
|
||||
widgets:
|
||||
- class: power-btn
|
||||
label: <span font-size='40pt'></span>
|
||||
on_click: '!shutdown now'
|
||||
type: button
|
||||
- class: power-btn
|
||||
label: <span font-size='40pt'></span>
|
||||
on_click: '!reboot'
|
||||
type: button
|
||||
- label: 'Uptime: {{30000:uptime -p | cut -d '' '' -f2-}}'
|
||||
name: uptime
|
||||
type: label
|
||||
tooltip: 'Up: {{30000:uptime -p | cut -d '' '' -f2-}}'
|
||||
type: custom
|
||||
- type: clock
|
||||
icon_theme: Paper
|
||||
position: bottom
|
||||
start:
|
||||
- all_monitors: false
|
||||
name_map:
|
||||
'1': ﭮ
|
||||
'2': icon:firefox
|
||||
'3':
|
||||
Code:
|
||||
Games: icon:steam
|
||||
type: workspaces
|
||||
- favorites:
|
||||
- firefox
|
||||
- discord
|
||||
- Steam
|
||||
show_icons: true
|
||||
show_names: false
|
||||
type: launcher
|
||||
- label: 'random num: {{500:echo $RANDOM}}'
|
||||
type: label
|
||||
|
||||
@@ -1,31 +1,19 @@
|
||||
* {
|
||||
/* `otf-font-awesome` is required to be installed for icons */
|
||||
font-family: Noto Sans Nerd Font, sans-serif;
|
||||
/* font-family: 'Jetbrains Mono', monospace;*/
|
||||
font-size: 16px;
|
||||
|
||||
/*color: white;*/
|
||||
/*background-color: #2d2d2d;*/
|
||||
/*background-color: red;*/
|
||||
border: none;
|
||||
|
||||
/*opacity: 0.4;*/
|
||||
}
|
||||
|
||||
#bar {
|
||||
border-top: 1px solid #424242;
|
||||
}
|
||||
|
||||
.container {
|
||||
.background, .container {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
/* test 34543*/
|
||||
|
||||
#right > * + * {
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
#workspaces .item {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
@@ -57,7 +45,7 @@
|
||||
|
||||
#launcher .focused {
|
||||
color: white;
|
||||
background-color: black;
|
||||
background-color: #1c1c1c;
|
||||
border-bottom: 4px solid #6699cc;
|
||||
}
|
||||
|
||||
@@ -66,25 +54,54 @@
|
||||
background-color: #8f0a0a;
|
||||
}
|
||||
|
||||
#popup-launcher .popup-item {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#popup-launcher .popup-item:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#popup-launcher .popup-item:not(:first-child) {
|
||||
border-top: 1px solid white;
|
||||
}
|
||||
|
||||
#clock {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
font-weight: bold;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#clock:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#script {
|
||||
padding-left: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sysinfo {
|
||||
margin-left: 10px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#sysinfo #item {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
#tray {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
#tray .item {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
#mpd {
|
||||
#music {
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
@@ -119,30 +136,105 @@
|
||||
background-color: #6699cc;
|
||||
}
|
||||
|
||||
#popup-mpd {
|
||||
#music:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#popup-music {
|
||||
color: white;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
#popup-mpd #album-art {
|
||||
/*border: 1px solid #424242;*/
|
||||
#popup-music #album-art {
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
#popup-mpd #title .icon, #popup-mpd #title .label {
|
||||
#popup-music #title .icon *, #popup-music #title .label {
|
||||
font-size: 1.7em;
|
||||
}
|
||||
|
||||
#popup-mpd #controls * {
|
||||
#popup-music #controls * {
|
||||
border-radius: 0;
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#popup-mpd #controls *:disabled {
|
||||
#popup-music #controls *:disabled {
|
||||
color: #424242;
|
||||
}
|
||||
|
||||
#popup-music #volume > box:last-child label {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
#focused {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.power-menu {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.power-menu #power-btn {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
.power-menu #power-btn:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
.popup-power-menu {
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu #header {
|
||||
color: white;
|
||||
font-size: 1.4em;
|
||||
border-bottom: 1px solid white;
|
||||
padding-bottom: 0.4em;
|
||||
margin-bottom: 0.8em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn {
|
||||
color: white;
|
||||
background-color: #2d2d2d;
|
||||
border: 1px solid white;
|
||||
padding: 0.6em 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn + .power-btn {
|
||||
margin-left: 1em;
|
||||
}
|
||||
|
||||
.popup-power-menu .power-btn:hover {
|
||||
background-color: #1c1c1c;
|
||||
}
|
||||
|
||||
#clipboard * {
|
||||
font-size: 1.8em;
|
||||
}
|
||||
|
||||
#popup-clipboard {
|
||||
padding: 1em;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#popup-clipboard .item {
|
||||
border-bottom: 1px solid #424242;
|
||||
}
|
||||
|
||||
#popup-clipboard .btn > *:nth-child(2) {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#popup-clipboard .btn-remove {
|
||||
background-color: #2d2d2d;
|
||||
color: white;
|
||||
font-size: 1.2em;
|
||||
border-left: 1px solid #424242;
|
||||
}
|
||||
|
||||
#popup-clipboard .btn-remove:hover {
|
||||
color: #fcc;
|
||||
}
|
||||
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": 1680213900,
|
||||
"narHash": "sha256-cIDr5WZIj3EkKyCgj/6j3HBH4Jj1W296z7HTcWj1aMA=",
|
||||
"owner": "nixos",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "e3652e0735fbec227f342712f180f4f21f0594f2",
|
||||
"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": 1680229280,
|
||||
"narHash": "sha256-9UoyQCeKUmHcsIdpsAgcz41LAIDkWhI2PhVDjckrpg0=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "aa480d799023141e1b9e5d6108700de63d9ad002",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
162
flake.nix
Normal file
162
flake.nix
Normal file
@@ -0,0 +1,162 @@
|
||||
{
|
||||
description = "Nix Flake for ironbar";
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
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;
|
||||
};
|
||||
props = builtins.fromTOML (builtins.readFile ./Cargo.toml);
|
||||
mkDate = longDate: (lib.concatStringsSep "-" [
|
||||
(builtins.substring 0 4 longDate)
|
||||
(builtins.substring 4 2 longDate)
|
||||
(builtins.substring 6 2 longDate)
|
||||
]);
|
||||
in {
|
||||
ironbar = prev.callPackage ./nix/default.nix {
|
||||
version = props.package.version + "+date=" + (mkDate (self.lastModifiedDate or "19700101")) + "_" + (self.shortRev or "dirty");
|
||||
inherit rustPlatform;
|
||||
};
|
||||
};
|
||||
packages = genSystems (
|
||||
system: let
|
||||
pkgs = pkgsFor system;
|
||||
in
|
||||
(self.overlays.default pkgs pkgs)
|
||||
// {
|
||||
default = self.packages.${system}.ironbar;
|
||||
}
|
||||
);
|
||||
apps = genSystems (system: let
|
||||
pkgs = pkgsFor system;
|
||||
in {
|
||||
default = {
|
||||
type = "app";
|
||||
program = "${pkgs.ironbar}/bin/ironbar";
|
||||
};
|
||||
ironbar = {
|
||||
type = "app";
|
||||
program = "${pkgs.ironbar}/bin/ironbar";
|
||||
};
|
||||
});
|
||||
devShells = genSystems (system: let
|
||||
pkgs = pkgsFor system;
|
||||
rust = mkRustToolchain pkgs;
|
||||
in {
|
||||
default = pkgs.mkShell {
|
||||
packages = with pkgs; [
|
||||
rust
|
||||
rust-analyzer-unwrapped
|
||||
gcc
|
||||
gtk3
|
||||
gtk-layer-shell
|
||||
pkg-config
|
||||
openssl
|
||||
];
|
||||
|
||||
RUST_SRC_PATH = "${rust}/lib/rustlib/src/rust/library";
|
||||
};
|
||||
});
|
||||
homeManagerModules.default = {
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}: let
|
||||
cfg = config.programs.ironbar;
|
||||
defaultIronbarPackage = self.packages.${pkgs.hostPlatform.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.";
|
||||
};
|
||||
features = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.nonEmptyStr;
|
||||
default = [];
|
||||
description = "The features to be used.";
|
||||
};
|
||||
};
|
||||
config = let
|
||||
pkg = cfg.package.override {features = cfg.features;};
|
||||
in
|
||||
lib.mkIf cfg.enable {
|
||||
home.packages = [pkg];
|
||||
xdg.configFile = {
|
||||
"ironbar/config.json" = lib.mkIf (cfg.config != "") {
|
||||
source = jsonFormat.generate "ironbar-config" cfg.config;
|
||||
};
|
||||
"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 = "${pkg}/bin/ironbar";
|
||||
};
|
||||
Install.WantedBy = [
|
||||
(lib.mkIf config.wayland.windowManager.hyprland.systemdIntegration "hyprland-session.target")
|
||||
(lib.mkIf config.wayland.windowManager.sway.systemdIntegration "sway-session.target")
|
||||
];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
nixConfig = {
|
||||
extra-substituters = ["https://jakestanger.cachix.org"];
|
||||
extra-trusted-public-keys = ["jakestanger.cachix.org-1:VWJE7AWNe5/KOEvCQRxoE8UsI2Xs2nHULJ7TEjYm7mM="];
|
||||
};
|
||||
}
|
||||
64
nix/default.nix
Normal file
64
nix/default.nix
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
gtk3,
|
||||
gdk-pixbuf,
|
||||
librsvg,
|
||||
webp-pixbuf-loader,
|
||||
gobject-introspection,
|
||||
glib-networking,
|
||||
glib,
|
||||
shared-mime-info,
|
||||
gsettings-desktop-schemas,
|
||||
wrapGAppsHook,
|
||||
gtk-layer-shell,
|
||||
gnome,
|
||||
libxkbcommon,
|
||||
openssl,
|
||||
pkg-config,
|
||||
hicolor-icon-theme,
|
||||
rustPlatform,
|
||||
lib,
|
||||
version ? "git",
|
||||
features ? [],
|
||||
}:
|
||||
rustPlatform.buildRustPackage rec {
|
||||
inherit version;
|
||||
pname = "ironbar";
|
||||
src = builtins.path {
|
||||
name = "ironbar";
|
||||
path = lib.cleanSource ../.;
|
||||
};
|
||||
buildNoDefaultFeatures =
|
||||
if features == []
|
||||
then false
|
||||
else true;
|
||||
buildFeatures = features;
|
||||
cargoDeps = rustPlatform.importCargoLock {lockFile = ../Cargo.lock;};
|
||||
cargoLock.lockFile = ../Cargo.lock;
|
||||
nativeBuildInputs = [pkg-config wrapGAppsHook gobject-introspection];
|
||||
buildInputs = [gtk3 gdk-pixbuf glib gtk-layer-shell glib-networking shared-mime-info gnome.adwaita-icon-theme hicolor-icon-theme gsettings-desktop-schemas libxkbcommon openssl];
|
||||
propagatedBuildInputs = [
|
||||
gtk3
|
||||
];
|
||||
preFixup = ''
|
||||
gappsWrapperArgs+=(
|
||||
# Thumbnailers
|
||||
--prefix XDG_DATA_DIRS : "${gdk-pixbuf}/share"
|
||||
--prefix XDG_DATA_DIRS : "${librsvg}/share"
|
||||
--prefix XDG_DATA_DIRS : "${webp-pixbuf-loader}/share"
|
||||
--prefix XDG_DATA_DIRS : "${shared-mime-info}/share"
|
||||
)
|
||||
'';
|
||||
passthru = {
|
||||
updateScript = gnome.updateScript {
|
||||
packageName = pname;
|
||||
attrPath = "gnome.${pname}";
|
||||
};
|
||||
};
|
||||
meta = with lib; {
|
||||
homepage = "https://github.com/JakeStanger/ironbar";
|
||||
description = "Customisable gtk-layer-shell wlroots/sway bar written in rust.";
|
||||
license = licenses.mit;
|
||||
platforms = platforms.linux;
|
||||
mainProgram = "ironbar";
|
||||
};
|
||||
}
|
||||
5
scripts/generate-examples.sh
Executable file
5
scripts/generate-examples.sh
Executable file
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
corn examples/config.corn -t json > examples/config.json
|
||||
corn examples/config.corn -t toml > examples/config.toml
|
||||
corn examples/config.corn -t yaml > examples/config.yaml
|
||||
249
src/bar.rs
249
src/bar.rs
@@ -1,10 +1,13 @@
|
||||
use crate::config::{BarPosition, ModuleConfig};
|
||||
use crate::modules::{Module, ModuleInfo, ModuleLocation};
|
||||
use crate::config::{BarPosition, MarginConfig, ModuleConfig};
|
||||
use crate::modules::{create_module, wrap_widget, ModuleInfo, ModuleLocation};
|
||||
use crate::popup::Popup;
|
||||
use crate::Config;
|
||||
use color_eyre::Result;
|
||||
use gtk::gdk::Monitor;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Application, ApplicationWindow, Orientation};
|
||||
use gtk::{Application, ApplicationWindow, IconTheme, Orientation};
|
||||
use std::sync::{Arc, RwLock};
|
||||
use glib::signal::Inhibit;
|
||||
use tracing::{debug, info};
|
||||
|
||||
/// Creates a new window for a bar,
|
||||
@@ -17,44 +20,120 @@ 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,
|
||||
config.margin,
|
||||
);
|
||||
|
||||
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")
|
||||
.build();
|
||||
.name("bar");
|
||||
|
||||
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();
|
||||
let content = if orientation == Orientation::Horizontal {
|
||||
content.height_request(config.height)
|
||||
} else {
|
||||
content.width_request(config.height)
|
||||
}
|
||||
.build();
|
||||
|
||||
content.style_context().add_class("container");
|
||||
left.style_context().add_class("container");
|
||||
center.style_context().add_class("container");
|
||||
right.style_context().add_class("container");
|
||||
|
||||
content.add(&left);
|
||||
let start = create_container("start", orientation);
|
||||
let center = create_container("center", orientation);
|
||||
let end = create_container("end", orientation);
|
||||
|
||||
content.append(&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)?;
|
||||
win.add(&content);
|
||||
load_modules(&start, ¢er, &end, app, config, monitor, monitor_name)?;
|
||||
win.append(&content);
|
||||
|
||||
win.connect_destroy_event(|_, _| {
|
||||
info!("Shutting down");
|
||||
gtk::main_quit();
|
||||
// gtk::main_quit();
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
debug!("Showing bar");
|
||||
win.show_all();
|
||||
|
||||
// show each box but do not use `show_all`.
|
||||
// this ensures `show_if` option works as intended.
|
||||
start.show();
|
||||
center.show();
|
||||
end.show();
|
||||
content.show();
|
||||
win.show();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets up GTK layer shell for a provided application window.
|
||||
fn setup_layer_shell(
|
||||
win: &ApplicationWindow,
|
||||
monitor: &Monitor,
|
||||
position: BarPosition,
|
||||
anchor_to_edges: bool,
|
||||
margin: MarginConfig,
|
||||
) {
|
||||
gtk_layer_shell::init_for_window(win);
|
||||
gtk_layer_shell::set_monitor(win, monitor);
|
||||
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
|
||||
gtk_layer_shell::auto_exclusive_zone_enable(win);
|
||||
gtk_layer_shell::set_namespace(win, env!("CARGO_PKG_NAME"));
|
||||
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Top, margin.top);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Bottom, margin.bottom);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, margin.left);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, margin.right);
|
||||
|
||||
let bar_orientation = position.get_orientation();
|
||||
|
||||
gtk_layer_shell::set_anchor(
|
||||
win,
|
||||
gtk_layer_shell::Edge::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
|
||||
|| (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),
|
||||
);
|
||||
}
|
||||
|
||||
/// Creates a `gtk::Box` container to place widgets inside.
|
||||
fn create_container(name: &str, orientation: Orientation) -> gtk::Box {
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(orientation)
|
||||
.spacing(0)
|
||||
.name(name)
|
||||
.build();
|
||||
|
||||
container.style_context().add_class("container");
|
||||
container
|
||||
}
|
||||
|
||||
/// Loads the configured modules onto a bar.
|
||||
fn load_modules(
|
||||
left: >k::Box,
|
||||
@@ -65,40 +144,37 @@ 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 icon_theme = IconTheme::new();
|
||||
if let Some(ref theme) = config.icon_theme {
|
||||
icon_theme.set_custom_theme(Some(theme));
|
||||
}
|
||||
|
||||
add_modules(left, modules, &info)?;
|
||||
macro_rules! info {
|
||||
($location:expr) => {
|
||||
ModuleInfo {
|
||||
app,
|
||||
bar_position: config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
location: $location,
|
||||
icon_theme: &icon_theme,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(modules) = config.start {
|
||||
let info = info!(ModuleLocation::Left);
|
||||
add_modules(left, modules, &info, config.popup_gap)?;
|
||||
}
|
||||
|
||||
if let Some(modules) = config.center {
|
||||
let info = ModuleInfo {
|
||||
app,
|
||||
location: ModuleLocation::Center,
|
||||
bar_position: &config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
};
|
||||
|
||||
add_modules(center, modules, &info)?;
|
||||
let info = info!(ModuleLocation::Center);
|
||||
add_modules(center, modules, &info, config.popup_gap)?;
|
||||
}
|
||||
|
||||
if let Some(modules) = config.right {
|
||||
let info = ModuleInfo {
|
||||
app,
|
||||
location: ModuleLocation::Right,
|
||||
bar_position: &config.position,
|
||||
monitor,
|
||||
output_name,
|
||||
};
|
||||
|
||||
add_modules(right, modules, &info)?;
|
||||
if let Some(modules) = config.end {
|
||||
let info = info!(ModuleLocation::Right);
|
||||
add_modules(right, modules, &info, config.popup_gap)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -106,54 +182,49 @@ fn load_modules(
|
||||
|
||||
/// Adds modules into a provided GTK box,
|
||||
/// which should be one of its left, center or right containers.
|
||||
fn add_modules(content: >k::Box, modules: Vec<ModuleConfig>, info: &ModuleInfo) -> Result<()> {
|
||||
fn add_modules(
|
||||
content: >k::Box,
|
||||
modules: Vec<ModuleConfig>,
|
||||
info: &ModuleInfo,
|
||||
popup_gap: i32,
|
||||
) -> Result<()> {
|
||||
let popup = Popup::new(info, popup_gap);
|
||||
let popup = Arc::new(RwLock::new(popup));
|
||||
|
||||
let orientation = info.bar_position.get_orientation();
|
||||
|
||||
macro_rules! add_module {
|
||||
($module:expr, $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) => {{
|
||||
let common = $module.common.take().expect("Common config did not exist");
|
||||
let widget = create_module(*$module, $id, &info, &Arc::clone(&popup))?;
|
||||
let container = wrap_widget(&widget, common, orientation);
|
||||
content.append(&container);
|
||||
}};
|
||||
}
|
||||
|
||||
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"),
|
||||
#[cfg(feature = "clipboard")]
|
||||
ModuleConfig::Clipboard(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "clock")]
|
||||
ModuleConfig::Clock(mut module) => add_module!(module, id),
|
||||
ModuleConfig::Custom(mut module) => add_module!(module, id),
|
||||
ModuleConfig::Focused(mut module) => add_module!(module, id),
|
||||
ModuleConfig::Label(mut module) => add_module!(module, id),
|
||||
ModuleConfig::Launcher(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "music")]
|
||||
ModuleConfig::Music(mut module) => add_module!(module, id),
|
||||
ModuleConfig::Script(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "sys_info")]
|
||||
ModuleConfig::SysInfo(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "tray")]
|
||||
ModuleConfig::Tray(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "upower")]
|
||||
ModuleConfig::Upower(mut module) => add_module!(module, id),
|
||||
#[cfg(feature = "workspaces")]
|
||||
ModuleConfig::Workspaces(mut module) => add_module!(module, id),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets up GTK layer shell for a provided aplication window.
|
||||
fn setup_layer_shell(win: &ApplicationWindow, monitor: &Monitor, position: &BarPosition) {
|
||||
gtk_layer_shell::init_for_window(win);
|
||||
gtk_layer_shell::set_monitor(win, monitor);
|
||||
gtk_layer_shell::set_layer(win, gtk_layer_shell::Layer::Top);
|
||||
gtk_layer_shell::auto_exclusive_zone_enable(win);
|
||||
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Top, 0);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Bottom, 0);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Left, 0);
|
||||
gtk_layer_shell::set_margin(win, gtk_layer_shell::Edge::Right, 0);
|
||||
|
||||
gtk_layer_shell::set_anchor(
|
||||
win,
|
||||
gtk_layer_shell::Edge::Top,
|
||||
position == &BarPosition::Top,
|
||||
);
|
||||
gtk_layer_shell::set_anchor(
|
||||
win,
|
||||
gtk_layer_shell::Edge::Bottom,
|
||||
position == &BarPosition::Bottom,
|
||||
);
|
||||
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Left, true);
|
||||
gtk_layer_shell::set_anchor(win, gtk_layer_shell::Edge::Right, true);
|
||||
}
|
||||
|
||||
44
src/bridge_channel.rs
Normal file
44
src/bridge_channel.rs
Normal file
@@ -0,0 +1,44 @@
|
||||
use crate::send;
|
||||
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 {
|
||||
send!(sync_tx, val);
|
||||
}
|
||||
});
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
243
src/clients/clipboard.rs
Normal file
243
src/clients/clipboard.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
use super::wayland::{self, ClipboardItem};
|
||||
use crate::{lock, try_send};
|
||||
use indexmap::map::Iter;
|
||||
use indexmap::IndexMap;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::debug;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClipboardEvent {
|
||||
Add(Arc<ClipboardItem>),
|
||||
Remove(usize),
|
||||
Activate(usize),
|
||||
}
|
||||
|
||||
type EventSender = mpsc::Sender<ClipboardEvent>;
|
||||
|
||||
/// Clipboard client singleton,
|
||||
/// to ensure bars don't duplicate requests to the compositor.
|
||||
pub struct ClipboardClient {
|
||||
senders: Arc<Mutex<Vec<(EventSender, usize)>>>,
|
||||
cache: Arc<Mutex<ClipboardCache>>,
|
||||
}
|
||||
|
||||
impl ClipboardClient {
|
||||
fn new() -> Self {
|
||||
let senders = Arc::new(Mutex::new(Vec::<(EventSender, usize)>::new()));
|
||||
|
||||
let cache = Arc::new(Mutex::new(ClipboardCache::new()));
|
||||
|
||||
{
|
||||
let senders = senders.clone();
|
||||
let cache = cache.clone();
|
||||
|
||||
spawn(async move {
|
||||
let mut rx = {
|
||||
let wl = wayland::get_client().await;
|
||||
wl.subscribe_clipboard()
|
||||
};
|
||||
|
||||
while let Ok(item) = rx.recv().await {
|
||||
debug!("Received clipboard item (ID: {})", item.id);
|
||||
|
||||
let (existing_id, cache_size) = {
|
||||
let cache = lock!(cache);
|
||||
(cache.contains(&item), cache.len())
|
||||
};
|
||||
|
||||
existing_id.map_or_else(
|
||||
|| {
|
||||
{
|
||||
let mut cache = lock!(cache);
|
||||
let senders = lock!(senders);
|
||||
cache.insert(item.clone(), senders.len());
|
||||
}
|
||||
let senders = lock!(senders);
|
||||
let iter = senders.iter();
|
||||
for (tx, sender_cache_size) in iter {
|
||||
if cache_size == *sender_cache_size {
|
||||
// let mut cache = lock!(cache);
|
||||
let removed_id = lock!(cache)
|
||||
.remove_ref_first()
|
||||
.expect("Clipboard cache unexpectedly empty");
|
||||
try_send!(tx, ClipboardEvent::Remove(removed_id));
|
||||
}
|
||||
try_send!(tx, ClipboardEvent::Add(item.clone()));
|
||||
}
|
||||
},
|
||||
|existing_id| {
|
||||
let senders = lock!(senders);
|
||||
let iter = senders.iter();
|
||||
for (tx, _) in iter {
|
||||
try_send!(tx, ClipboardEvent::Activate(existing_id));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self { senders, cache }
|
||||
}
|
||||
|
||||
pub async fn subscribe(&self, cache_size: usize) -> mpsc::Receiver<ClipboardEvent> {
|
||||
let (tx, rx) = mpsc::channel(16);
|
||||
|
||||
let wl = wayland::get_client().await;
|
||||
wl.roundtrip();
|
||||
|
||||
{
|
||||
let mut cache = lock!(self.cache);
|
||||
|
||||
if let Some(item) = wl.get_clipboard() {
|
||||
cache.insert_or_inc_ref(item);
|
||||
}
|
||||
|
||||
let iter = cache.iter();
|
||||
for (_, (item, _)) in iter {
|
||||
try_send!(tx, ClipboardEvent::Add(item.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut senders = lock!(self.senders);
|
||||
senders.push((tx, cache_size));
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
|
||||
pub async fn copy(&self, id: usize) {
|
||||
debug!("Copying item with id {id}");
|
||||
|
||||
let item = {
|
||||
let cache = lock!(self.cache);
|
||||
cache.get(id)
|
||||
};
|
||||
|
||||
if let Some(item) = item {
|
||||
let wl = wayland::get_client().await;
|
||||
wl.copy_to_clipboard(item);
|
||||
}
|
||||
|
||||
let senders = lock!(self.senders);
|
||||
let iter = senders.iter();
|
||||
for (tx, _) in iter {
|
||||
try_send!(tx, ClipboardEvent::Activate(id));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove(&self, id: usize) {
|
||||
lock!(self.cache).remove(id);
|
||||
|
||||
let senders = lock!(self.senders);
|
||||
let iter = senders.iter();
|
||||
for (tx, _) in iter {
|
||||
try_send!(tx, ClipboardEvent::Remove(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared clipboard item cache.
|
||||
///
|
||||
/// Items are stored with a number of references,
|
||||
/// allowing different consumers to 'remove' cached items
|
||||
/// at different times.
|
||||
#[derive(Debug)]
|
||||
struct ClipboardCache {
|
||||
cache: IndexMap<usize, (Arc<ClipboardItem>, usize)>,
|
||||
}
|
||||
|
||||
impl ClipboardCache {
|
||||
/// Creates a new empty cache.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
cache: IndexMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the entry with key `id` from the cache.
|
||||
fn get(&self, id: usize) -> Option<Arc<ClipboardItem>> {
|
||||
self.cache.get(&id).map(|(item, _)| item).cloned()
|
||||
}
|
||||
|
||||
/// Inserts an entry with `ref_count` initial references.
|
||||
fn insert(&mut self, item: Arc<ClipboardItem>, ref_count: usize) -> Option<Arc<ClipboardItem>> {
|
||||
self.cache
|
||||
.insert(item.id, (item, ref_count))
|
||||
.map(|(item, _)| item)
|
||||
}
|
||||
|
||||
/// Inserts an entry with `ref_count` initial references,
|
||||
/// or increments the `ref_count` by 1 if it already exists.
|
||||
fn insert_or_inc_ref(&mut self, item: Arc<ClipboardItem>) {
|
||||
let mut item = self.cache.entry(item.id).or_insert((item, 0));
|
||||
item.1 += 1;
|
||||
}
|
||||
|
||||
/// Removes the entry with key `id`.
|
||||
/// This ignores references.
|
||||
fn remove(&mut self, id: usize) -> Option<Arc<ClipboardItem>> {
|
||||
self.cache.shift_remove(&id).map(|(item, _)| item)
|
||||
}
|
||||
|
||||
/// Removes a reference to the entry with key `id`.
|
||||
///
|
||||
/// If the reference count reaches zero, the entry
|
||||
/// is removed from the cache.
|
||||
fn remove_ref(&mut self, id: usize) {
|
||||
if let Some(entry) = self.cache.get_mut(&id) {
|
||||
entry.1 -= 1;
|
||||
|
||||
if entry.1 == 0 {
|
||||
self.cache.shift_remove(&id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a reference to the first entry.
|
||||
///
|
||||
/// If the reference count reaches zero, the entry
|
||||
/// is removed from the cache.
|
||||
fn remove_ref_first(&mut self) -> Option<usize> {
|
||||
if let Some((id, _)) = self.cache.first() {
|
||||
let id = *id;
|
||||
self.remove_ref(id);
|
||||
Some(id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks if an item with matching mime type and value
|
||||
/// already exists in the cache.
|
||||
fn contains(&self, item: &ClipboardItem) -> Option<usize> {
|
||||
self.cache.values().find_map(|(it, _)| {
|
||||
if it.mime_type == item.mime_type && it.value == item.value {
|
||||
Some(it.id)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets the current number of items in the cache.
|
||||
fn len(&self) -> usize {
|
||||
self.cache.len()
|
||||
}
|
||||
|
||||
fn iter(&self) -> Iter<'_, usize, (Arc<ClipboardItem>, usize)> {
|
||||
self.cache.iter()
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: ClipboardClient = ClipboardClient::new();
|
||||
}
|
||||
|
||||
pub fn get_client() -> &'static ClipboardClient {
|
||||
&CLIENT
|
||||
}
|
||||
281
src/clients/compositor/hyprland.rs
Normal file
281
src/clients/compositor/hyprland.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
|
||||
use crate::{lock, send};
|
||||
use color_eyre::Result;
|
||||
use hyprland::data::{Workspace as HWorkspace, Workspaces};
|
||||
use hyprland::dispatch::{Dispatch, DispatchType, WorkspaceIdentifierWithSpecial};
|
||||
use hyprland::event_listener::EventListenerMutable as EventListener;
|
||||
use hyprland::prelude::*;
|
||||
use hyprland::shared::WorkspaceType;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::broadcast::{channel, Receiver, Sender};
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
pub struct EventClient {
|
||||
workspace_tx: Sender<WorkspaceUpdate>,
|
||||
_workspace_rx: Receiver<WorkspaceUpdate>,
|
||||
}
|
||||
|
||||
impl EventClient {
|
||||
fn new() -> Self {
|
||||
let (workspace_tx, workspace_rx) = channel(16);
|
||||
|
||||
Self {
|
||||
workspace_tx,
|
||||
_workspace_rx: workspace_rx,
|
||||
}
|
||||
}
|
||||
|
||||
fn listen_workspace_events(&self) {
|
||||
info!("Starting Hyprland event listener");
|
||||
|
||||
let tx = self.workspace_tx.clone();
|
||||
|
||||
spawn_blocking(move || {
|
||||
let mut event_listener = EventListener::new();
|
||||
|
||||
// we need a lock to ensure events don't run at the same time
|
||||
let lock = Arc::new(Mutex::new(()));
|
||||
|
||||
// cache the active workspace since Hyprland doesn't give us the prev active
|
||||
let active = Self::get_active_workspace().expect("Failed to get active workspace");
|
||||
let active = Arc::new(Mutex::new(Some(active)));
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let lock = lock.clone();
|
||||
let active = active.clone();
|
||||
|
||||
event_listener.add_workspace_added_handler(move |workspace_type, _state| {
|
||||
let _lock = lock!(lock);
|
||||
debug!("Added workspace: {workspace_type:?}");
|
||||
|
||||
let workspace_name = get_workspace_name(workspace_type);
|
||||
let prev_workspace = lock!(active);
|
||||
let focused = prev_workspace
|
||||
.as_ref()
|
||||
.map_or(false, |w| w.name == workspace_name);
|
||||
|
||||
let workspace = Self::get_workspace(&workspace_name, focused);
|
||||
|
||||
if let Some(workspace) = workspace {
|
||||
send!(tx, WorkspaceUpdate::Add(workspace));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let lock = lock.clone();
|
||||
let active = active.clone();
|
||||
|
||||
event_listener.add_workspace_change_handler(move |workspace_type, _state| {
|
||||
let _lock = lock!(lock);
|
||||
|
||||
let mut prev_workspace = lock!(active);
|
||||
|
||||
debug!(
|
||||
"Received workspace change: {:?} -> {workspace_type:?}",
|
||||
prev_workspace.as_ref().map(|w| &w.id)
|
||||
);
|
||||
|
||||
let workspace_name = get_workspace_name(workspace_type);
|
||||
let focused = prev_workspace
|
||||
.as_ref()
|
||||
.map_or(false, |w| w.name == workspace_name);
|
||||
let workspace = Self::get_workspace(&workspace_name, focused);
|
||||
|
||||
workspace.map_or_else(
|
||||
|| {
|
||||
error!("Unable to locate workspace");
|
||||
},
|
||||
|workspace| {
|
||||
// there may be another type of update so dispatch that regardless of focus change
|
||||
send!(tx, WorkspaceUpdate::Update(workspace.clone()));
|
||||
if !focused {
|
||||
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let lock = lock.clone();
|
||||
let active = active.clone();
|
||||
|
||||
event_listener.add_active_monitor_change_handler(move |event_data, _state| {
|
||||
let _lock = lock!(lock);
|
||||
let workspace_type = event_data.1;
|
||||
|
||||
let mut prev_workspace = lock!(active);
|
||||
|
||||
debug!(
|
||||
"Received active monitor change: {:?} -> {workspace_type:?}",
|
||||
prev_workspace.as_ref().map(|w| &w.name)
|
||||
);
|
||||
|
||||
let workspace_name = get_workspace_name(workspace_type);
|
||||
let focused = prev_workspace
|
||||
.as_ref()
|
||||
.map_or(false, |w| w.name == workspace_name);
|
||||
let workspace = Self::get_workspace(&workspace_name, focused);
|
||||
|
||||
if let (Some(workspace), false) = (workspace, focused) {
|
||||
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
|
||||
} else {
|
||||
error!("Unable to locate workspace");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let lock = lock.clone();
|
||||
|
||||
event_listener.add_workspace_moved_handler(move |event_data, _state| {
|
||||
let _lock = lock!(lock);
|
||||
let workspace_type = event_data.1;
|
||||
debug!("Received workspace move: {workspace_type:?}");
|
||||
|
||||
let mut prev_workspace = lock!(active);
|
||||
|
||||
let workspace_name = get_workspace_name(workspace_type);
|
||||
let focused = prev_workspace
|
||||
.as_ref()
|
||||
.map_or(false, |w| w.name == workspace_name);
|
||||
let workspace = Self::get_workspace(&workspace_name, focused);
|
||||
|
||||
if let Some(workspace) = workspace {
|
||||
send!(tx, WorkspaceUpdate::Move(workspace.clone()));
|
||||
|
||||
if !focused {
|
||||
Self::send_focus_change(&mut prev_workspace, workspace, &tx);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
event_listener.add_workspace_destroy_handler(move |workspace_type, _state| {
|
||||
let _lock = lock!(lock);
|
||||
debug!("Received workspace destroy: {workspace_type:?}");
|
||||
|
||||
let name = get_workspace_name(workspace_type);
|
||||
send!(tx, WorkspaceUpdate::Remove(name));
|
||||
});
|
||||
}
|
||||
|
||||
event_listener
|
||||
.start_listener()
|
||||
.expect("Failed to start listener");
|
||||
});
|
||||
}
|
||||
|
||||
/// Sends a `WorkspaceUpdate::Focus` event
|
||||
/// and updates the active workspace cache.
|
||||
fn send_focus_change(
|
||||
prev_workspace: &mut Option<Workspace>,
|
||||
workspace: Workspace,
|
||||
tx: &Sender<WorkspaceUpdate>,
|
||||
) {
|
||||
let old = prev_workspace
|
||||
.as_ref()
|
||||
.map(|w| w.name.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
send!(
|
||||
tx,
|
||||
WorkspaceUpdate::Focus {
|
||||
old,
|
||||
new: workspace.name.clone(),
|
||||
}
|
||||
);
|
||||
|
||||
prev_workspace.replace(workspace);
|
||||
}
|
||||
|
||||
/// Gets a workspace by name from the server.
|
||||
///
|
||||
/// Use `focused` to manually mark the workspace as focused,
|
||||
/// as this is not automatically checked.
|
||||
fn get_workspace(name: &str, focused: bool) -> Option<Workspace> {
|
||||
Workspaces::get()
|
||||
.expect("Failed to get workspaces")
|
||||
.find_map(|w| {
|
||||
if w.name == name {
|
||||
Some(Workspace::from((focused, w)))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Gets the active workspace from the server.
|
||||
fn get_active_workspace() -> Result<Workspace> {
|
||||
let w = HWorkspace::get_active().map(|w| Workspace::from((true, w)))?;
|
||||
Ok(w)
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for EventClient {
|
||||
fn focus(&self, id: String) -> Result<()> {
|
||||
Dispatch::call(DispatchType::Workspace(
|
||||
WorkspaceIdentifierWithSpecial::Name(&id),
|
||||
))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
|
||||
let rx = self.workspace_tx.subscribe();
|
||||
|
||||
{
|
||||
let tx = self.workspace_tx.clone();
|
||||
|
||||
let active_name = HWorkspace::get_active()
|
||||
.map(|active| active.name)
|
||||
.unwrap_or_default();
|
||||
|
||||
let workspaces = Workspaces::get()
|
||||
.expect("Failed to get workspaces")
|
||||
.map(|w| Workspace::from((w.name == active_name, w)))
|
||||
.collect();
|
||||
|
||||
send!(tx, WorkspaceUpdate::Init(workspaces));
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: EventClient = {
|
||||
let client = EventClient::new();
|
||||
client.listen_workspace_events();
|
||||
client
|
||||
};
|
||||
}
|
||||
|
||||
pub fn get_client() -> &'static EventClient {
|
||||
&CLIENT
|
||||
}
|
||||
|
||||
fn get_workspace_name(name: WorkspaceType) -> String {
|
||||
match name {
|
||||
WorkspaceType::Regular(name) => name,
|
||||
WorkspaceType::Special(name) => name.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<(bool, hyprland::data::Workspace)> for Workspace {
|
||||
fn from((focused, workspace): (bool, hyprland::data::Workspace)) -> Self {
|
||||
Self {
|
||||
id: workspace.id.to_string(),
|
||||
name: workspace.name,
|
||||
monitor: workspace.monitor,
|
||||
focused,
|
||||
}
|
||||
}
|
||||
}
|
||||
104
src/clients/compositor/mod.rs
Normal file
104
src/clients/compositor/mod.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
use cfg_if::cfg_if;
|
||||
use color_eyre::{Help, Report, Result};
|
||||
use std::fmt::{Display, Formatter};
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::debug;
|
||||
|
||||
#[cfg(feature = "workspaces+hyprland")]
|
||||
pub mod hyprland;
|
||||
#[cfg(feature = "workspaces+sway")]
|
||||
pub mod sway;
|
||||
|
||||
pub enum Compositor {
|
||||
#[cfg(feature = "workspaces+sway")]
|
||||
Sway,
|
||||
#[cfg(feature = "workspaces+hyprland")]
|
||||
Hyprland,
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Display for Compositor {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}",
|
||||
match self {
|
||||
#[cfg(feature = "workspaces+sway")]
|
||||
Self::Sway => "Sway",
|
||||
#[cfg(feature = "workspaces+hyprland")]
|
||||
Self::Hyprland => "Hyprland",
|
||||
Self::Unsupported => "Unsupported",
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl Compositor {
|
||||
/// Attempts to get the current compositor.
|
||||
/// This is done by checking system env vars.
|
||||
fn get_current() -> Self {
|
||||
if std::env::var("SWAYSOCK").is_ok() {
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "workspaces+sway")] { Self::Sway }
|
||||
else { tracing::error!("Not compiled with Sway support"); Self::Unsupported }
|
||||
}
|
||||
} else if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() {
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "workspaces+hyprland")] { Self::Hyprland}
|
||||
else { tracing::error!("Not compiled with Hyprland support"); Self::Unsupported }
|
||||
}
|
||||
} else {
|
||||
Self::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the workspace client for the current compositor
|
||||
pub fn get_workspace_client() -> Result<&'static (dyn WorkspaceClient + Send)> {
|
||||
let current = Self::get_current();
|
||||
debug!("Getting workspace client for: {current}");
|
||||
match current {
|
||||
#[cfg(feature = "workspaces+sway")]
|
||||
Self::Sway => Ok(sway::get_sub_client()),
|
||||
#[cfg(feature = "workspaces+hyprland")]
|
||||
Self::Hyprland => Ok(hyprland::get_client()),
|
||||
Self::Unsupported => Err(Report::msg("Unsupported compositor")
|
||||
.note("Currently workspaces are only supported by Sway and Hyprland")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Workspace {
|
||||
/// Unique identifier
|
||||
pub id: String,
|
||||
/// Workspace friendly name
|
||||
pub name: String,
|
||||
/// Name of the monitor (output) the workspace is located on
|
||||
pub monitor: String,
|
||||
/// Whether the workspace is in focus
|
||||
pub focused: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum WorkspaceUpdate {
|
||||
/// Provides an initial list of workspaces.
|
||||
/// This is re-sent to all subscribers when a new subscription is created.
|
||||
Init(Vec<Workspace>),
|
||||
Add(Workspace),
|
||||
Remove(String),
|
||||
Update(Workspace),
|
||||
Move(Workspace),
|
||||
/// Declares focus moved from the old workspace to the new.
|
||||
Focus {
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub trait WorkspaceClient {
|
||||
/// Requests the workspace with this name is focused.
|
||||
fn focus(&self, name: String) -> Result<()>;
|
||||
|
||||
/// Creates a new to workspace event receiver.
|
||||
fn subscribe_workspace_change(&self) -> broadcast::Receiver<WorkspaceUpdate>;
|
||||
}
|
||||
159
src/clients/compositor/sway.rs
Normal file
159
src/clients/compositor/sway.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
use super::{Workspace, WorkspaceClient, WorkspaceUpdate};
|
||||
use crate::{await_sync, send};
|
||||
use async_once::AsyncOnce;
|
||||
use color_eyre::Report;
|
||||
use futures_util::StreamExt;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
use swayipc_async::{Connection, Event, EventType, Node, WorkspaceChange, WorkspaceEvent};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::broadcast::{channel, Receiver, Sender};
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{info, trace};
|
||||
|
||||
pub struct SwayEventClient {
|
||||
workspace_tx: Sender<WorkspaceUpdate>,
|
||||
_workspace_rx: Receiver<WorkspaceUpdate>,
|
||||
}
|
||||
|
||||
impl SwayEventClient {
|
||||
fn new() -> Self {
|
||||
let (workspace_tx, workspace_rx) = channel(16);
|
||||
|
||||
{
|
||||
let workspace_tx = workspace_tx.clone();
|
||||
spawn(async move {
|
||||
let client = Connection::new().await?;
|
||||
info!("Sway IPC subscription client connected");
|
||||
|
||||
let event_types = [EventType::Workspace];
|
||||
|
||||
let mut events = client.subscribe(event_types).await?;
|
||||
|
||||
while let Some(event) = events.next().await {
|
||||
trace!("event: {:?}", event);
|
||||
if let Event::Workspace(ev) = event? {
|
||||
workspace_tx.send(WorkspaceUpdate::from(*ev))?;
|
||||
};
|
||||
}
|
||||
|
||||
Ok::<(), Report>(())
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
workspace_tx,
|
||||
_workspace_rx: workspace_rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WorkspaceClient for SwayEventClient {
|
||||
fn focus(&self, id: String) -> color_eyre::Result<()> {
|
||||
await_sync(async move {
|
||||
let client = get_client().await;
|
||||
let mut client = client.lock().await;
|
||||
client.run_command(format!("workspace {id}")).await
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_workspace_change(&self) -> Receiver<WorkspaceUpdate> {
|
||||
let rx = self.workspace_tx.subscribe();
|
||||
|
||||
{
|
||||
let tx = self.workspace_tx.clone();
|
||||
await_sync(async {
|
||||
let client = get_client().await;
|
||||
let mut client = client.lock().await;
|
||||
|
||||
let workspaces = client
|
||||
.get_workspaces()
|
||||
.await
|
||||
.expect("Failed to get workspaces");
|
||||
let event =
|
||||
WorkspaceUpdate::Init(workspaces.into_iter().map(Workspace::from).collect());
|
||||
|
||||
send!(tx, event);
|
||||
});
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: AsyncOnce<Arc<Mutex<Connection>>> = AsyncOnce::new(async {
|
||||
let client = Connection::new()
|
||||
.await
|
||||
.expect("Failed to connect to Sway socket");
|
||||
Arc::new(Mutex::new(client))
|
||||
});
|
||||
static ref SUB_CLIENT: SwayEventClient = SwayEventClient::new();
|
||||
}
|
||||
|
||||
/// Gets the sway IPC client
|
||||
async fn get_client() -> Arc<Mutex<Connection>> {
|
||||
let client = CLIENT.get().await;
|
||||
Arc::clone(client)
|
||||
}
|
||||
|
||||
/// Gets the sway IPC event subscription client
|
||||
pub fn get_sub_client() -> &'static SwayEventClient {
|
||||
&SUB_CLIENT
|
||||
}
|
||||
|
||||
impl From<Node> for Workspace {
|
||||
fn from(node: Node) -> Self {
|
||||
Self {
|
||||
id: node.id.to_string(),
|
||||
name: node.name.unwrap_or_default(),
|
||||
monitor: node.output.unwrap_or_default(),
|
||||
focused: node.focused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<swayipc_async::Workspace> for Workspace {
|
||||
fn from(workspace: swayipc_async::Workspace) -> Self {
|
||||
Self {
|
||||
id: workspace.id.to_string(),
|
||||
name: workspace.name,
|
||||
monitor: workspace.output,
|
||||
focused: workspace.focused,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<WorkspaceEvent> for WorkspaceUpdate {
|
||||
fn from(event: WorkspaceEvent) -> Self {
|
||||
match event.change {
|
||||
WorkspaceChange::Init => {
|
||||
Self::Add(event.current.expect("Missing current workspace").into())
|
||||
}
|
||||
WorkspaceChange::Empty => Self::Remove(
|
||||
event
|
||||
.current
|
||||
.expect("Missing current workspace")
|
||||
.name
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
WorkspaceChange::Focus => Self::Focus {
|
||||
old: event
|
||||
.old
|
||||
.expect("Missing old workspace")
|
||||
.name
|
||||
.unwrap_or_default(),
|
||||
new: event
|
||||
.current
|
||||
.expect("Missing current workspace")
|
||||
.name
|
||||
.unwrap_or_default(),
|
||||
},
|
||||
WorkspaceChange::Move => {
|
||||
Self::Move(event.current.expect("Missing current workspace").into())
|
||||
}
|
||||
_ => Self::Update(event.current.expect("Missing current workspace").into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/clients/mod.rs
Normal file
11
src/clients/mod.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
#[cfg(feature = "clipboard")]
|
||||
pub mod clipboard;
|
||||
#[cfg(feature = "workspaces")]
|
||||
pub mod compositor;
|
||||
#[cfg(feature = "music")]
|
||||
pub mod music;
|
||||
#[cfg(feature = "tray")]
|
||||
pub mod system_tray;
|
||||
#[cfg(feature = "upower")]
|
||||
pub mod upower;
|
||||
pub mod wayland;
|
||||
72
src/clients/music/mod.rs
Normal file
72
src/clients/music/mod.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
use color_eyre::Result;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
#[cfg(feature = "music+mpd")]
|
||||
pub mod mpd;
|
||||
#[cfg(feature = "music+mpris")]
|
||||
pub mod mpris;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlayerUpdate {
|
||||
Update(Box<Option<Track>>, Status),
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Track {
|
||||
pub title: Option<String>,
|
||||
pub album: Option<String>,
|
||||
pub artist: Option<String>,
|
||||
pub date: Option<String>,
|
||||
pub disc: Option<u64>,
|
||||
pub genre: Option<String>,
|
||||
pub track: Option<u64>,
|
||||
pub cover_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub enum PlayerState {
|
||||
Playing,
|
||||
Paused,
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Status {
|
||||
pub state: PlayerState,
|
||||
pub volume_percent: u8,
|
||||
pub duration: Option<Duration>,
|
||||
pub elapsed: Option<Duration>,
|
||||
pub playlist_position: u32,
|
||||
pub playlist_length: u32,
|
||||
}
|
||||
|
||||
pub trait MusicClient {
|
||||
fn play(&self) -> Result<()>;
|
||||
fn pause(&self) -> Result<()>;
|
||||
fn next(&self) -> Result<()>;
|
||||
fn prev(&self) -> Result<()>;
|
||||
|
||||
fn set_volume_percent(&self, vol: u8) -> Result<()>;
|
||||
|
||||
fn subscribe_change(&self) -> broadcast::Receiver<PlayerUpdate>;
|
||||
}
|
||||
|
||||
pub enum ClientType<'a> {
|
||||
Mpd { host: &'a str, music_dir: PathBuf },
|
||||
Mpris,
|
||||
}
|
||||
|
||||
pub async fn get_client(client_type: ClientType<'_>) -> Box<Arc<dyn MusicClient>> {
|
||||
match client_type {
|
||||
ClientType::Mpd { host, music_dir } => Box::new(
|
||||
mpd::get_client(host, music_dir)
|
||||
.await
|
||||
.expect("Failed to connect to MPD client"),
|
||||
),
|
||||
ClientType::Mpris => Box::new(mpris::get_client()),
|
||||
}
|
||||
}
|
||||
311
src/clients/music/mpd.rs
Normal file
311
src/clients/music/mpd.rs
Normal file
@@ -0,0 +1,311 @@
|
||||
use super::{MusicClient, Status, Track};
|
||||
use crate::await_sync;
|
||||
use crate::clients::music::{PlayerState, PlayerUpdate};
|
||||
use color_eyre::Result;
|
||||
use lazy_static::lazy_static;
|
||||
use mpd_client::client::{Connection, ConnectionEvent, Subsystem};
|
||||
use mpd_client::protocol::MpdProtocolError;
|
||||
use mpd_client::responses::{PlayState, Song};
|
||||
use mpd_client::tag::Tag;
|
||||
use mpd_client::{commands, Client};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::os::unix::fs::FileTypeExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::{TcpStream, UnixStream};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::broadcast::{channel, error::SendError, Receiver, Sender};
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::sleep;
|
||||
use tracing::{debug, error, info};
|
||||
|
||||
lazy_static! {
|
||||
static ref CONNECTIONS: Arc<Mutex<HashMap<String, Arc<MpdClient>>>> =
|
||||
Arc::new(Mutex::new(HashMap::new()));
|
||||
}
|
||||
|
||||
pub struct MpdClient {
|
||||
client: Client,
|
||||
music_dir: PathBuf,
|
||||
tx: Sender<PlayerUpdate>,
|
||||
_rx: Receiver<PlayerUpdate>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum MpdConnectionError {
|
||||
MaxRetries,
|
||||
ProtocolError(MpdProtocolError),
|
||||
}
|
||||
|
||||
impl Display for MpdConnectionError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MaxRetries => write!(f, "Reached max retries"),
|
||||
Self::ProtocolError(e) => write!(f, "{e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MpdConnectionError {}
|
||||
|
||||
impl MpdClient {
|
||||
async fn new(host: &str, music_dir: PathBuf) -> Result<Self, MpdConnectionError> {
|
||||
debug!("Creating new MPD connection to {}", host);
|
||||
|
||||
let (client, mut state_changes) =
|
||||
wait_for_connection(host, Duration::from_secs(5), None).await?;
|
||||
|
||||
let (tx, rx) = channel(16);
|
||||
|
||||
{
|
||||
let music_dir = music_dir.clone();
|
||||
let tx = tx.clone();
|
||||
let client = client.clone();
|
||||
|
||||
spawn(async move {
|
||||
while let Some(change) = state_changes.next().await {
|
||||
debug!("Received state change: {:?}", change);
|
||||
|
||||
if let ConnectionEvent::SubsystemChange(
|
||||
Subsystem::Player | Subsystem::Queue | Subsystem::Mixer,
|
||||
) = change
|
||||
{
|
||||
Self::send_update(&client, &tx, &music_dir)
|
||||
.await
|
||||
.expect("Failed to send update");
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), SendError<(Option<Track>, Status)>>(())
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
client,
|
||||
music_dir,
|
||||
tx,
|
||||
_rx: rx,
|
||||
})
|
||||
}
|
||||
|
||||
async fn send_update(
|
||||
client: &Client,
|
||||
tx: &Sender<PlayerUpdate>,
|
||||
music_dir: &Path,
|
||||
) -> Result<(), SendError<PlayerUpdate>> {
|
||||
let current_song = client.command(commands::CurrentSong).await;
|
||||
let status = client.command(commands::Status).await;
|
||||
|
||||
if let (Ok(current_song), Ok(status)) = (current_song, status) {
|
||||
let track = current_song.map(|s| Self::convert_song(&s.song, music_dir));
|
||||
let status = Status::from(status);
|
||||
|
||||
tx.send(PlayerUpdate::Update(Box::new(track), status))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
!self.client.is_connection_closed()
|
||||
}
|
||||
|
||||
fn send_disconnect_update(&self) -> Result<(), SendError<PlayerUpdate>> {
|
||||
info!("Connection to MPD server lost");
|
||||
self.tx.send(PlayerUpdate::Disconnect)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn convert_song(song: &Song, music_dir: &Path) -> Track {
|
||||
let (track, disc) = song.number();
|
||||
|
||||
let cover_path = music_dir
|
||||
.join(
|
||||
song.file_path()
|
||||
.parent()
|
||||
.expect("Song path should not be root")
|
||||
.join("cover.jpg"),
|
||||
)
|
||||
.into_os_string()
|
||||
.into_string()
|
||||
.ok();
|
||||
|
||||
Track {
|
||||
title: song.title().map(std::string::ToString::to_string),
|
||||
album: song.album().map(std::string::ToString::to_string),
|
||||
artist: Some(song.artists().join(", ")),
|
||||
date: try_get_first_tag(song, &Tag::Date).map(std::string::ToString::to_string),
|
||||
genre: try_get_first_tag(song, &Tag::Genre).map(std::string::ToString::to_string),
|
||||
disc: Some(disc),
|
||||
track: Some(track),
|
||||
cover_path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! async_command {
|
||||
($client:expr, $command:expr) => {
|
||||
await_sync(async {
|
||||
$client
|
||||
.command($command)
|
||||
.await
|
||||
.unwrap_or_else(|err| error!("Failed to send command: {err:?}"))
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
impl MusicClient for MpdClient {
|
||||
fn play(&self) -> Result<()> {
|
||||
async_command!(self.client, commands::SetPause(false));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
async_command!(self.client, commands::SetPause(true));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next(&self) -> Result<()> {
|
||||
async_command!(self.client, commands::Next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prev(&self) -> Result<()> {
|
||||
async_command!(self.client, commands::Previous);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume_percent(&self, vol: u8) -> Result<()> {
|
||||
async_command!(self.client, commands::SetVolume(vol));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_change(&self) -> Receiver<PlayerUpdate> {
|
||||
let rx = self.tx.subscribe();
|
||||
await_sync(async {
|
||||
Self::send_update(&self.client, &self.tx, &self.music_dir)
|
||||
.await
|
||||
.expect("Failed to send player update");
|
||||
});
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_client(
|
||||
host: &str,
|
||||
music_dir: PathBuf,
|
||||
) -> Result<Arc<MpdClient>, MpdConnectionError> {
|
||||
let mut connections = CONNECTIONS.lock().await;
|
||||
match connections.get(host) {
|
||||
None => {
|
||||
let client = MpdClient::new(host, music_dir).await?;
|
||||
let client = Arc::new(client);
|
||||
connections.insert(host.to_string(), Arc::clone(&client));
|
||||
Ok(client)
|
||||
}
|
||||
Some(client) => {
|
||||
if client.is_connected() {
|
||||
Ok(Arc::clone(client))
|
||||
} else {
|
||||
client
|
||||
.send_disconnect_update()
|
||||
.expect("Failed to send disconnect update");
|
||||
|
||||
let client = MpdClient::new(host, music_dir).await?;
|
||||
let client = Arc::new(client);
|
||||
connections.insert(host.to_string(), Arc::clone(&client));
|
||||
Ok(client)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_connection(
|
||||
host: &str,
|
||||
interval: Duration,
|
||||
max_retries: Option<usize>,
|
||||
) -> Result<Connection, MpdConnectionError> {
|
||||
let mut retries = 0;
|
||||
let max_retries = max_retries.unwrap_or(usize::MAX);
|
||||
|
||||
loop {
|
||||
if retries == max_retries {
|
||||
break Err(MpdConnectionError::MaxRetries);
|
||||
}
|
||||
|
||||
retries += 1;
|
||||
|
||||
match try_get_mpd_conn(host).await {
|
||||
Ok(conn) => break Ok(conn),
|
||||
Err(err) => {
|
||||
if retries == max_retries {
|
||||
break Err(MpdConnectionError::ProtocolError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sleep(interval).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Cycles through each MPD host and
|
||||
/// returns the first one which connects,
|
||||
/// or none if there are none
|
||||
async fn try_get_mpd_conn(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
if is_unix_socket(host) {
|
||||
connect_unix(host).await
|
||||
} else {
|
||||
connect_tcp(host).await
|
||||
}
|
||||
}
|
||||
|
||||
fn is_unix_socket(host: &str) -> bool {
|
||||
let path = PathBuf::from(host);
|
||||
path.exists()
|
||||
&& path
|
||||
.metadata()
|
||||
.map_or(false, |metadata| metadata.file_type().is_socket())
|
||||
}
|
||||
|
||||
async fn connect_unix(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = UnixStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
async fn connect_tcp(host: &str) -> Result<Connection, MpdProtocolError> {
|
||||
let connection = TcpStream::connect(host).await?;
|
||||
Client::connect(connection).await
|
||||
}
|
||||
|
||||
/// Attempts to read the first value for a tag
|
||||
/// (since the MPD client returns a vector of tags, or None)
|
||||
pub fn try_get_first_tag<'a>(song: &'a Song, tag: &'a Tag) -> Option<&'a str> {
|
||||
song.tags
|
||||
.get(tag)
|
||||
.and_then(|vec| vec.first().map(String::as_str))
|
||||
}
|
||||
|
||||
impl From<mpd_client::responses::Status> for Status {
|
||||
fn from(status: mpd_client::responses::Status) -> Self {
|
||||
Self {
|
||||
state: PlayerState::from(status.state),
|
||||
volume_percent: status.volume,
|
||||
duration: status.duration,
|
||||
elapsed: status.elapsed,
|
||||
playlist_position: status.current_song.map_or(0, |(pos, _)| pos.0 as u32),
|
||||
playlist_length: status.playlist_length as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PlayState> for PlayerState {
|
||||
fn from(value: PlayState) -> Self {
|
||||
match value {
|
||||
PlayState::Stopped => Self::Stopped,
|
||||
PlayState::Playing => Self::Playing,
|
||||
PlayState::Paused => Self::Paused,
|
||||
}
|
||||
}
|
||||
}
|
||||
275
src/clients/music/mpris.rs
Normal file
275
src/clients/music/mpris.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
use super::{MusicClient, PlayerUpdate, Status, Track};
|
||||
use crate::clients::music::PlayerState;
|
||||
use crate::{lock, send};
|
||||
use color_eyre::Result;
|
||||
use lazy_static::lazy_static;
|
||||
use mpris::{DBusError, Event, Metadata, PlaybackStatus, Player, PlayerFinder};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread::sleep;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::broadcast::{channel, Receiver, Sender};
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: Arc<Client> = Arc::new(Client::new());
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
current_player: Arc<Mutex<Option<String>>>,
|
||||
tx: Sender<PlayerUpdate>,
|
||||
_rx: Receiver<PlayerUpdate>,
|
||||
}
|
||||
|
||||
impl Client {
|
||||
fn new() -> Self {
|
||||
let (tx, rx) = channel(32);
|
||||
|
||||
let current_player = Arc::new(Mutex::new(None));
|
||||
|
||||
{
|
||||
let players_list = Arc::new(Mutex::new(HashSet::new()));
|
||||
let current_player = current_player.clone();
|
||||
let tx = tx.clone();
|
||||
|
||||
spawn_blocking(move || {
|
||||
let player_finder = PlayerFinder::new().expect("Failed to connect to D-Bus");
|
||||
|
||||
// D-Bus gives no event for new players,
|
||||
// so we have to keep polling the player list
|
||||
loop {
|
||||
let players = player_finder
|
||||
.find_all()
|
||||
.expect("Failed to connect to D-Bus");
|
||||
|
||||
let mut players_list_val = lock!(players_list);
|
||||
for player in players {
|
||||
let identity = player.identity();
|
||||
|
||||
if !players_list_val.contains(identity) {
|
||||
debug!("Adding MPRIS player '{identity}'");
|
||||
players_list_val.insert(identity.to_string());
|
||||
|
||||
let status = player
|
||||
.get_playback_status()
|
||||
.expect("Failed to connect to D-Bus");
|
||||
|
||||
{
|
||||
let mut current_player = lock!(current_player);
|
||||
|
||||
if status == PlaybackStatus::Playing || current_player.is_none() {
|
||||
debug!("Setting active player to '{identity}'");
|
||||
|
||||
current_player.replace(identity.to_string());
|
||||
if let Err(err) = Self::send_update(&player, &tx) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Self::listen_player_events(
|
||||
identity.to_string(),
|
||||
players_list.clone(),
|
||||
current_player.clone(),
|
||||
tx.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// wait 1 second before re-checking players
|
||||
sleep(Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
current_player,
|
||||
tx,
|
||||
_rx: rx,
|
||||
}
|
||||
}
|
||||
|
||||
fn listen_player_events(
|
||||
player_id: String,
|
||||
players: Arc<Mutex<HashSet<String>>>,
|
||||
current_player: Arc<Mutex<Option<String>>>,
|
||||
tx: Sender<PlayerUpdate>,
|
||||
) {
|
||||
spawn_blocking(move || {
|
||||
let player_finder = PlayerFinder::new()?;
|
||||
|
||||
if let Ok(player) = player_finder.find_by_name(&player_id) {
|
||||
let identity = player.identity();
|
||||
|
||||
for event in player.events()? {
|
||||
trace!("Received player event from '{identity}': {event:?}");
|
||||
match event {
|
||||
Ok(Event::PlayerShutDown) => {
|
||||
lock!(current_player).take();
|
||||
lock!(players).remove(identity);
|
||||
break;
|
||||
}
|
||||
Ok(Event::Playing) => {
|
||||
lock!(current_player).replace(identity.to_string());
|
||||
|
||||
if let Err(err) = Self::send_update(&player, &tx) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
Ok(_) => {
|
||||
let current_player = lock!(current_player);
|
||||
let current_player = current_player.as_ref();
|
||||
if let Some(current_player) = current_player {
|
||||
if current_player == identity {
|
||||
if let Err(err) = Self::send_update(&player, &tx) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => error!("{err:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), DBusError>(())
|
||||
});
|
||||
}
|
||||
|
||||
fn send_update(player: &Player, tx: &Sender<PlayerUpdate>) -> Result<()> {
|
||||
debug!("Sending update using '{}'", player.identity());
|
||||
|
||||
let metadata = player.get_metadata()?;
|
||||
let playback_status = player
|
||||
.get_playback_status()
|
||||
.unwrap_or(PlaybackStatus::Stopped);
|
||||
|
||||
let track_list = player.get_track_list();
|
||||
|
||||
let volume_percent = player
|
||||
.get_volume()
|
||||
.map(|vol| (vol * 100.0) as u8)
|
||||
.unwrap_or(0);
|
||||
|
||||
let status = Status {
|
||||
// MRPIS doesn't seem to provide playlist info reliably,
|
||||
// so we can just assume next/prev will work by bodging the numbers
|
||||
playlist_position: 1,
|
||||
playlist_length: track_list.map(|list| list.len() as u32).unwrap_or(u32::MAX),
|
||||
state: PlayerState::from(playback_status),
|
||||
elapsed: player.get_position().ok(),
|
||||
duration: metadata.length(),
|
||||
volume_percent,
|
||||
};
|
||||
|
||||
let track = Track::from(metadata);
|
||||
|
||||
let player_update = PlayerUpdate::Update(Box::new(Some(track)), status);
|
||||
send!(tx, player_update);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_player(&self) -> Option<Player> {
|
||||
let player_name = lock!(self.current_player);
|
||||
let player_name = player_name.as_ref();
|
||||
|
||||
player_name.and_then(|player_name| {
|
||||
let player_finder = PlayerFinder::new().expect("Failed to connect to D-Bus");
|
||||
player_finder.find_by_name(player_name).ok()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! command {
|
||||
($self:ident, $func:ident) => {
|
||||
if let Some(player) = Self::get_player($self) {
|
||||
player.$func()?;
|
||||
} else {
|
||||
error!("Could not find player");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
impl MusicClient for Client {
|
||||
fn play(&self) -> Result<()> {
|
||||
command!(self, play);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pause(&self) -> Result<()> {
|
||||
command!(self, pause);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn next(&self) -> Result<()> {
|
||||
command!(self, next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prev(&self) -> Result<()> {
|
||||
command!(self, previous);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_volume_percent(&self, vol: u8) -> Result<()> {
|
||||
if let Some(player) = Self::get_player(self) {
|
||||
player.set_volume(vol as f64 / 100.0)?;
|
||||
} else {
|
||||
error!("Could not find player");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn subscribe_change(&self) -> Receiver<PlayerUpdate> {
|
||||
debug!("Creating new subscription");
|
||||
let rx = self.tx.subscribe();
|
||||
|
||||
if let Some(player) = self.get_player() {
|
||||
if let Err(err) = Self::send_update(&player, &self.tx) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
rx
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_client() -> Arc<Client> {
|
||||
CLIENT.clone()
|
||||
}
|
||||
|
||||
impl From<Metadata> for Track {
|
||||
fn from(value: Metadata) -> Self {
|
||||
const KEY_DATE: &str = "xesam:contentCreated";
|
||||
const KEY_GENRE: &str = "xesam:genre";
|
||||
|
||||
Self {
|
||||
title: value.title().map(std::string::ToString::to_string),
|
||||
album: value.album_name().map(std::string::ToString::to_string),
|
||||
artist: value.artists().map(|artists| artists.join(", ")),
|
||||
date: value
|
||||
.get(KEY_DATE)
|
||||
.and_then(mpris::MetadataValue::as_string)
|
||||
.map(std::string::ToString::to_string),
|
||||
disc: value.disc_number().map(|disc| disc as u64),
|
||||
genre: value
|
||||
.get(KEY_GENRE)
|
||||
.and_then(mpris::MetadataValue::as_str_array)
|
||||
.and_then(|arr| arr.first().map(|val| (*val).to_string())),
|
||||
track: value.track_number().map(|track| track as u64),
|
||||
cover_path: value.art_url().map(|s| s.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PlaybackStatus> for PlayerState {
|
||||
fn from(value: PlaybackStatus) -> Self {
|
||||
match value {
|
||||
PlaybackStatus::Playing => Self::Playing,
|
||||
PlaybackStatus::Paused => Self::Paused,
|
||||
PlaybackStatus::Stopped => Self::Stopped,
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/clients/system_tray.rs
Normal file
124
src/clients/system_tray.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use crate::{lock, send};
|
||||
use async_once::AsyncOnce;
|
||||
use color_eyre::Report;
|
||||
use lazy_static::lazy_static;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use stray::message::menu::TrayMenu;
|
||||
use stray::message::tray::StatusNotifierItem;
|
||||
use stray::message::{NotifierItemCommand, NotifierItemMessage};
|
||||
use stray::StatusNotifierWatcher;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{debug, error, trace};
|
||||
|
||||
type Tray = BTreeMap<String, (Box<StatusNotifierItem>, Option<TrayMenu>)>;
|
||||
|
||||
pub struct TrayEventReceiver {
|
||||
tx: mpsc::Sender<NotifierItemCommand>,
|
||||
b_tx: broadcast::Sender<NotifierItemMessage>,
|
||||
_b_rx: broadcast::Receiver<NotifierItemMessage>,
|
||||
|
||||
tray: Arc<Mutex<Tray>>,
|
||||
}
|
||||
|
||||
impl TrayEventReceiver {
|
||||
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 tray = Arc::new(Mutex::new(BTreeMap::new()));
|
||||
|
||||
{
|
||||
let b_tx = b_tx.clone();
|
||||
let tray = tray.clone();
|
||||
|
||||
spawn(async move {
|
||||
while let Ok(message) = host.recv().await {
|
||||
trace!("Received message: {message:?} ");
|
||||
|
||||
send!(b_tx, message.clone());
|
||||
let mut tray = lock!(tray);
|
||||
match message {
|
||||
NotifierItemMessage::Update {
|
||||
address,
|
||||
item,
|
||||
menu,
|
||||
} => {
|
||||
debug!("Adding item with address '{address}'");
|
||||
tray.insert(address, (item, menu));
|
||||
}
|
||||
NotifierItemMessage::Remove { address } => {
|
||||
debug!("Removing item with address '{address}'");
|
||||
tray.remove(&address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), broadcast::error::SendError<NotifierItemMessage>>(())
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
tx,
|
||||
b_tx,
|
||||
_b_rx: b_rx,
|
||||
tray,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe(
|
||||
&self,
|
||||
) -> (
|
||||
mpsc::Sender<NotifierItemCommand>,
|
||||
broadcast::Receiver<NotifierItemMessage>,
|
||||
) {
|
||||
let tx = self.tx.clone();
|
||||
let b_rx = self.b_tx.subscribe();
|
||||
|
||||
let tray = lock!(self.tray).clone();
|
||||
for (address, (item, menu)) in tray {
|
||||
let update = NotifierItemMessage::Update {
|
||||
address,
|
||||
item,
|
||||
menu,
|
||||
};
|
||||
send!(self.b_tx, update);
|
||||
}
|
||||
|
||||
(tx, b_rx)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
match tray {
|
||||
Ok(tray) => break Some(tray),
|
||||
Err(err) => error!("{:?}", Report::new(err).wrap_err(format!("Failed to create StatusNotifierWatcher (attempt {retries})")))
|
||||
}
|
||||
|
||||
if retries == MAX_RETRIES {
|
||||
break None;
|
||||
}
|
||||
};
|
||||
|
||||
value.expect("Failed to create StatusNotifierWatcher")
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_tray_event_client() -> &'static TrayEventReceiver {
|
||||
CLIENT.get().await
|
||||
}
|
||||
40
src/clients/upower.rs
Normal file
40
src/clients/upower.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use async_once::AsyncOnce;
|
||||
use lazy_static::lazy_static;
|
||||
use std::sync::Arc;
|
||||
use upower_dbus::UPowerProxy;
|
||||
use zbus::fdo::PropertiesProxy;
|
||||
|
||||
lazy_static! {
|
||||
static ref DISPLAY_PROXY: AsyncOnce<Arc<PropertiesProxy<'static>>> = AsyncOnce::new(async {
|
||||
let dbus = zbus::Connection::system()
|
||||
.await
|
||||
.expect("failed to create connection to system bus");
|
||||
|
||||
let device_proxy = UPowerProxy::new(&dbus)
|
||||
.await
|
||||
.expect("failed to create upower proxy");
|
||||
|
||||
let display_device = device_proxy
|
||||
.get_display_device()
|
||||
.await
|
||||
.unwrap_or_else(|_| panic!("failed to get display device for {device_proxy:?}"));
|
||||
|
||||
let path = display_device.path().to_owned();
|
||||
|
||||
let proxy = PropertiesProxy::builder(&dbus)
|
||||
.destination("org.freedesktop.UPower")
|
||||
.expect("failed to set proxy destination address")
|
||||
.path(path)
|
||||
.expect("failed to set proxy path")
|
||||
.cache_properties(zbus::CacheProperties::No)
|
||||
.build()
|
||||
.await
|
||||
.expect("failed to build proxy");
|
||||
|
||||
Arc::new(proxy)
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn get_display_proxy() -> &'static PropertiesProxy<'static> {
|
||||
DISPLAY_PROXY.get().await
|
||||
}
|
||||
287
src/clients/wayland/client.rs
Normal file
287
src/clients/wayland/client.rs
Normal file
@@ -0,0 +1,287 @@
|
||||
use super::wlr_foreign_toplevel::{
|
||||
handle::{ToplevelEvent, ToplevelInfo},
|
||||
manager::listen_for_toplevels,
|
||||
};
|
||||
use super::{DData, Env, ToplevelHandler};
|
||||
use crate::{error as err, send};
|
||||
use cfg_if::cfg_if;
|
||||
use color_eyre::Report;
|
||||
use indexmap::IndexMap;
|
||||
use smithay_client_toolkit::environment::Environment;
|
||||
use smithay_client_toolkit::output::{with_output_info, OutputInfo};
|
||||
use smithay_client_toolkit::reexports::calloop::channel::{channel, Event, Sender};
|
||||
use smithay_client_toolkit::reexports::calloop::EventLoop;
|
||||
use smithay_client_toolkit::WaylandSource;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::sync::{broadcast, oneshot};
|
||||
use tokio::task::spawn_blocking;
|
||||
use tracing::{debug, error};
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::{ConnectError, Display, EventQueue};
|
||||
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::{
|
||||
zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1,
|
||||
zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1,
|
||||
};
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "clipboard")] {
|
||||
use super::{ClipboardItem};
|
||||
use super::wlr_data_control::manager::{listen_to_devices, DataControlDeviceHandler};
|
||||
use crate::{read_lock, write_lock};
|
||||
use tokio::spawn;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum Request {
|
||||
/// Copies the value to the clipboard
|
||||
#[cfg(feature = "clipboard")]
|
||||
CopyToClipboard(Arc<ClipboardItem>),
|
||||
/// Forces a dispatch, flushing any currently queued events
|
||||
Refresh,
|
||||
}
|
||||
|
||||
pub struct WaylandClient {
|
||||
pub outputs: Vec<OutputInfo>,
|
||||
pub seats: Vec<WlSeat>,
|
||||
|
||||
pub toplevels: Arc<RwLock<IndexMap<usize, (ToplevelInfo, ZwlrForeignToplevelHandleV1)>>>,
|
||||
toplevel_tx: broadcast::Sender<ToplevelEvent>,
|
||||
_toplevel_rx: broadcast::Receiver<ToplevelEvent>,
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
clipboard_tx: broadcast::Sender<Arc<ClipboardItem>>,
|
||||
#[cfg(feature = "clipboard")]
|
||||
clipboard: Arc<RwLock<Option<Arc<ClipboardItem>>>>,
|
||||
|
||||
request_tx: Sender<Request>,
|
||||
}
|
||||
|
||||
impl WaylandClient {
|
||||
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 toplevels = Arc::new(RwLock::new(IndexMap::new()));
|
||||
let toplevels2 = toplevels.clone();
|
||||
|
||||
let toplevel_tx2 = toplevel_tx.clone();
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "clipboard")] {
|
||||
let (clipboard_tx, mut clipboard_rx) = broadcast::channel(32);
|
||||
let clipboard = Arc::new(RwLock::new(None));
|
||||
let clipboard_tx2 = clipboard_tx.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let (ev_tx, ev_rx) = channel::<Request>();
|
||||
|
||||
// `queue` is not `Send` so we need to handle everything inside the task
|
||||
spawn_blocking(move || {
|
||||
let toplevels = toplevels2;
|
||||
let toplevel_tx = toplevel_tx2;
|
||||
|
||||
let (env, _display, queue) =
|
||||
Self::new_environment().expect("Failed to connect to Wayland compositor");
|
||||
|
||||
let mut event_loop =
|
||||
EventLoop::<DData>::try_new().expect("Failed to create new event loop");
|
||||
WaylandSource::new(queue)
|
||||
.quick_insert(event_loop.handle())
|
||||
.expect("Failed to insert Wayland event queue into event loop");
|
||||
|
||||
let outputs = Self::get_outputs(&env);
|
||||
send!(output_tx, outputs);
|
||||
|
||||
let seats = env.get_all_seats();
|
||||
|
||||
// TODO: Actually handle seats properly
|
||||
#[cfg(feature = "clipboard")]
|
||||
let default_seat = seats[0].detach();
|
||||
|
||||
send!(
|
||||
seat_tx,
|
||||
seats
|
||||
.into_iter()
|
||||
.map(|seat| seat.detach())
|
||||
.collect::<Vec<WlSeat>>()
|
||||
);
|
||||
|
||||
let handle = event_loop.handle();
|
||||
handle
|
||||
.insert_source(ev_rx, move |event, _metadata, ddata| {
|
||||
// let env = &ddata.env;
|
||||
match event {
|
||||
Event::Msg(Request::Refresh) => debug!("Received refresh event"),
|
||||
#[cfg(feature = "clipboard")]
|
||||
Event::Msg(Request::CopyToClipboard(value)) => {
|
||||
super::wlr_data_control::copy_to_clipboard(
|
||||
&ddata.env,
|
||||
&default_seat,
|
||||
&value,
|
||||
)
|
||||
.expect("Failed to copy to clipboard");
|
||||
}
|
||||
Event::Closed => panic!("Channel unexpectedly closed"),
|
||||
}
|
||||
})
|
||||
.expect("Failed to insert channel into event queue");
|
||||
|
||||
let _toplevel_manager = env.require_global::<ZwlrForeignToplevelManagerV1>();
|
||||
|
||||
let _toplevel_listener = listen_for_toplevels(&env, move |handle, event, _ddata| {
|
||||
super::wlr_foreign_toplevel::update_toplevels(
|
||||
&toplevels,
|
||||
handle,
|
||||
event,
|
||||
&toplevel_tx,
|
||||
);
|
||||
});
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "clipboard")] {
|
||||
let clipboard_tx = clipboard_tx2;
|
||||
let handle = event_loop.handle();
|
||||
|
||||
let _offer_listener = listen_to_devices(&env, move |_seat, event, ddata| {
|
||||
debug!("Received clipboard event");
|
||||
super::wlr_data_control::receive_offer(event, &handle, clipboard_tx.clone(), ddata);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut data = DData {
|
||||
env,
|
||||
offer_tokens: HashMap::new(),
|
||||
};
|
||||
|
||||
loop {
|
||||
if let Err(err) = event_loop.dispatch(None, &mut data) {
|
||||
error!(
|
||||
"{:?}",
|
||||
Report::new(err).wrap_err("Failed to dispatch pending wayland events")
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// keep track of current clipboard item
|
||||
#[cfg(feature = "clipboard")]
|
||||
{
|
||||
let clipboard = clipboard.clone();
|
||||
spawn(async move {
|
||||
while let Ok(item) = clipboard_rx.recv().await {
|
||||
let mut clipboard = write_lock!(clipboard);
|
||||
clipboard.replace(item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let outputs = output_rx.await.expect(err::ERR_CHANNEL_RECV);
|
||||
|
||||
let seats = seat_rx.await.expect(err::ERR_CHANNEL_RECV);
|
||||
|
||||
Self {
|
||||
outputs,
|
||||
seats,
|
||||
#[cfg(feature = "clipboard")]
|
||||
clipboard,
|
||||
toplevels,
|
||||
toplevel_tx,
|
||||
_toplevel_rx: toplevel_rx,
|
||||
#[cfg(feature = "clipboard")]
|
||||
clipboard_tx,
|
||||
request_tx: ev_tx,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn subscribe_toplevels(&self) -> broadcast::Receiver<ToplevelEvent> {
|
||||
self.toplevel_tx.subscribe()
|
||||
}
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
pub fn subscribe_clipboard(&self) -> broadcast::Receiver<Arc<ClipboardItem>> {
|
||||
self.clipboard_tx.subscribe()
|
||||
}
|
||||
|
||||
pub fn roundtrip(&self) {
|
||||
send!(self.request_tx, Request::Refresh);
|
||||
}
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
pub fn get_clipboard(&self) -> Option<Arc<ClipboardItem>> {
|
||||
let clipboard = read_lock!(self.clipboard);
|
||||
clipboard.as_ref().cloned()
|
||||
}
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
pub fn copy_to_clipboard(&self, item: Arc<ClipboardItem>) {
|
||||
send!(self.request_tx, Request::CopyToClipboard(item));
|
||||
}
|
||||
|
||||
fn get_outputs(env: &Environment<Env>) -> Vec<OutputInfo> {
|
||||
let outputs = env.get_all_outputs();
|
||||
|
||||
outputs
|
||||
.iter()
|
||||
.filter_map(|output| with_output_info(output, Clone::clone))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn new_environment() -> Result<(Environment<Env>, Display, EventQueue), ConnectError> {
|
||||
Display::connect_to_env().and_then(|display| {
|
||||
let mut queue = display.create_event_queue();
|
||||
let ret = {
|
||||
let mut sctk_seats = smithay_client_toolkit::seat::SeatHandler::new();
|
||||
let sctk_data_device_manager =
|
||||
smithay_client_toolkit::data_device::DataDeviceHandler::init(&mut sctk_seats);
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
let data_control_device = DataControlDeviceHandler::init(&mut sctk_seats);
|
||||
|
||||
let sctk_primary_selection_manager =
|
||||
smithay_client_toolkit::primary_selection::PrimarySelectionHandler::init(
|
||||
&mut sctk_seats,
|
||||
);
|
||||
|
||||
let display = ::smithay_client_toolkit::reexports::client::Proxy::clone(&display);
|
||||
let env = Environment::new(
|
||||
&display.attach(queue.token()),
|
||||
&mut queue,
|
||||
Env {
|
||||
sctk_compositor: smithay_client_toolkit::environment::SimpleGlobal::new(),
|
||||
sctk_subcompositor: smithay_client_toolkit::environment::SimpleGlobal::new(
|
||||
),
|
||||
sctk_shm: smithay_client_toolkit::shm::ShmHandler::new(),
|
||||
sctk_outputs: smithay_client_toolkit::output::OutputHandler::new(),
|
||||
sctk_seats,
|
||||
sctk_data_device_manager,
|
||||
sctk_primary_selection_manager,
|
||||
toplevel: ToplevelHandler::init(),
|
||||
#[cfg(feature = "clipboard")]
|
||||
data_control_device,
|
||||
},
|
||||
);
|
||||
|
||||
if let Ok(env) = env.as_ref() {
|
||||
let _psm = env.get_primary_selection_manager();
|
||||
}
|
||||
|
||||
env
|
||||
};
|
||||
match ret {
|
||||
Ok(env) => Ok((env, display, queue)),
|
||||
Err(_e) => display.protocol_error().map_or_else(
|
||||
|| Err(ConnectError::NoCompositorListening),
|
||||
|perr| {
|
||||
panic!("[SCTK] A protocol error occured during initial setup: {perr}");
|
||||
},
|
||||
),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
75
src/clients/wayland/mod.rs
Normal file
75
src/clients/wayland/mod.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
mod client;
|
||||
|
||||
mod wlr_foreign_toplevel;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use async_once::AsyncOnce;
|
||||
use lazy_static::lazy_static;
|
||||
use std::fmt::Debug;
|
||||
use cfg_if::cfg_if;
|
||||
use smithay_client_toolkit::default_environment;
|
||||
use smithay_client_toolkit::environment::Environment;
|
||||
use smithay_client_toolkit::reexports::calloop::RegistrationToken;
|
||||
use wayland_client::{Attached, Interface};
|
||||
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_manager_v1::ZwlrForeignToplevelManagerV1;
|
||||
pub use wlr_foreign_toplevel::handle::{ToplevelChange, ToplevelEvent, ToplevelInfo};
|
||||
use wlr_foreign_toplevel::manager::{ToplevelHandler};
|
||||
|
||||
pub use client::WaylandClient;
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "clipboard")] {
|
||||
mod wlr_data_control;
|
||||
|
||||
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1;
|
||||
use wlr_data_control::manager::DataControlDeviceHandler;
|
||||
pub use wlr_data_control::{ClipboardItem, ClipboardValue};
|
||||
}
|
||||
}
|
||||
|
||||
/// A utility for lazy-loading globals.
|
||||
/// Taken from `smithay_client_toolkit` where it's not exposed
|
||||
#[derive(Debug)]
|
||||
enum LazyGlobal<I: Interface> {
|
||||
Unknown,
|
||||
Seen { id: u32, version: u32 },
|
||||
Bound(Attached<I>),
|
||||
}
|
||||
|
||||
pub struct DData {
|
||||
env: Environment<Env>,
|
||||
offer_tokens: HashMap<u128, RegistrationToken>,
|
||||
}
|
||||
|
||||
cfg_if! {
|
||||
if #[cfg(feature = "clipboard")] {
|
||||
default_environment!(Env,
|
||||
fields = [
|
||||
toplevel: ToplevelHandler,
|
||||
data_control_device: DataControlDeviceHandler
|
||||
],
|
||||
singles = [
|
||||
ZwlrForeignToplevelManagerV1 => toplevel,
|
||||
ZwlrDataControlManagerV1 => data_control_device
|
||||
],
|
||||
);
|
||||
} else {
|
||||
default_environment!(Env,
|
||||
fields = [
|
||||
toplevel: ToplevelHandler,
|
||||
],
|
||||
singles = [
|
||||
ZwlrForeignToplevelManagerV1 => toplevel,
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
static ref CLIENT: AsyncOnce<WaylandClient> =
|
||||
AsyncOnce::new(async { WaylandClient::new().await });
|
||||
}
|
||||
|
||||
pub async fn get_client() -> &'static WaylandClient {
|
||||
CLIENT.get().await
|
||||
}
|
||||
88
src/clients/wayland/wlr_data_control/device.rs
Normal file
88
src/clients/wayland/wlr_data_control/device.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use super::offer::DataControlOffer;
|
||||
use super::source::DataControlSource;
|
||||
use crate::lock;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::{Attached, DispatchData, Main};
|
||||
use wayland_protocols::wlr::unstable::data_control::v1::client::{
|
||||
zwlr_data_control_device_v1::{Event, ZwlrDataControlDeviceV1},
|
||||
zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
|
||||
zwlr_data_control_offer_v1::ZwlrDataControlOfferV1,
|
||||
};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
offer: Option<Arc<DataControlOffer>>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
fn new_offer(&mut self, offer: &Main<ZwlrDataControlOfferV1>) {
|
||||
self.offer.replace(Arc::new(DataControlOffer::new(offer)));
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataControlDeviceEvent(pub Arc<DataControlOffer>);
|
||||
|
||||
fn data_control_device_implem<F>(
|
||||
event: Event,
|
||||
inner: &mut Inner,
|
||||
implem: &mut F,
|
||||
ddata: DispatchData,
|
||||
) where
|
||||
F: FnMut(DataControlDeviceEvent, DispatchData),
|
||||
{
|
||||
match event {
|
||||
Event::DataOffer { id } => {
|
||||
inner.new_offer(&id);
|
||||
}
|
||||
Event::Selection { id: Some(offer) } => {
|
||||
let inner_offer = inner
|
||||
.offer
|
||||
.clone()
|
||||
.expect("Offer should exist at this stage");
|
||||
if offer == inner_offer.offer {
|
||||
implem(DataControlDeviceEvent(inner_offer), ddata);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DataControlDevice {
|
||||
device: ZwlrDataControlDeviceV1,
|
||||
_inner: Arc<Mutex<Inner>>,
|
||||
}
|
||||
|
||||
impl DataControlDevice {
|
||||
pub fn init_for_seat<F>(
|
||||
manager: &Attached<ZwlrDataControlManagerV1>,
|
||||
seat: &WlSeat,
|
||||
mut callback: F,
|
||||
) -> Self
|
||||
where
|
||||
F: FnMut(DataControlDeviceEvent, DispatchData) + 'static,
|
||||
{
|
||||
let inner = Arc::new(Mutex::new(Inner { offer: None }));
|
||||
|
||||
let device = manager.get_data_device(seat);
|
||||
|
||||
{
|
||||
let inner = inner.clone();
|
||||
device.quick_assign(move |_handle, event, ddata| {
|
||||
let mut inner = lock!(inner);
|
||||
data_control_device_implem(event, &mut inner, &mut callback, ddata);
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
device: device.detach(),
|
||||
_inner: inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_selection(&self, source: &Option<DataControlSource>) {
|
||||
self.device
|
||||
.set_selection(source.as_ref().map(|s| &s.source));
|
||||
}
|
||||
}
|
||||
253
src/clients/wayland/wlr_data_control/manager.rs
Normal file
253
src/clients/wayland/wlr_data_control/manager.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use super::device::{DataControlDevice, DataControlDeviceEvent};
|
||||
use super::source::DataControlSource;
|
||||
use smithay_client_toolkit::data_device::WritePipe;
|
||||
use smithay_client_toolkit::environment::{Environment, GlobalHandler};
|
||||
use smithay_client_toolkit::seat::{SeatHandling, SeatListener};
|
||||
use smithay_client_toolkit::MissingGlobal;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::{self, Rc};
|
||||
use tracing::warn;
|
||||
use wayland_client::protocol::wl_registry::WlRegistry;
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::{Attached, DispatchData};
|
||||
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_manager_v1::ZwlrDataControlManagerV1;
|
||||
|
||||
enum DataControlDeviceHandlerInner {
|
||||
Ready {
|
||||
manager: Attached<ZwlrDataControlManagerV1>,
|
||||
devices: Vec<(WlSeat, DataControlDevice)>,
|
||||
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
|
||||
},
|
||||
Pending {
|
||||
seats: Vec<WlSeat>,
|
||||
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl DataControlDeviceHandlerInner {
|
||||
fn init_manager(&mut self, manager: Attached<ZwlrDataControlManagerV1>) {
|
||||
let (seats, status_listeners) = if let Self::Pending {
|
||||
seats,
|
||||
status_listeners,
|
||||
} = self
|
||||
{
|
||||
(std::mem::take(seats), status_listeners.clone())
|
||||
} else {
|
||||
warn!("Ignoring second zwlr_data_control_manager_v1");
|
||||
return;
|
||||
};
|
||||
|
||||
let mut devices = Vec::new();
|
||||
|
||||
for seat in seats {
|
||||
let my_seat = seat.clone();
|
||||
let status_listeners = status_listeners.clone();
|
||||
let device =
|
||||
DataControlDevice::init_for_seat(&manager, &seat, move |event, dispatch_data| {
|
||||
notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners);
|
||||
});
|
||||
devices.push((seat.clone(), device));
|
||||
}
|
||||
|
||||
*self = Self::Ready {
|
||||
manager,
|
||||
devices,
|
||||
status_listeners,
|
||||
};
|
||||
}
|
||||
|
||||
fn get_manager(&self) -> Option<Attached<ZwlrDataControlManagerV1>> {
|
||||
match self {
|
||||
Self::Ready { manager, .. } => Some(manager.clone()),
|
||||
Self::Pending { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn new_seat(&mut self, seat: &WlSeat) {
|
||||
match self {
|
||||
Self::Ready {
|
||||
manager,
|
||||
devices,
|
||||
status_listeners,
|
||||
} => {
|
||||
if devices.iter().any(|(s, _)| s == seat) {
|
||||
// the seat already exists, nothing to do
|
||||
return;
|
||||
}
|
||||
let my_seat = seat.clone();
|
||||
let status_listeners = status_listeners.clone();
|
||||
let device =
|
||||
DataControlDevice::init_for_seat(manager, seat, move |event, dispatch_data| {
|
||||
notify_status_listeners(&my_seat, &event, dispatch_data, &status_listeners);
|
||||
});
|
||||
devices.push((seat.clone(), device));
|
||||
}
|
||||
Self::Pending { seats, .. } => {
|
||||
seats.push(seat.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_seat(&mut self, seat: &WlSeat) {
|
||||
match self {
|
||||
Self::Ready { devices, .. } => devices.retain(|(s, _)| s != seat),
|
||||
Self::Pending { seats, .. } => seats.retain(|s| s != seat),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
|
||||
where
|
||||
F: FnMut(String, WritePipe, DispatchData) + 'static,
|
||||
{
|
||||
match self {
|
||||
Self::Ready { manager, .. } => {
|
||||
let source = DataControlSource::new(manager, mime_types, callback);
|
||||
Some(source)
|
||||
}
|
||||
Self::Pending { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
|
||||
where
|
||||
F: FnOnce(&DataControlDevice),
|
||||
{
|
||||
match self {
|
||||
Self::Ready { devices, .. } => {
|
||||
let device = devices
|
||||
.iter()
|
||||
.find_map(|(s, device)| if s == seat { Some(device) } else { None });
|
||||
|
||||
device.map_or(Err(MissingGlobal), |device| {
|
||||
f(device);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
Self::Pending { .. } => Err(MissingGlobal),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DataControlDeviceHandler {
|
||||
inner: Rc<RefCell<DataControlDeviceHandlerInner>>,
|
||||
status_listeners: Rc<RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>>,
|
||||
_seat_listener: SeatListener,
|
||||
}
|
||||
|
||||
impl DataControlDeviceHandler {
|
||||
pub fn init<S>(seat_handler: &mut S) -> Self
|
||||
where
|
||||
S: SeatHandling,
|
||||
{
|
||||
let status_listeners = Rc::new(RefCell::new(Vec::new()));
|
||||
|
||||
let inner = Rc::new(RefCell::new(DataControlDeviceHandlerInner::Pending {
|
||||
seats: Vec::new(),
|
||||
status_listeners: status_listeners.clone(),
|
||||
}));
|
||||
|
||||
let seat_inner = inner.clone();
|
||||
let seat_listener = seat_handler.listen(move |seat, seat_data, _| {
|
||||
if seat_data.defunct {
|
||||
seat_inner.borrow_mut().remove_seat(&seat);
|
||||
} else {
|
||||
seat_inner.borrow_mut().new_seat(&seat);
|
||||
}
|
||||
});
|
||||
|
||||
Self {
|
||||
inner,
|
||||
_seat_listener: seat_listener,
|
||||
status_listeners,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl GlobalHandler<ZwlrDataControlManagerV1> for DataControlDeviceHandler {
|
||||
fn created(
|
||||
&mut self,
|
||||
registry: Attached<WlRegistry>,
|
||||
id: u32,
|
||||
version: u32,
|
||||
_ddata: DispatchData,
|
||||
) {
|
||||
// data control manager is supported until version 2
|
||||
let version = std::cmp::min(version, 2);
|
||||
|
||||
let manager = registry.bind::<ZwlrDataControlManagerV1>(version, id);
|
||||
self.inner.borrow_mut().init_manager((*manager).clone());
|
||||
}
|
||||
|
||||
fn get(&self) -> Option<Attached<ZwlrDataControlManagerV1>> {
|
||||
RefCell::borrow(&self.inner).get_manager()
|
||||
}
|
||||
}
|
||||
|
||||
type DataControlDeviceStatusCallback =
|
||||
dyn FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static;
|
||||
|
||||
/// Notifies the callbacks of an event on the data device
|
||||
fn notify_status_listeners(
|
||||
seat: &WlSeat,
|
||||
event: &DataControlDeviceEvent,
|
||||
mut ddata: DispatchData,
|
||||
listeners: &RefCell<Vec<rc::Weak<RefCell<DataControlDeviceStatusCallback>>>>,
|
||||
) {
|
||||
listeners.borrow_mut().retain(|lst| {
|
||||
rc::Weak::upgrade(lst).map_or(false, |cb| {
|
||||
(cb.borrow_mut())(seat.clone(), event.clone(), ddata.reborrow());
|
||||
true
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
pub struct DataControlDeviceStatusListener {
|
||||
_cb: Rc<RefCell<DataControlDeviceStatusCallback>>,
|
||||
}
|
||||
|
||||
pub trait DataControlDeviceHandling {
|
||||
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
|
||||
where
|
||||
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static;
|
||||
|
||||
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
|
||||
where
|
||||
F: FnOnce(&DataControlDevice);
|
||||
|
||||
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
|
||||
where
|
||||
F: FnMut(String, WritePipe, DispatchData) + 'static;
|
||||
}
|
||||
|
||||
impl DataControlDeviceHandling for DataControlDeviceHandler {
|
||||
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
|
||||
where
|
||||
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
|
||||
{
|
||||
let rc = Rc::new(RefCell::new(f)) as Rc<_>;
|
||||
self.status_listeners.borrow_mut().push(Rc::downgrade(&rc));
|
||||
DataControlDeviceStatusListener { _cb: rc }
|
||||
}
|
||||
|
||||
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
|
||||
where
|
||||
F: FnOnce(&DataControlDevice),
|
||||
{
|
||||
RefCell::borrow(&self.inner).with_device(seat, f)
|
||||
}
|
||||
|
||||
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
|
||||
where
|
||||
F: FnMut(String, WritePipe, DispatchData) + 'static,
|
||||
{
|
||||
RefCell::borrow(&self.inner).create_source(mime_types, callback)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn listen_to_devices<E, F>(env: &Environment<E>, f: F) -> DataControlDeviceStatusListener
|
||||
where
|
||||
E: DataControlDeviceHandling,
|
||||
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
|
||||
{
|
||||
env.with_inner(move |inner| DataControlDeviceHandling::listen(inner, f))
|
||||
}
|
||||
258
src/clients/wayland/wlr_data_control/mod.rs
Normal file
258
src/clients/wayland/wlr_data_control/mod.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
pub mod device;
|
||||
pub mod manager;
|
||||
pub mod offer;
|
||||
pub mod source;
|
||||
|
||||
use super::Env;
|
||||
use crate::clients::wayland::DData;
|
||||
use crate::send;
|
||||
use color_eyre::Report;
|
||||
use device::{DataControlDevice, DataControlDeviceEvent};
|
||||
use glib::Bytes;
|
||||
use manager::{DataControlDeviceHandling, DataControlDeviceStatusListener};
|
||||
use smithay_client_toolkit::data_device::WritePipe;
|
||||
use smithay_client_toolkit::environment::Environment;
|
||||
use smithay_client_toolkit::reexports::calloop::LoopHandle;
|
||||
use smithay_client_toolkit::MissingGlobal;
|
||||
use source::DataControlSource;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::{Read, Write};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::{debug, error, trace};
|
||||
use wayland_client::protocol::wl_seat::WlSeat;
|
||||
use wayland_client::DispatchData;
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
const INTERNAL_MIME_TYPE: &str = "x-ironbar-internal";
|
||||
|
||||
fn get_id() -> usize {
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Eq)]
|
||||
pub struct ClipboardItem {
|
||||
pub id: usize,
|
||||
pub value: ClipboardValue,
|
||||
pub mime_type: String,
|
||||
}
|
||||
|
||||
impl PartialEq<Self> for ClipboardItem {
|
||||
fn eq(&self, other: &Self) -> bool {
|
||||
self.id == other.id
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ClipboardValue {
|
||||
Text(String),
|
||||
Image(Bytes),
|
||||
Other,
|
||||
}
|
||||
|
||||
impl DataControlDeviceHandling for Env {
|
||||
fn listen<F>(&mut self, f: F) -> DataControlDeviceStatusListener
|
||||
where
|
||||
F: FnMut(WlSeat, DataControlDeviceEvent, DispatchData) + 'static,
|
||||
{
|
||||
self.data_control_device.listen(f)
|
||||
}
|
||||
|
||||
fn with_data_control_device<F>(&self, seat: &WlSeat, f: F) -> Result<(), MissingGlobal>
|
||||
where
|
||||
F: FnOnce(&DataControlDevice),
|
||||
{
|
||||
self.data_control_device.with_data_control_device(seat, f)
|
||||
}
|
||||
|
||||
fn create_source<F>(&self, mime_types: Vec<String>, callback: F) -> Option<DataControlSource>
|
||||
where
|
||||
F: FnMut(String, WritePipe, DispatchData) + 'static,
|
||||
{
|
||||
self.data_control_device.create_source(mime_types, callback)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn copy_to_clipboard<E>(
|
||||
env: &Environment<E>,
|
||||
seat: &WlSeat,
|
||||
item: &ClipboardItem,
|
||||
) -> Result<(), MissingGlobal>
|
||||
where
|
||||
E: DataControlDeviceHandling,
|
||||
{
|
||||
debug!("Copying item with id {} [{}]", item.id, item.mime_type);
|
||||
trace!("Copying: {item:?}");
|
||||
|
||||
let item = item.clone();
|
||||
|
||||
env.with_inner(|env| {
|
||||
let mime_types = vec![INTERNAL_MIME_TYPE.to_string(), item.mime_type];
|
||||
let source = env.create_source(mime_types, move |mime_type, mut pipe, _ddata| {
|
||||
debug!(
|
||||
"Triggering source callback for item with id {} [{}]",
|
||||
item.id, mime_type
|
||||
);
|
||||
|
||||
// FIXME: Not working for large (buffered) values in xwayland
|
||||
let bytes = match &item.value {
|
||||
ClipboardValue::Text(text) => text.as_bytes(),
|
||||
ClipboardValue::Image(bytes) => bytes.as_ref(),
|
||||
ClipboardValue::Other => panic!(
|
||||
"{:?}",
|
||||
io::Error::new(
|
||||
io::ErrorKind::Other,
|
||||
"Attempted to copy unsupported mime type",
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
if let Err(err) = pipe.write_all(bytes) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
});
|
||||
|
||||
env.with_data_control_device(seat, |device| device.set_selection(&source))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct MimeType {
|
||||
value: String,
|
||||
category: MimeTypeCategory,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum MimeTypeCategory {
|
||||
Text,
|
||||
Image,
|
||||
}
|
||||
|
||||
impl MimeType {
|
||||
fn parse(mime_types: &[String]) -> Option<Self> {
|
||||
mime_types
|
||||
.iter()
|
||||
.map(|s| s.to_lowercase())
|
||||
.find_map(|mime_type| match mime_type.as_str() {
|
||||
"text"
|
||||
| "string"
|
||||
| "utf8_string"
|
||||
| "text/plain"
|
||||
| "text/plain;charset=utf-8"
|
||||
| "text/plain;charset=iso-8859-1"
|
||||
| "text/plain;charset=us-ascii"
|
||||
| "text/plain;charset=unicode" => Some(Self {
|
||||
value: mime_type,
|
||||
category: MimeTypeCategory::Text,
|
||||
}),
|
||||
"image/png" | "image/jpg" | "image/jpeg" | "image/tiff" | "image/bmp"
|
||||
| "image/x-bmp" | "image/icon" => Some(Self {
|
||||
value: mime_type,
|
||||
category: MimeTypeCategory::Image,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn receive_offer(
|
||||
event: DataControlDeviceEvent,
|
||||
handle: &LoopHandle<DData>,
|
||||
tx: broadcast::Sender<Arc<ClipboardItem>>,
|
||||
mut ddata: DispatchData,
|
||||
) {
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("Could not get epoch, system time is probably very wrong")
|
||||
.as_nanos();
|
||||
|
||||
let offer = event.0;
|
||||
|
||||
let ddata = ddata
|
||||
.get::<DData>()
|
||||
.expect("Expected dispatch data to exist");
|
||||
|
||||
let handle2 = handle.clone();
|
||||
|
||||
let res = offer.with_mime_types(|mime_types| {
|
||||
debug!("Offer mime types: {mime_types:?}");
|
||||
|
||||
if mime_types.contains(&INTERNAL_MIME_TYPE.to_string()) {
|
||||
debug!("Skipping value provided by bar");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mime_type = MimeType::parse(mime_types);
|
||||
debug!("Detected mime type: {mime_type:?}");
|
||||
|
||||
match mime_type {
|
||||
Some(mime_type) => {
|
||||
debug!("[{timestamp}] Sending clipboard read request ({mime_type:?})");
|
||||
let read_pipe = offer.receive(mime_type.value.clone())?;
|
||||
let source = handle.insert_source(read_pipe, move |(), file, ddata| {
|
||||
debug!(
|
||||
"[{timestamp}] Reading clipboard contents ({:?})",
|
||||
&mime_type.category
|
||||
);
|
||||
match read_file(&mime_type, file) {
|
||||
Ok(item) => {
|
||||
send!(tx, Arc::new(item));
|
||||
}
|
||||
Err(err) => error!("{err:?}"),
|
||||
}
|
||||
|
||||
if let Some(src) = ddata.offer_tokens.remove(×tamp) {
|
||||
handle2.remove(src);
|
||||
}
|
||||
})?;
|
||||
|
||||
ddata.offer_tokens.insert(timestamp, source);
|
||||
}
|
||||
None => {
|
||||
// send an event so the clipboard module is aware it's changed
|
||||
send!(
|
||||
tx,
|
||||
Arc::new(ClipboardItem {
|
||||
id: usize::MAX,
|
||||
mime_type: String::new(),
|
||||
value: ClipboardValue::Other
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok::<(), Report>(())
|
||||
});
|
||||
|
||||
if let Err(err) = res {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
fn read_file(mime_type: &MimeType, file: &mut File) -> io::Result<ClipboardItem> {
|
||||
let value = match mime_type.category {
|
||||
MimeTypeCategory::Text => {
|
||||
let mut txt = String::new();
|
||||
file.read_to_string(&mut txt)?;
|
||||
|
||||
ClipboardValue::Text(txt)
|
||||
}
|
||||
MimeTypeCategory::Image => {
|
||||
let mut bytes = vec![];
|
||||
file.read_to_end(&mut bytes)?;
|
||||
let bytes = Bytes::from(&bytes);
|
||||
|
||||
ClipboardValue::Image(bytes)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ClipboardItem {
|
||||
id: get_id(),
|
||||
value,
|
||||
mime_type: mime_type.value.clone(),
|
||||
})
|
||||
}
|
||||
74
src/clients/wayland/wlr_data_control/offer.rs
Normal file
74
src/clients/wayland/wlr_data_control/offer.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use crate::lock;
|
||||
use nix::fcntl::OFlag;
|
||||
use nix::unistd::{close, pipe2};
|
||||
use smithay_client_toolkit::data_device::ReadPipe;
|
||||
use std::io;
|
||||
use std::os::fd::FromRawFd;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tracing::warn;
|
||||
use wayland_client::Main;
|
||||
use wayland_protocols::wlr::unstable::data_control::v1::client::zwlr_data_control_offer_v1::{
|
||||
Event, ZwlrDataControlOfferV1,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct Inner {
|
||||
mime_types: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DataControlOffer {
|
||||
inner: Arc<Mutex<Inner>>,
|
||||
pub(crate) offer: ZwlrDataControlOfferV1,
|
||||
}
|
||||
|
||||
impl DataControlOffer {
|
||||
pub(crate) fn new(offer: &Main<ZwlrDataControlOfferV1>) -> Self {
|
||||
let inner = Arc::new(Mutex::new(Inner {
|
||||
mime_types: Vec::new(),
|
||||
}));
|
||||
|
||||
{
|
||||
let inner = inner.clone();
|
||||
|
||||
offer.quick_assign(move |_, event, _| {
|
||||
let mut inner = lock!(inner);
|
||||
if let Event::Offer { mime_type } = event {
|
||||
inner.mime_types.push(mime_type);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
offer: offer.detach(),
|
||||
inner,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_mime_types<F, T>(&self, f: F) -> T
|
||||
where
|
||||
F: FnOnce(&[String]) -> T,
|
||||
{
|
||||
let inner = lock!(self.inner);
|
||||
f(&inner.mime_types)
|
||||
}
|
||||
|
||||
pub fn receive(&self, mime_type: String) -> io::Result<ReadPipe> {
|
||||
// create a pipe
|
||||
let (readfd, writefd) = pipe2(OFlag::O_CLOEXEC)?;
|
||||
|
||||
self.offer.receive(mime_type, writefd);
|
||||
|
||||
if let Err(err) = close(writefd) {
|
||||
warn!("Failed to close write pipe: {}", err);
|
||||
}
|
||||
|
||||
Ok(unsafe { FromRawFd::from_raw_fd(readfd) })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DataControlOffer {
|
||||
fn drop(&mut self) {
|
||||
self.offer.destroy();
|
||||
}
|
||||
}
|
||||
54
src/clients/wayland/wlr_data_control/source.rs
Normal file
54
src/clients/wayland/wlr_data_control/source.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
use smithay_client_toolkit::data_device::WritePipe;
|
||||
use std::os::fd::FromRawFd;
|
||||
use wayland_client::{Attached, DispatchData};
|
||||
use wayland_protocols::wlr::unstable::data_control::v1::client::{
|
||||
zwlr_data_control_manager_v1::ZwlrDataControlManagerV1,
|
||||
zwlr_data_control_source_v1::{Event, ZwlrDataControlSourceV1},
|
||||
};
|
||||
|
||||
fn data_control_source_impl<F>(
|
||||
source: &ZwlrDataControlSourceV1,
|
||||
event: Event,
|
||||
implem: &mut F,
|
||||
ddata: DispatchData,
|
||||
) where
|
||||
F: FnMut(String, WritePipe, DispatchData),
|
||||
{
|
||||
match event {
|
||||
Event::Send { mime_type, fd } => {
|
||||
let pipe = unsafe { FromRawFd::from_raw_fd(fd) };
|
||||
implem(mime_type, pipe, ddata);
|
||||
}
|
||||
Event::Cancelled => source.destroy(),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct DataControlSource {
|
||||
pub(crate) source: ZwlrDataControlSourceV1,
|
||||
}
|
||||
|
||||
impl DataControlSource {
|
||||
pub fn new<F>(
|
||||
manager: &Attached<ZwlrDataControlManagerV1>,
|
||||
mime_types: Vec<String>,
|
||||
mut callback: F,
|
||||
) -> Self
|
||||
where
|
||||
F: FnMut(String, WritePipe, DispatchData) + 'static,
|
||||
{
|
||||
let source = manager.create_data_source();
|
||||
|
||||
source.quick_assign(move |source, evt, ddata| {
|
||||
data_control_source_impl(&source, evt, &mut callback, ddata);
|
||||
});
|
||||
|
||||
for mime_type in mime_types {
|
||||
source.offer(mime_type);
|
||||
}
|
||||
|
||||
Self {
|
||||
source: source.detach(),
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/clients/wayland/wlr_foreign_toplevel/handle.rs
Normal file
152
src/clients/wayland/wlr_foreign_toplevel/handle.rs
Normal file
@@ -0,0 +1,152 @@
|
||||
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};
|
||||
use crate::write_lock;
|
||||
|
||||
const STATE_ACTIVE: u32 = 2;
|
||||
const STATE_FULLSCREEN: u32 = 3;
|
||||
|
||||
static COUNTER: AtomicUsize = AtomicUsize::new(1);
|
||||
|
||||
fn get_id() -> usize {
|
||||
COUNTER.fetch_add(1, Ordering::Relaxed)
|
||||
}
|
||||
|
||||
#[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: _ }
|
||||
| Event::OutputLeave { output: _ }
|
||||
| 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 = write_lock!(inner);
|
||||
toplevel_implem(event, &mut inner, &mut callback, ddata);
|
||||
});
|
||||
|
||||
Self
|
||||
}
|
||||
}
|
||||
163
src/clients/wayland/wlr_foreign_toplevel/manager.rs
Normal file
163
src/clients/wayland/wlr_foreign_toplevel/manager.rs
Normal file
@@ -0,0 +1,163 @@
|
||||
use super::handle::{Toplevel, ToplevelEvent};
|
||||
use crate::wayland::LazyGlobal;
|
||||
use smithay_client_toolkit::environment::{Environment, GlobalHandler};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::{self, 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 matches!(inner.manager, LazyGlobal::Unknown) {
|
||||
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))
|
||||
}
|
||||
39
src/clients/wayland/wlr_foreign_toplevel/mod.rs
Normal file
39
src/clients/wayland/wlr_foreign_toplevel/mod.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::sync::RwLock;
|
||||
use indexmap::IndexMap;
|
||||
use tokio::sync::broadcast::Sender;
|
||||
use tracing::trace;
|
||||
use super::Env;
|
||||
use handle::{ToplevelEvent, ToplevelChange, ToplevelInfo};
|
||||
use manager::{ToplevelHandling, ToplevelStatusListener};
|
||||
use wayland_client::DispatchData;
|
||||
use wayland_protocols::wlr::unstable::foreign_toplevel::v1::client::zwlr_foreign_toplevel_handle_v1::ZwlrForeignToplevelHandleV1;
|
||||
use crate::{send, write_lock};
|
||||
|
||||
pub mod handle;
|
||||
pub mod manager;
|
||||
|
||||
impl ToplevelHandling for Env {
|
||||
fn listen<F>(&mut self, f: F) -> ToplevelStatusListener
|
||||
where
|
||||
F: FnMut(ZwlrForeignToplevelHandleV1, ToplevelEvent, DispatchData) + 'static,
|
||||
{
|
||||
self.toplevel.listen(f)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_toplevels(
|
||||
toplevels: &RwLock<IndexMap<usize, (ToplevelInfo, ZwlrForeignToplevelHandleV1)>>,
|
||||
handle: ZwlrForeignToplevelHandleV1,
|
||||
event: ToplevelEvent,
|
||||
tx: &Sender<ToplevelEvent>,
|
||||
) {
|
||||
trace!("Received toplevel event: {:?}", event);
|
||||
|
||||
if event.change == ToplevelChange::Close {
|
||||
write_lock!(toplevels).remove(&event.toplevel.id);
|
||||
} else {
|
||||
write_lock!(toplevels).insert(event.toplevel.id, (event.toplevel.clone(), handle));
|
||||
}
|
||||
|
||||
send!(tx, event);
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
151
src/config.rs
151
src/config.rs
@@ -1,151 +0,0 @@
|
||||
use crate::modules::clock::ClockModule;
|
||||
use crate::modules::focused::FocusedModule;
|
||||
use crate::modules::launcher::LauncherModule;
|
||||
use crate::modules::mpd::MpdModule;
|
||||
use crate::modules::script::ScriptModule;
|
||||
use crate::modules::sysinfo::SysInfoModule;
|
||||
use crate::modules::tray::TrayModule;
|
||||
use crate::modules::workspaces::WorkspacesModule;
|
||||
use color_eyre::eyre::{Context, ContextCompat};
|
||||
use color_eyre::{eyre, Help, Report};
|
||||
use dirs::config_dir;
|
||||
use eyre::Result;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::{env, fs};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum ModuleConfig {
|
||||
Clock(ClockModule),
|
||||
Mpd(MpdModule),
|
||||
Tray(TrayModule),
|
||||
Workspaces(WorkspacesModule),
|
||||
SysInfo(SysInfoModule),
|
||||
Launcher(LauncherModule),
|
||||
Script(ScriptModule),
|
||||
Focused(FocusedModule),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum MonitorConfig {
|
||||
Single(Config),
|
||||
Multiple(Vec<Config>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "kebab-case")]
|
||||
pub enum BarPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
}
|
||||
|
||||
impl Default for BarPosition {
|
||||
fn default() -> Self {
|
||||
Self::Bottom
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Default)]
|
||||
pub struct Config {
|
||||
#[serde(default = "default_bar_position")]
|
||||
pub position: BarPosition,
|
||||
#[serde(default = "default_bar_height")]
|
||||
pub height: i32,
|
||||
|
||||
pub left: Option<Vec<ModuleConfig>>,
|
||||
pub center: Option<Vec<ModuleConfig>>,
|
||||
pub right: Option<Vec<ModuleConfig>>,
|
||||
|
||||
pub monitors: Option<HashMap<String, MonitorConfig>>,
|
||||
}
|
||||
|
||||
const fn default_bar_position() -> BarPosition {
|
||||
BarPosition::Bottom
|
||||
}
|
||||
|
||||
const fn default_bar_height() -> i32 {
|
||||
42
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Attempts to load the config file from file,
|
||||
/// parse it and return a new instance of `Self`.
|
||||
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")
|
||||
.note("Config file was specified using `IRONBAR_CONFIG` environment variable"))
|
||||
}
|
||||
} else {
|
||||
Self::try_find_config()
|
||||
}?;
|
||||
|
||||
Self::load_file(&config_path)
|
||||
}
|
||||
|
||||
/// Attempts to discover the location of the config file
|
||||
/// by checking each valid format's extension.
|
||||
///
|
||||
/// Returns the path of the first valid match, if any.
|
||||
fn try_find_config() -> Result<PathBuf> {
|
||||
let config_dir = config_dir().wrap_err("Failed to locate user config dir")?;
|
||||
|
||||
let extensions = vec!["json", "toml", "yaml", "yml", "corn"];
|
||||
|
||||
let file = extensions.into_iter().find_map(|extension| {
|
||||
let full_path = config_dir
|
||||
.join("ironbar")
|
||||
.join(format!("config.{extension}"));
|
||||
|
||||
if Path::exists(&full_path) {
|
||||
Some(full_path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
match file {
|
||||
Some(file) => Ok(file),
|
||||
None => Err(Report::msg("Could not find config file")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads the config file at the specified path
|
||||
/// and parses it into `Self` based on its extension.
|
||||
fn load_file(path: &Path) -> Result<Self> {
|
||||
let file = fs::read(path).wrap_err("Failed to read config file")?;
|
||||
let extension = path
|
||||
.extension()
|
||||
.unwrap_or_default()
|
||||
.to_str()
|
||||
.unwrap_or_default();
|
||||
|
||||
match extension {
|
||||
"json" => serde_json::from_slice(&file).wrap_err("Invalid JSON config"),
|
||||
"toml" => toml::from_slice(&file).wrap_err("Invalid TOML config"),
|
||||
"yaml" | "yml" => serde_yaml::from_slice(&file).wrap_err("Invalid YAML config"),
|
||||
"corn" => {
|
||||
// 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)?)?)
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub const fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
pub const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
162
src/config/common.rs
Normal file
162
src/config/common.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use glib::signal::Inhibit;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::script::{Script, ScriptInput};
|
||||
use crate::send;
|
||||
use gtk::gdk::ScrollDirection;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{GestureClick, Orientation, Revealer, RevealerTransitionType, Widget};
|
||||
use serde::Deserialize;
|
||||
use tokio::spawn;
|
||||
use tracing::trace;
|
||||
|
||||
/// Common configuration options
|
||||
/// which can be set on every module.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CommonConfig {
|
||||
pub show_if: Option<ScriptInput>,
|
||||
pub transition_type: Option<TransitionType>,
|
||||
pub transition_duration: Option<u32>,
|
||||
|
||||
pub on_click_left: Option<ScriptInput>,
|
||||
pub on_click_right: Option<ScriptInput>,
|
||||
pub on_click_middle: Option<ScriptInput>,
|
||||
pub on_scroll_up: Option<ScriptInput>,
|
||||
pub on_scroll_down: Option<ScriptInput>,
|
||||
pub on_mouse_enter: Option<ScriptInput>,
|
||||
pub on_mouse_exit: Option<ScriptInput>,
|
||||
|
||||
pub tooltip: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TransitionType {
|
||||
None,
|
||||
Crossfade,
|
||||
SlideStart,
|
||||
SlideEnd,
|
||||
}
|
||||
|
||||
impl TransitionType {
|
||||
pub fn to_revealer_transition_type(&self, orientation: Orientation) -> RevealerTransitionType {
|
||||
match (self, orientation) {
|
||||
(TransitionType::SlideStart, Orientation::Horizontal) => {
|
||||
RevealerTransitionType::SlideLeft
|
||||
}
|
||||
(TransitionType::SlideStart, Orientation::Vertical) => RevealerTransitionType::SlideUp,
|
||||
(TransitionType::SlideEnd, Orientation::Horizontal) => {
|
||||
RevealerTransitionType::SlideRight
|
||||
}
|
||||
(TransitionType::SlideEnd, Orientation::Vertical) => RevealerTransitionType::SlideDown,
|
||||
(TransitionType::Crossfade, _) => RevealerTransitionType::Crossfade,
|
||||
_ => RevealerTransitionType::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CommonConfig {
|
||||
/// Configures the module's container according to the common config options.
|
||||
pub fn install<W: IsA<Widget>>(mut self, widget: &W, revealer: &Revealer) {
|
||||
self.install_show_if(widget, revealer);
|
||||
|
||||
let left_click_script = self.on_click_left.map(Script::new_polling);
|
||||
let middle_click_script = self.on_click_middle.map(Script::new_polling);
|
||||
let right_click_script = self.on_click_right.map(Script::new_polling);
|
||||
|
||||
let gesture = GestureClick::new();
|
||||
|
||||
gesture.connect_pressed(move |_, event| {
|
||||
let script = match event.button() {
|
||||
1 => left_click_script.as_ref(),
|
||||
2 => middle_click_script.as_ref(),
|
||||
3 => right_click_script.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(script) = script {
|
||||
trace!("Running on-click script: {}", event.button());
|
||||
script.run_as_oneshot(None);
|
||||
}
|
||||
});
|
||||
|
||||
let scroll_up_script = self.on_scroll_up.map(Script::new_polling);
|
||||
let scroll_down_script = self.on_scroll_down.map(Script::new_polling);
|
||||
|
||||
widget.connect_scroll_event(move |_, event| {
|
||||
let script = match event.direction() {
|
||||
ScrollDirection::Up => scroll_up_script.as_ref(),
|
||||
ScrollDirection::Down => scroll_down_script.as_ref(),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(script) = script {
|
||||
trace!("Running on-scroll script: {}", event.direction());
|
||||
script.run_as_oneshot(None);
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
macro_rules! install_oneshot {
|
||||
($option:expr, $method:ident) => {
|
||||
$option.map(Script::new_polling).map(|script| {
|
||||
widget.$method(move |_, _| {
|
||||
script.run_as_oneshot(None);
|
||||
Inhibit(false)
|
||||
});
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
install_oneshot!(self.on_mouse_enter, connect_enter_notify_event);
|
||||
install_oneshot!(self.on_mouse_exit, connect_leave_notify_event);
|
||||
|
||||
if let Some(tooltip) = self.tooltip {
|
||||
let container = widget.clone();
|
||||
DynamicString::new(&tooltip, move |string| {
|
||||
container.set_tooltip_text(Some(&string));
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn install_show_if<W: IsA<Widget>>(&mut self, widget: &W, revealer: &Revealer) {
|
||||
self.show_if.take().map_or_else(
|
||||
|| {
|
||||
widget.set_visible(true)
|
||||
},
|
||||
|show_if| {
|
||||
let script = Script::new_polling(show_if);
|
||||
let widget = widget.clone();
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(None, |_, success| {
|
||||
send!(tx, success);
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
{
|
||||
let revealer = revealer.clone();
|
||||
let container = container.clone();
|
||||
|
||||
rx.attach(None, move |success| {
|
||||
if success {
|
||||
container.show_all();
|
||||
}
|
||||
revealer.set_reveal_child(success);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
revealer.connect_child_revealed_notify(move |revealer| {
|
||||
if !revealer.reveals_child() {
|
||||
container.hide()
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
58
src/config/impl.rs
Normal file
58
src/config/impl.rs
Normal file
@@ -0,0 +1,58 @@
|
||||
use super::{BarPosition, Config, MonitorConfig};
|
||||
use color_eyre::{Help, Report};
|
||||
use gtk::Orientation;
|
||||
use serde::{Deserialize, Deserializer};
|
||||
|
||||
// Manually implement for better untagged enum error handling:
|
||||
// currently open pr: https://github.com/serde-rs/serde/pull/1544
|
||||
impl<'de> Deserialize<'de> for MonitorConfig {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
let content =
|
||||
<serde::__private::de::Content as serde::Deserialize>::deserialize(deserializer)?;
|
||||
|
||||
match <Config as serde::Deserialize>::deserialize(
|
||||
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
|
||||
) {
|
||||
Ok(config) => Ok(Self::Single(config)),
|
||||
Err(outer) => match <Vec<Config> as serde::Deserialize>::deserialize(
|
||||
serde::__private::de::ContentRefDeserializer::<D::Error>::new(&content),
|
||||
) {
|
||||
Ok(config) => Ok(Self::Multiple(config)),
|
||||
Err(inner) => {
|
||||
let report = Report::msg(format!(" multi-bar (c): {inner}").replace("An error occurred when deserializing: ", ""))
|
||||
.wrap_err(format!("single-bar (b): {outer}").replace("An error occurred when deserializing: ", ""))
|
||||
.wrap_err("An invalid config was found. The following errors were encountered:")
|
||||
.note("Both the single-bar (type b / error 1) and multi-bar (type c / error 2) config variants were tried. You can likely ignore whichever of these is not relevant to you.")
|
||||
.suggestion("Please see https://github.com/JakeStanger/ironbar/wiki/configuration-guide#2-pick-your-use-case for more info on the above");
|
||||
|
||||
Err(serde::de::Error::custom(format!("{report:?}")))
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl BarPosition {
|
||||
/// Gets the orientation the bar and widgets should use
|
||||
/// based on this position.
|
||||
pub fn get_orientation(self) -> Orientation {
|
||||
if self == Self::Top || self == Self::Bottom {
|
||||
Orientation::Horizontal
|
||||
} else {
|
||||
Orientation::Vertical
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the angle that label text should be displayed at
|
||||
/// based on this position.
|
||||
pub const fn get_angle(self) -> f64 {
|
||||
match self {
|
||||
Self::Top | Self::Bottom => 0.0,
|
||||
Self::Left => 90.0,
|
||||
Self::Right => 270.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
124
src/config/mod.rs
Normal file
124
src/config/mod.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
mod common;
|
||||
mod r#impl;
|
||||
mod truncate;
|
||||
|
||||
#[cfg(feature = "clipboard")]
|
||||
use crate::modules::clipboard::ClipboardModule;
|
||||
#[cfg(feature = "clock")]
|
||||
use crate::modules::clock::ClockModule;
|
||||
use crate::modules::custom::CustomModule;
|
||||
use crate::modules::focused::FocusedModule;
|
||||
use crate::modules::label::LabelModule;
|
||||
use crate::modules::launcher::LauncherModule;
|
||||
#[cfg(feature = "music")]
|
||||
use crate::modules::music::MusicModule;
|
||||
use crate::modules::script::ScriptModule;
|
||||
#[cfg(feature = "sys_info")]
|
||||
use crate::modules::sysinfo::SysInfoModule;
|
||||
#[cfg(feature = "tray")]
|
||||
use crate::modules::tray::TrayModule;
|
||||
#[cfg(feature = "upower")]
|
||||
use crate::modules::upower::UpowerModule;
|
||||
#[cfg(feature = "workspaces")]
|
||||
use crate::modules::workspaces::WorkspacesModule;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
pub use self::common::{CommonConfig, TransitionType};
|
||||
pub use self::truncate::{EllipsizeMode, TruncateMode};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ModuleConfig {
|
||||
#[cfg(feature = "clock")]
|
||||
Clipboard(Box<ClipboardModule>),
|
||||
#[cfg(feature = "clock")]
|
||||
Clock(Box<ClockModule>),
|
||||
Custom(Box<CustomModule>),
|
||||
Focused(Box<FocusedModule>),
|
||||
Label(Box<LabelModule>),
|
||||
Launcher(Box<LauncherModule>),
|
||||
#[cfg(feature = "music")]
|
||||
Music(Box<MusicModule>),
|
||||
Script(Box<ScriptModule>),
|
||||
#[cfg(feature = "sys_info")]
|
||||
SysInfo(Box<SysInfoModule>),
|
||||
#[cfg(feature = "tray")]
|
||||
Tray(Box<TrayModule>),
|
||||
#[cfg(feature = "upower")]
|
||||
Upower(Box<UpowerModule>),
|
||||
#[cfg(feature = "workspaces")]
|
||||
Workspaces(Box<WorkspacesModule>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MonitorConfig {
|
||||
Single(Config),
|
||||
Multiple(Vec<Config>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BarPosition {
|
||||
Top,
|
||||
Bottom,
|
||||
Left,
|
||||
Right,
|
||||
}
|
||||
|
||||
impl Default for BarPosition {
|
||||
fn default() -> Self {
|
||||
Self::Bottom
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Copy, Clone, PartialEq, Eq)]
|
||||
pub struct MarginConfig {
|
||||
#[serde(default)]
|
||||
pub bottom: i32,
|
||||
#[serde(default)]
|
||||
pub left: i32,
|
||||
#[serde(default)]
|
||||
pub right: i32,
|
||||
#[serde(default)]
|
||||
pub top: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub position: BarPosition,
|
||||
#[serde(default = "default_true")]
|
||||
pub anchor_to_edges: bool,
|
||||
#[serde(default = "default_bar_height")]
|
||||
pub height: i32,
|
||||
#[serde(default)]
|
||||
pub margin: MarginConfig,
|
||||
#[serde(default = "default_popup_gap")]
|
||||
pub popup_gap: i32,
|
||||
|
||||
/// GTK icon theme to use.
|
||||
pub icon_theme: Option<String>,
|
||||
|
||||
pub start: Option<Vec<ModuleConfig>>,
|
||||
pub center: Option<Vec<ModuleConfig>>,
|
||||
pub end: Option<Vec<ModuleConfig>>,
|
||||
|
||||
pub monitors: Option<HashMap<String, MonitorConfig>>,
|
||||
}
|
||||
|
||||
const fn default_bar_height() -> i32 {
|
||||
42
|
||||
}
|
||||
|
||||
const fn default_popup_gap() -> i32 {
|
||||
5
|
||||
}
|
||||
|
||||
pub const fn default_false() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
pub const fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
66
src/config/truncate.rs
Normal file
66
src/config/truncate.rs
Normal file
@@ -0,0 +1,66 @@
|
||||
use gtk::pango::EllipsizeMode as GtkEllipsizeMode;
|
||||
use gtk::prelude::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum EllipsizeMode {
|
||||
Start,
|
||||
Middle,
|
||||
End,
|
||||
}
|
||||
|
||||
impl From<EllipsizeMode> for GtkEllipsizeMode {
|
||||
fn from(value: EllipsizeMode) -> Self {
|
||||
match value {
|
||||
EllipsizeMode::Start => Self::Start,
|
||||
EllipsizeMode::Middle => Self::Middle,
|
||||
EllipsizeMode::End => Self::End,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone, Copy)]
|
||||
#[serde(untagged)]
|
||||
pub enum TruncateMode {
|
||||
Auto(EllipsizeMode),
|
||||
Length {
|
||||
mode: EllipsizeMode,
|
||||
length: Option<i32>,
|
||||
max_length: Option<i32>,
|
||||
},
|
||||
}
|
||||
|
||||
impl TruncateMode {
|
||||
const fn mode(&self) -> EllipsizeMode {
|
||||
match self {
|
||||
Self::Length { mode, .. } | Self::Auto(mode) => *mode,
|
||||
}
|
||||
}
|
||||
|
||||
const fn length(&self) -> Option<i32> {
|
||||
match self {
|
||||
Self::Auto(_) => None,
|
||||
Self::Length { length, .. } => *length,
|
||||
}
|
||||
}
|
||||
|
||||
const fn max_length(&self) -> Option<i32> {
|
||||
match self {
|
||||
Self::Auto(_) => None,
|
||||
Self::Length { max_length, .. } => *max_length,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn truncate_label(&self, label: >k::Label) {
|
||||
label.set_ellipsize(self.mode().into());
|
||||
|
||||
if let Some(length) = self.length() {
|
||||
label.set_width_chars(length);
|
||||
}
|
||||
|
||||
if let Some(length) = self.max_length() {
|
||||
label.set_max_width_chars(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/desktop_file.rs
Normal file
74
src/desktop_file.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Gets directories that should contain `.desktop` files
|
||||
/// and exist on the filesystem.
|
||||
fn find_application_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
||||
let user_dir = dirs::data_local_dir();
|
||||
|
||||
if let Some(mut user_dir) = user_dir {
|
||||
user_dir.push("applications");
|
||||
dirs.push(user_dir);
|
||||
}
|
||||
|
||||
dirs.into_iter().filter(|dir| dir.exists()).collect()
|
||||
}
|
||||
|
||||
/// Attempts to locate a `.desktop` file for an app id
|
||||
/// (or app class).
|
||||
///
|
||||
/// A simple case-insensitive check is performed on filename == `app_id`.
|
||||
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
|
||||
let dirs = find_application_dirs();
|
||||
|
||||
for dir in dirs {
|
||||
let mut walker = WalkDir::new(dir).max_depth(5).into_iter();
|
||||
|
||||
let entry = walker.find(|entry| match entry {
|
||||
Ok(entry) => {
|
||||
let file_name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
let test_name = format!("{}.desktop", app_id.to_lowercase());
|
||||
file_name == test_name
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
|
||||
if let Some(Ok(entry)) = entry {
|
||||
let path = entry.path().to_owned();
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parses a desktop file into a flat hashmap of keys/values.
|
||||
fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
|
||||
let file = File::open(path)?;
|
||||
let lines = io::BufReader::new(file).lines();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in lines.flatten() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// Attempts to get the icon name from the app's `.desktop` file.
|
||||
pub fn get_desktop_icon_name(app_id: &str) -> Option<String> {
|
||||
find_desktop_file(app_id).and_then(|file| {
|
||||
let map = parse_desktop_file(file);
|
||||
map.map_or(None, |map| {
|
||||
map.get("Icon").map(std::string::ToString::to_string)
|
||||
})
|
||||
})
|
||||
}
|
||||
160
src/dynamic_string.rs
Normal file
160
src/dynamic_string.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use crate::script::{OutputStream, Script};
|
||||
use crate::{lock, send};
|
||||
use gtk::prelude::*;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::spawn;
|
||||
|
||||
/// A segment of a dynamic string,
|
||||
/// containing either a static string
|
||||
/// or a script.
|
||||
#[derive(Debug)]
|
||||
enum DynamicStringSegment {
|
||||
Static(String),
|
||||
Dynamic(Script),
|
||||
}
|
||||
|
||||
/// A string with embedded scripts for dynamic content.
|
||||
pub struct DynamicString;
|
||||
|
||||
impl DynamicString {
|
||||
/// Creates a new dynamic string, based off the input template.
|
||||
/// Runs `f` with the compiled string each time one of the scripts updates.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```rs
|
||||
/// DynamicString::new(&text, move |string| {
|
||||
/// label.set_markup(&string);
|
||||
/// Continue(true)
|
||||
/// });
|
||||
/// ```
|
||||
pub fn new<F>(input: &str, f: F) -> Self
|
||||
where
|
||||
F: FnMut(String) -> Continue + 'static,
|
||||
{
|
||||
let segments = Self::parse_input(input);
|
||||
|
||||
let label_parts = Arc::new(Mutex::new(Vec::new()));
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
for (i, segment) in segments.into_iter().enumerate() {
|
||||
match segment {
|
||||
DynamicStringSegment::Static(str) => {
|
||||
lock!(label_parts).push(str);
|
||||
}
|
||||
DynamicStringSegment::Dynamic(script) => {
|
||||
let tx = tx.clone();
|
||||
let label_parts = label_parts.clone();
|
||||
|
||||
// insert blank value to preserve segment order
|
||||
lock!(label_parts).push(String::new());
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(None, |out, _| {
|
||||
if let OutputStream::Stdout(out) = out {
|
||||
let mut label_parts = lock!(label_parts);
|
||||
|
||||
let _ = std::mem::replace(&mut label_parts[i], out);
|
||||
|
||||
let string = label_parts.join("");
|
||||
send!(tx, string);
|
||||
}
|
||||
})
|
||||
.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// initialize
|
||||
{
|
||||
let label_parts = lock!(label_parts).join("");
|
||||
send!(tx, label_parts);
|
||||
}
|
||||
|
||||
rx.attach(None, f);
|
||||
|
||||
Self
|
||||
}
|
||||
|
||||
/// Parses the input string into static and dynamic segments
|
||||
fn parse_input(input: &str) -> Vec<DynamicStringSegment> {
|
||||
if !input.contains("{{") {
|
||||
return vec![DynamicStringSegment::Static(input.to_string())];
|
||||
}
|
||||
|
||||
let mut segments = vec![];
|
||||
|
||||
let mut chars = input.chars().collect::<Vec<_>>();
|
||||
while !chars.is_empty() {
|
||||
let char_pair = if chars.len() > 1 {
|
||||
Some(&chars[..=1])
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (token, skip) = if let Some(['{', '{']) = char_pair {
|
||||
const SKIP_BRACKETS: usize = 4; // two braces either side
|
||||
|
||||
let str = chars
|
||||
.windows(2)
|
||||
.skip(2)
|
||||
.take_while(|win| win != &['}', '}'])
|
||||
.map(|w| w[0])
|
||||
.collect::<String>();
|
||||
|
||||
let len = str.len();
|
||||
|
||||
(
|
||||
DynamicStringSegment::Dynamic(Script::from(str.as_str())),
|
||||
len + SKIP_BRACKETS,
|
||||
)
|
||||
} else {
|
||||
let mut str = chars
|
||||
.windows(2)
|
||||
.take_while(|win| win != &['{', '{'])
|
||||
.map(|w| w[0])
|
||||
.collect::<String>();
|
||||
|
||||
// if segment is at end of string, last char gets missed above due to uneven window.
|
||||
if chars.len() == str.len() + 1 {
|
||||
let remaining_char = *chars.get(str.len()).expect("Failed to find last char");
|
||||
str.push(remaining_char);
|
||||
}
|
||||
|
||||
let len = str.len();
|
||||
|
||||
(DynamicStringSegment::Static(str), len)
|
||||
};
|
||||
|
||||
// quick runtime check to make sure the parser is working as expected
|
||||
assert_ne!(skip, 0);
|
||||
|
||||
segments.push(token);
|
||||
chars.drain(..skip);
|
||||
}
|
||||
|
||||
segments
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
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)
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/error.rs
Normal file
13
src/error.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
#[repr(i32)]
|
||||
pub enum ExitCode {
|
||||
GtkDisplay = 1,
|
||||
CreateBars = 2,
|
||||
Config = 3,
|
||||
}
|
||||
|
||||
pub const ERR_OUTPUTS: &str = "GTK and Sway are reporting a different set of outputs - this is a severe bug and should never happen";
|
||||
pub const ERR_MUTEX_LOCK: &str = "Failed to get lock on Mutex";
|
||||
pub const ERR_READ_LOCK: &str = "Failed to get read lock";
|
||||
pub const ERR_WRITE_LOCK: &str = "Failed to get write lock";
|
||||
pub const ERR_CHANNEL_SEND: &str = "Failed to send message to channel";
|
||||
pub const ERR_CHANNEL_RECV: &str = "Failed to receive message from channel";
|
||||
149
src/icon.rs
149
src/icon.rs
@@ -1,149 +0,0 @@
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconLookupFlags, IconTheme};
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Gets directories that should contain `.desktop` files
|
||||
/// and exist on the filesystem.
|
||||
fn find_application_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = vec![PathBuf::from("/usr/share/applications")];
|
||||
let user_dir = dirs::data_local_dir();
|
||||
|
||||
if let Some(mut user_dir) = user_dir {
|
||||
user_dir.push("applications");
|
||||
dirs.push(user_dir);
|
||||
}
|
||||
|
||||
dirs.into_iter().filter(|dir| dir.exists()).collect()
|
||||
}
|
||||
|
||||
/// Attempts to locate a `.desktop` file for an app id
|
||||
/// (or app class).
|
||||
///
|
||||
/// A simple case-insensitive check is performed on filename == `app_id`.
|
||||
pub fn find_desktop_file(app_id: &str) -> Option<PathBuf> {
|
||||
let dirs = find_application_dirs();
|
||||
|
||||
for dir in dirs {
|
||||
let mut walker = WalkDir::new(dir).max_depth(5).into_iter();
|
||||
|
||||
let entry = walker.find(|entry| match entry {
|
||||
Ok(entry) => {
|
||||
let file_name = entry.file_name().to_string_lossy().to_lowercase();
|
||||
let test_name = format!("{}.desktop", app_id.to_lowercase());
|
||||
file_name == test_name
|
||||
}
|
||||
_ => false,
|
||||
});
|
||||
|
||||
if let Some(Ok(entry)) = entry {
|
||||
let path = entry.path().to_owned();
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Parses a desktop file into a flat hashmap of keys/values.
|
||||
fn parse_desktop_file(path: PathBuf) -> io::Result<HashMap<String, String>> {
|
||||
let file = File::open(path)?;
|
||||
let lines = io::BufReader::new(file).lines();
|
||||
|
||||
let mut map = HashMap::new();
|
||||
|
||||
for line in lines.flatten() {
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
enum IconLocation {
|
||||
Theme(String),
|
||||
File(PathBuf),
|
||||
}
|
||||
|
||||
/// Attempts to get the location of an icon.
|
||||
///
|
||||
/// Handles icons that are part of a GTK theme, icons specified as path
|
||||
/// and icons for steam games.
|
||||
fn get_icon_location(theme: &IconTheme, app_id: &str, size: i32) -> Option<IconLocation> {
|
||||
let has_icon = theme
|
||||
.lookup_icon(app_id, size, IconLookupFlags::empty())
|
||||
.is_some();
|
||||
|
||||
if has_icon {
|
||||
return Some(IconLocation::Theme(app_id.to_string()));
|
||||
}
|
||||
|
||||
let is_steam_game = app_id.starts_with("steam_app_");
|
||||
if is_steam_game {
|
||||
let steam_id: String = app_id.chars().skip("steam_app_".len()).collect();
|
||||
|
||||
return match dirs::data_dir() {
|
||||
Some(dir) => {
|
||||
let path = dir.join(format!(
|
||||
"icons/hicolor/32x32/apps/steam_icon_{}.png",
|
||||
steam_id
|
||||
));
|
||||
|
||||
return Some(IconLocation::File(path));
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
}
|
||||
|
||||
let icon_name = get_desktop_icon_name(app_id);
|
||||
if let Some(icon_name) = icon_name {
|
||||
let is_path = PathBuf::from(&icon_name).exists();
|
||||
|
||||
return if is_path {
|
||||
Some(IconLocation::File(PathBuf::from(icon_name)))
|
||||
} else {
|
||||
return Some(IconLocation::Theme(icon_name));
|
||||
};
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Gets the icon associated with an app.
|
||||
pub fn get_icon(theme: &IconTheme, app_id: &str, size: i32) -> Option<Pixbuf> {
|
||||
let icon_location = get_icon_location(theme, app_id, size);
|
||||
|
||||
match icon_location {
|
||||
Some(IconLocation::Theme(icon_name)) => {
|
||||
let icon = theme.load_icon(&icon_name, size, IconLookupFlags::FORCE_SIZE);
|
||||
|
||||
match icon {
|
||||
Ok(icon) => icon,
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
Some(IconLocation::File(path)) => Pixbuf::from_file_at_scale(path, size, size, true).ok(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
56
src/image/gtk.rs
Normal file
56
src/image/gtk.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
use super::ImageProvider;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, IconTheme, Image, Label, Orientation};
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
|
||||
pub fn new_icon_button(input: &str, icon_theme: &IconTheme, size: i32) -> Button {
|
||||
let button = Button::new();
|
||||
|
||||
if ImageProvider::is_definitely_image_input(input) {
|
||||
let image = Image::new();
|
||||
image.set_widget_name("image");
|
||||
|
||||
match ImageProvider::parse(input, icon_theme, size)
|
||||
.and_then(|provider| provider.load_into_image(image.clone()))
|
||||
{
|
||||
Ok(_) => {
|
||||
button.set_image(Some(&image));
|
||||
button.set_always_show_image(true);
|
||||
}
|
||||
Err(err) => {
|
||||
error!("{err:?}");
|
||||
button.set_label(input);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
button.set_label(input);
|
||||
}
|
||||
|
||||
button
|
||||
}
|
||||
|
||||
#[cfg(feature = "music")]
|
||||
pub fn new_icon_label(input: &str, icon_theme: &IconTheme, size: i32) -> gtk::Box {
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||
|
||||
if ImageProvider::is_definitely_image_input(input) {
|
||||
let image = Image::new();
|
||||
image.set_widget_name("image");
|
||||
|
||||
container.append(&image);
|
||||
|
||||
if let Err(err) = ImageProvider::parse(input, icon_theme, size)
|
||||
.and_then(|provider| provider.load_into_image(image))
|
||||
{
|
||||
error!("{err:?}");
|
||||
}
|
||||
} else {
|
||||
let label = Label::new(Some(input));
|
||||
label.set_widget_name("label");
|
||||
|
||||
container.append(&label);
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
7
src/image/mod.rs
Normal file
7
src/image/mod.rs
Normal file
@@ -0,0 +1,7 @@
|
||||
#[cfg(any(feature = "music", feature = "workspaces", feature = "clipboard"))]
|
||||
mod gtk;
|
||||
mod provider;
|
||||
|
||||
#[cfg(any(feature = "music", feature = "workspaces"))]
|
||||
pub use self::gtk::*;
|
||||
pub use provider::ImageProvider;
|
||||
211
src/image/provider.rs
Normal file
211
src/image/provider.rs
Normal file
@@ -0,0 +1,211 @@
|
||||
use crate::desktop_file::get_desktop_icon_name;
|
||||
use cfg_if::cfg_if;
|
||||
use color_eyre::{Help, Report, Result};
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconLookupFlags, IconTheme};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
cfg_if!(
|
||||
if #[cfg(feature = "http")] {
|
||||
use crate::send;
|
||||
use gtk::gio::{Cancellable, MemoryInputStream};
|
||||
use tokio::spawn;
|
||||
use tracing::error;
|
||||
}
|
||||
);
|
||||
|
||||
#[derive(Debug)]
|
||||
enum ImageLocation<'a> {
|
||||
Icon {
|
||||
name: String,
|
||||
theme: &'a IconTheme,
|
||||
},
|
||||
Local(PathBuf),
|
||||
Steam(String),
|
||||
#[cfg(feature = "http")]
|
||||
Remote(reqwest::Url),
|
||||
}
|
||||
|
||||
pub struct ImageProvider<'a> {
|
||||
location: ImageLocation<'a>,
|
||||
size: i32,
|
||||
}
|
||||
|
||||
impl<'a> ImageProvider<'a> {
|
||||
/// Attempts to parse the image input to find its location.
|
||||
/// Errors if no valid location type can be found.
|
||||
///
|
||||
/// Note this checks that icons exist in theme, or files exist on disk
|
||||
/// but no other check is performed.
|
||||
pub fn parse(input: &str, theme: &'a IconTheme, size: i32) -> Result<Self> {
|
||||
let location = Self::get_location(input, theme, size)?;
|
||||
Ok(Self { location, size })
|
||||
}
|
||||
|
||||
/// Returns true if the input starts with a prefix
|
||||
/// that is supported by the parser
|
||||
/// (ie the parser would not fallback to checking the input).
|
||||
#[cfg(any(feature = "music", feature = "workspaces"))]
|
||||
pub fn is_definitely_image_input(input: &str) -> bool {
|
||||
input.starts_with("icon:")
|
||||
|| input.starts_with("file://")
|
||||
|| input.starts_with("http://")
|
||||
|| input.starts_with("https://")
|
||||
}
|
||||
|
||||
fn get_location(input: &str, theme: &'a IconTheme, size: i32) -> Result<ImageLocation<'a>> {
|
||||
let (input_type, input_name) = input
|
||||
.split_once(':')
|
||||
.map_or((None, input), |(t, n)| (Some(t), n));
|
||||
|
||||
match input_type {
|
||||
Some(input_type) if input_type == "icon" => Ok(ImageLocation::Icon {
|
||||
name: input_name.to_string(),
|
||||
theme,
|
||||
}),
|
||||
Some(input_type) if input_type == "file" => Ok(ImageLocation::Local(PathBuf::from(
|
||||
input_name[2..].to_string(),
|
||||
))),
|
||||
#[cfg(feature = "http")]
|
||||
Some(input_type) if input_type == "http" || input_type == "https" => {
|
||||
Ok(ImageLocation::Remote(input.parse()?))
|
||||
}
|
||||
None if input.starts_with("steam_app_") => Ok(ImageLocation::Steam(
|
||||
input_name.chars().skip("steam_app_".len()).collect(),
|
||||
)),
|
||||
None if theme
|
||||
.lookup_icon(input, size, IconLookupFlags::empty())
|
||||
.is_some() =>
|
||||
{
|
||||
Ok(ImageLocation::Icon {
|
||||
name: input_name.to_string(),
|
||||
theme,
|
||||
})
|
||||
}
|
||||
Some(input_type) => Err(Report::msg(format!("Unsupported image type: {input_type}"))
|
||||
.note("You may need to recompile with support if available")),
|
||||
None if PathBuf::from(input_name).is_file() => {
|
||||
Ok(ImageLocation::Local(PathBuf::from(input_name)))
|
||||
}
|
||||
None => get_desktop_icon_name(input_name).map_or_else(
|
||||
|| Err(Report::msg(format!("Unknown image type: '{input}'"))),
|
||||
|input| Self::get_location(&input, theme, size),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempts to fetch the image from the location
|
||||
/// and load it into the provided `GTK::Image` widget.
|
||||
pub fn load_into_image(&self, image: gtk::Image) -> Result<()> {
|
||||
// handle remote locations async to avoid blocking UI thread while downloading
|
||||
#[cfg(feature = "http")]
|
||||
if let ImageLocation::Remote(url) = &self.location {
|
||||
let url = url.clone();
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
let bytes = Self::get_bytes_from_http(url).await;
|
||||
if let Ok(bytes) = bytes {
|
||||
send!(tx, bytes);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let size = self.size;
|
||||
rx.attach(None, move |bytes| {
|
||||
let stream = MemoryInputStream::from_bytes(&bytes);
|
||||
let pixbuf = Pixbuf::from_stream_at_scale(
|
||||
&stream,
|
||||
size,
|
||||
size,
|
||||
true,
|
||||
Some(&Cancellable::new()),
|
||||
);
|
||||
|
||||
match pixbuf {
|
||||
Ok(pixbuf) => image.set_pixbuf(Some(&pixbuf)),
|
||||
Err(err) => error!("{err:?}"),
|
||||
}
|
||||
|
||||
Continue(false)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
self.load_into_image_sync(&image)?;
|
||||
};
|
||||
|
||||
#[cfg(not(feature = "http"))]
|
||||
self.load_into_image_sync(&image)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_into_image_sync(&self, image: >k::Image) -> Result<()> {
|
||||
let pixbuf = match &self.location {
|
||||
ImageLocation::Icon { name, theme } => {
|
||||
self.get_from_icon(name, theme, image.scale_factor())
|
||||
}
|
||||
ImageLocation::Local(path) => self.get_from_file(path),
|
||||
ImageLocation::Steam(steam_id) => self.get_from_steam_id(steam_id),
|
||||
#[cfg(feature = "http")]
|
||||
_ => unreachable!(), // handled above
|
||||
}?;
|
||||
|
||||
image.set_pixbuf(Some(&pixbuf));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Attempts to get a `Pixbuf` from the GTK icon theme.
|
||||
fn get_from_icon(&self, name: &str, theme: &IconTheme, scale: i32) -> Result<Pixbuf> {
|
||||
let pixbuf =
|
||||
match theme.lookup_icon_for_scale(name, self.size, scale, IconLookupFlags::empty()) {
|
||||
Some(_) => theme.load_icon(name, self.size, IconLookupFlags::FORCE_SIZE),
|
||||
None => Ok(None),
|
||||
}?;
|
||||
|
||||
pixbuf.map_or_else(
|
||||
|| Err(Report::msg("Icon theme does not contain icon '{name}'")),
|
||||
Ok,
|
||||
)
|
||||
}
|
||||
|
||||
/// Attempts to get a `Pixbuf` from a local file.
|
||||
fn get_from_file(&self, path: &Path) -> Result<Pixbuf> {
|
||||
let pixbuf = Pixbuf::from_file_at_scale(path, self.size, self.size, true)?;
|
||||
Ok(pixbuf)
|
||||
}
|
||||
|
||||
/// Attempts to get a `Pixbuf` from a local file,
|
||||
/// using the Steam game ID to look it up.
|
||||
fn get_from_steam_id(&self, steam_id: &str) -> Result<Pixbuf> {
|
||||
// TODO: Can we load this from icon theme with app id `steam_icon_{}`?
|
||||
let path = dirs::data_dir().map_or_else(
|
||||
|| Err(Report::msg("Missing XDG data dir")),
|
||||
|dir| {
|
||||
Ok(dir.join(format!(
|
||||
"icons/hicolor/32x32/apps/steam_icon_{steam_id}.png"
|
||||
)))
|
||||
},
|
||||
)?;
|
||||
|
||||
self.get_from_file(&path)
|
||||
}
|
||||
|
||||
/// Attempts to get `Bytes` from an HTTP resource asynchronously.
|
||||
#[cfg(feature = "http")]
|
||||
async fn get_bytes_from_http(url: reqwest::Url) -> Result<glib::Bytes> {
|
||||
let res = reqwest::get(url).await?;
|
||||
|
||||
let status = res.status();
|
||||
if status.is_success() {
|
||||
let bytes = res.bytes().await?;
|
||||
Ok(glib::Bytes::from_owned(bytes))
|
||||
} else {
|
||||
Err(Report::msg(format!(
|
||||
"Received non-success HTTP code ({status})"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
use color_eyre::Result;
|
||||
use dirs::data_dir;
|
||||
use std::env;
|
||||
use std::{env, panic};
|
||||
use strip_ansi_escapes::Writer;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
|
||||
use tracing_error::ErrorLayer;
|
||||
use tracing_subscriber::fmt::{Layer, MakeWriter};
|
||||
@@ -26,15 +27,43 @@ impl<'a> MakeWriter<'a> for MakeFileWriter {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_logging() -> Result<WorkerGuard> {
|
||||
// Disable backtraces by default
|
||||
if env::var("RUST_LIB_BACKTRACE").is_err() {
|
||||
env::set_var("RUST_LIB_BACKTRACE", "0");
|
||||
}
|
||||
|
||||
// keep guard in scope
|
||||
// otherwise file logging drops
|
||||
let guard = install_tracing()?;
|
||||
|
||||
let hook_builder = color_eyre::config::HookBuilder::default();
|
||||
let (panic_hook, eyre_hook) = hook_builder.into_hooks();
|
||||
|
||||
eyre_hook.install()?;
|
||||
|
||||
// custom hook allows tracing_appender to capture panics
|
||||
panic::set_hook(Box::new(move |panic_info| {
|
||||
error!("{}", panic_hook.panic_report(panic_info));
|
||||
}));
|
||||
|
||||
Ok(guard)
|
||||
}
|
||||
|
||||
/// Installs tracing into the current application.
|
||||
///
|
||||
/// The returned `WorkerGuard` must remain in scope
|
||||
/// for the lifetime of the application for logging to file to work.
|
||||
pub fn install_tracing() -> Result<WorkerGuard> {
|
||||
fn install_tracing() -> Result<WorkerGuard> {
|
||||
const DEFAULT_LOG: &str = "info";
|
||||
const DEFAULT_FILE_LOG: &str = "warn";
|
||||
|
||||
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");
|
||||
|
||||
|
||||
89
src/macros.rs
Normal file
89
src/macros.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
/// Sends a message on an asynchronous `Sender` using `send()`
|
||||
/// Panics if the message cannot be sent.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// send_async!(tx, "my message");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! send_async {
|
||||
($tx:expr, $msg:expr) => {
|
||||
$tx.send($msg).await.expect($crate::error::ERR_CHANNEL_SEND)
|
||||
};
|
||||
}
|
||||
|
||||
/// Sends a message on an synchronous `Sender` using `send()`
|
||||
/// Panics if the message cannot be sent.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// send!(tx, "my message");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! send {
|
||||
($tx:expr, $msg:expr) => {
|
||||
$tx.send($msg).expect($crate::error::ERR_CHANNEL_SEND)
|
||||
};
|
||||
}
|
||||
|
||||
/// Sends a message on an synchronous `Sender` using `try_send()`
|
||||
/// Panics if the message cannot be sent.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// try_send!(tx, "my message");
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! try_send {
|
||||
($tx:expr, $msg:expr) => {
|
||||
$tx.try_send($msg).expect($crate::error::ERR_CHANNEL_SEND)
|
||||
};
|
||||
}
|
||||
|
||||
/// Locks a `Mutex`.
|
||||
/// Panics if the `Mutex` cannot be locked.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// let mut val = lock!(my_mutex);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! lock {
|
||||
($mutex:expr) => {
|
||||
$mutex.lock().expect($crate::error::ERR_MUTEX_LOCK)
|
||||
};
|
||||
}
|
||||
|
||||
/// Gets a read lock on a `RwLock`.
|
||||
/// Panics if the `RwLock` cannot be locked.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// let val = read_lock!(my_rwlock);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! read_lock {
|
||||
($rwlock:expr) => {
|
||||
$rwlock.read().expect($crate::error::ERR_READ_LOCK)
|
||||
};
|
||||
}
|
||||
|
||||
/// Gets a write lock on a `RwLock`.
|
||||
/// Panics if the `RwLock` cannot be locked.
|
||||
///
|
||||
/// Usage:
|
||||
///
|
||||
/// ```rs
|
||||
/// let mut val = write_lock!(my_rwlock);
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! write_lock {
|
||||
($rwlock:expr) => {
|
||||
$rwlock.write().expect($crate::error::ERR_WRITE_LOCK)
|
||||
};
|
||||
}
|
||||
151
src/main.rs
151
src/main.rs
@@ -1,90 +1,103 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
mod bar;
|
||||
mod collection;
|
||||
mod bridge_channel;
|
||||
mod clients;
|
||||
mod config;
|
||||
mod icon;
|
||||
mod desktop_file;
|
||||
mod dynamic_string;
|
||||
mod error;
|
||||
mod image;
|
||||
mod logging;
|
||||
mod macros;
|
||||
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::gdk::{Display, Monitor};
|
||||
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 tokio::runtime::Handle;
|
||||
use tokio::task::block_in_place;
|
||||
|
||||
use crate::logging::install_tracing;
|
||||
use crate::error::ExitCode;
|
||||
use clients::wayland::{self, WaylandClient};
|
||||
use tracing::{debug, error, info};
|
||||
use universal_config::ConfigLoader;
|
||||
|
||||
const GTK_APP_ID: &str = "dev.jstanger.ironbar";
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// Disable backtraces by default
|
||||
if env::var("RUST_LIB_BACKTRACE").is_err() {
|
||||
env::set_var("RUST_LIB_BACKTRACE", "0");
|
||||
}
|
||||
|
||||
// keep guard in scope
|
||||
// otherwise file logging drops
|
||||
let _guard = install_tracing()?;
|
||||
|
||||
color_eyre::install()?;
|
||||
let _guard = logging::install_logging();
|
||||
|
||||
info!("Ironbar version {}", VERSION);
|
||||
info!("Starting application");
|
||||
|
||||
let app = Application::builder()
|
||||
.application_id("dev.jstanger.ironbar")
|
||||
.build();
|
||||
let wayland_client = wayland::get_client().await;
|
||||
|
||||
let app = Application::builder().application_id(GTK_APP_ID).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(ExitCode::GtkDisplay as i32)
|
||||
},
|
||||
|display| display,
|
||||
);
|
||||
|
||||
let config = match Config::load() {
|
||||
let config_res = env::var("IRONBAR_CONFIG").map_or_else(
|
||||
|_| ConfigLoader::new("ironbar").find_and_load(),
|
||||
ConfigLoader::load,
|
||||
);
|
||||
|
||||
let config = match config_res {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
error!("{:?}", err);
|
||||
Config::default()
|
||||
exit(ExitCode::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(ExitCode::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(ExitCode::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 +109,46 @@ 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);
|
||||
debug!("Received {} outputs from Wayland", outputs.len());
|
||||
debug!("Outputs: {:?}", outputs);
|
||||
|
||||
match outputs {
|
||||
Ok(outputs) => Ok(outputs),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}?;
|
||||
for monitor in display.monitors().iter::<Monitor>() {
|
||||
let monitor = monitor.unwrap();
|
||||
}
|
||||
|
||||
let outputs = serde_json::from_slice::<Vec<SwayOutput>>(&outputs)?;
|
||||
|
||||
debug!("Received {} outputs from Sway IPC", outputs.len());
|
||||
|
||||
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(error::ERR_OUTPUTS))?;
|
||||
let output = outputs
|
||||
.get(i as usize)
|
||||
.ok_or_else(|| Report::msg(error::ERR_OUTPUTS))?;
|
||||
let monitor_name = &output.name;
|
||||
|
||||
info!("Creating bar on '{}'", monitor_name);
|
||||
|
||||
// TODO: Could we use an Arc<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 +162,17 @@ 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.
|
||||
///
|
||||
/// TODO: remove all instances of this once async trait funcs are stable
|
||||
pub fn await_sync<F: Future>(f: F) -> F::Output {
|
||||
block_in_place(|| Handle::current().block_on(f))
|
||||
}
|
||||
|
||||
321
src/modules/clipboard.rs
Normal file
321
src/modules/clipboard.rs
Normal file
@@ -0,0 +1,321 @@
|
||||
use crate::clients::clipboard::{self, ClipboardEvent};
|
||||
use crate::clients::wayland::{ClipboardItem, ClipboardValue};
|
||||
use crate::config::{CommonConfig, TruncateMode};
|
||||
use crate::image::new_icon_button;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use crate::try_send;
|
||||
use gtk::gdk_pixbuf::Pixbuf;
|
||||
use gtk::gio::{Cancellable, MemoryInputStream};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Image, Label, Orientation, RadioButton, Widget};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use glib::signal::Inhibit;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ClipboardModule {
|
||||
#[serde(default = "default_icon")]
|
||||
icon: String,
|
||||
|
||||
#[serde(default = "default_icon_size")]
|
||||
icon_size: i32,
|
||||
|
||||
#[serde(default = "default_max_items")]
|
||||
max_items: usize,
|
||||
|
||||
// -- Common --
|
||||
truncate: Option<TruncateMode>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
fn default_icon() -> String {
|
||||
String::from("")
|
||||
}
|
||||
|
||||
const fn default_icon_size() -> i32 {
|
||||
32
|
||||
}
|
||||
|
||||
const fn default_max_items() -> usize {
|
||||
10
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ControllerEvent {
|
||||
Add(usize, Arc<ClipboardItem>),
|
||||
Remove(usize),
|
||||
Activate(usize),
|
||||
Deactivate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum UIEvent {
|
||||
Copy(usize),
|
||||
Remove(usize),
|
||||
}
|
||||
|
||||
impl Module<Button> for ClipboardModule {
|
||||
type SendMessage = ControllerEvent;
|
||||
type ReceiveMessage = UIEvent;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"clipboard"
|
||||
}
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
mut rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> color_eyre::Result<()> {
|
||||
let max_items = self.max_items;
|
||||
|
||||
// listen to clipboard events
|
||||
spawn(async move {
|
||||
let mut rx = {
|
||||
let client = clipboard::get_client();
|
||||
client.subscribe(max_items).await
|
||||
};
|
||||
|
||||
while let Some(event) = rx.recv().await {
|
||||
match event {
|
||||
ClipboardEvent::Add(item) => {
|
||||
let msg = match &item.value {
|
||||
ClipboardValue::Other => {
|
||||
ModuleUpdateEvent::Update(ControllerEvent::Deactivate)
|
||||
}
|
||||
_ => ModuleUpdateEvent::Update(ControllerEvent::Add(item.id, item)),
|
||||
};
|
||||
try_send!(tx, msg);
|
||||
}
|
||||
ClipboardEvent::Remove(id) => {
|
||||
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Remove(id)));
|
||||
}
|
||||
ClipboardEvent::Activate(id) => {
|
||||
try_send!(tx, ModuleUpdateEvent::Update(ControllerEvent::Activate(id)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
error!("Clipboard client unexpectedly closed");
|
||||
});
|
||||
|
||||
// listen to ui events
|
||||
spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
let client = clipboard::get_client();
|
||||
match event {
|
||||
UIEvent::Copy(id) => client.copy(id).await,
|
||||
UIEvent::Remove(id) => client.remove(id),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> color_eyre::Result<ModuleWidget<Button>> {
|
||||
let position = info.bar_position;
|
||||
|
||||
let button = new_icon_button(&self.icon, info.icon_theme, self.icon_size);
|
||||
button.style_context().add_class("btn");
|
||||
|
||||
button.connect_clicked(move |button| {
|
||||
let pos = Popup::widget_geometry(button, position.get_orientation());
|
||||
try_send!(context.tx, ModuleUpdateEvent::TogglePopup(pos));
|
||||
});
|
||||
|
||||
// we need to bind to the receiver as the channel does not open
|
||||
// until the popup is first opened.
|
||||
context.widget_rx.attach(None, |_| Continue(true));
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: button,
|
||||
popup: self.into_popup(context.controller_tx, context.popup_rx, info),
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
tx: Sender<Self::ReceiveMessage>,
|
||||
rx: glib::Receiver<Self::SendMessage>,
|
||||
_info: &ModuleInfo,
|
||||
) -> Option<gtk::Box>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(Orientation::Vertical)
|
||||
.spacing(10)
|
||||
.name("popup-clipboard")
|
||||
.build();
|
||||
|
||||
let entries = gtk::Box::new(Orientation::Vertical, 5);
|
||||
container.append(&entries);
|
||||
|
||||
let hidden_option = RadioButton::new();
|
||||
entries.append(&hidden_option);
|
||||
|
||||
let mut items = HashMap::new();
|
||||
|
||||
{
|
||||
let hidden_option = hidden_option.clone();
|
||||
rx.attach(None, move |event| {
|
||||
match event {
|
||||
ControllerEvent::Add(id, item) => {
|
||||
debug!("Adding new value with ID {}", id);
|
||||
|
||||
let row = gtk::Box::new(Orientation::Horizontal, 0);
|
||||
row.style_context().add_class("item");
|
||||
|
||||
let button = match &item.value {
|
||||
ClipboardValue::Text(value) => {
|
||||
let button = RadioButton::from_widget(&hidden_option);
|
||||
|
||||
let label = Label::new(Some(value));
|
||||
button.append(&label);
|
||||
|
||||
if let Some(truncate) = self.truncate {
|
||||
truncate.truncate_label(&label);
|
||||
}
|
||||
|
||||
button.style_context().add_class("text");
|
||||
button
|
||||
}
|
||||
ClipboardValue::Image(bytes) => {
|
||||
let stream = MemoryInputStream::from_bytes(bytes);
|
||||
let pixbuf = Pixbuf::from_stream_at_scale(
|
||||
&stream,
|
||||
128,
|
||||
64,
|
||||
true,
|
||||
Some(&Cancellable::new()),
|
||||
)
|
||||
.expect("Failed to read Pixbuf from stream");
|
||||
let image = Image::from_pixbuf(Some(&pixbuf));
|
||||
|
||||
let button = RadioButton::from_widget(&hidden_option);
|
||||
button.set_image(Some(&image));
|
||||
button.set_always_show_image(true);
|
||||
button.style_context().add_class("image");
|
||||
|
||||
button
|
||||
}
|
||||
ClipboardValue::Other => unreachable!(),
|
||||
};
|
||||
|
||||
button.style_context().add_class("btn");
|
||||
button.set_active(true); // if just added, should be on clipboard
|
||||
|
||||
let button_wrapper = EventBox::new();
|
||||
button_wrapper.append(&button);
|
||||
|
||||
button_wrapper.set_widget_name(&format!("copy-{id}"));
|
||||
button_wrapper.set_above_child(true);
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
button_wrapper.connect_button_press_event(
|
||||
move |button_wrapper, event| {
|
||||
// left click
|
||||
if event.button() == 1 {
|
||||
let id = get_button_id(button_wrapper)
|
||||
.expect("Failed to get id from button name");
|
||||
|
||||
debug!("Copying item with id: {id}");
|
||||
try_send!(tx, UIEvent::Copy(id));
|
||||
}
|
||||
|
||||
Inhibit(true)
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let remove_button = Button::with_label("x");
|
||||
remove_button.set_widget_name(&format!("remove-{id}"));
|
||||
remove_button.style_context().add_class("btn-remove");
|
||||
|
||||
{
|
||||
let tx = tx.clone();
|
||||
let entries = entries.clone();
|
||||
let row = row.clone();
|
||||
|
||||
remove_button.connect_clicked(move |button| {
|
||||
let id = get_button_id(button)
|
||||
.expect("Failed to get id from button name");
|
||||
|
||||
debug!("Removing item with id: {id}");
|
||||
try_send!(tx, UIEvent::Remove(id));
|
||||
|
||||
entries.remove(&row);
|
||||
});
|
||||
}
|
||||
|
||||
row.append(&button_wrapper);
|
||||
row.pack_end(&remove_button, false, false, 0);
|
||||
|
||||
entries.append(&row);
|
||||
entries.reorder_child(&row, 0);
|
||||
|
||||
items.insert(id, (row, button));
|
||||
}
|
||||
ControllerEvent::Remove(id) => {
|
||||
debug!("Removing option with ID {id}");
|
||||
let row = items.remove(&id);
|
||||
if let Some((row, button)) = row {
|
||||
if button.is_active() {
|
||||
hidden_option.set_active(true);
|
||||
}
|
||||
|
||||
entries.remove(&row);
|
||||
}
|
||||
}
|
||||
ControllerEvent::Activate(id) => {
|
||||
debug!("Activating option with ID {id}");
|
||||
|
||||
hidden_option.set_active(false);
|
||||
let row = items.get(&id);
|
||||
if let Some((_, button)) = row {
|
||||
button.set_active(true);
|
||||
}
|
||||
}
|
||||
ControllerEvent::Deactivate => {
|
||||
debug!("Deactivating current option");
|
||||
hidden_option.set_active(true);
|
||||
}
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
hidden_option.hide();
|
||||
|
||||
Some(container)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the ID from a widget's name.
|
||||
///
|
||||
/// This expects the button name to be
|
||||
/// in the format `<purpose>-<id>`.
|
||||
fn get_button_id<W>(button_wrapper: &W) -> Option<usize>
|
||||
where
|
||||
W: IsA<Widget>,
|
||||
{
|
||||
button_wrapper
|
||||
.widget_name()
|
||||
.split_once('-')
|
||||
.and_then(|(_, id)| id.parse().ok())
|
||||
}
|
||||
125
src/modules/clock.rs
Normal file
125
src/modules/clock.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::popup::Popup;
|
||||
use crate::{send_async, try_send};
|
||||
use chrono::{DateTime, Local};
|
||||
use color_eyre::Result;
|
||||
use glib::Continue;
|
||||
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: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
fn default_format() -> String {
|
||||
String::from("%d/%m/%Y %H:%M")
|
||||
}
|
||||
|
||||
impl Module<Button> for ClockModule {
|
||||
type SendMessage = DateTime<Local>;
|
||||
type ReceiveMessage = ();
|
||||
|
||||
fn name() -> &'static str {
|
||||
"clock"
|
||||
}
|
||||
|
||||
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();
|
||||
send_async!(tx, ModuleUpdateEvent::Update(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.append(&label);
|
||||
|
||||
let orientation = info.bar_position.get_orientation();
|
||||
button.connect_clicked(move |button| {
|
||||
try_send!(
|
||||
context.tx,
|
||||
ModuleUpdateEvent::TogglePopup(Popup::widget_geometry(button, orientation))
|
||||
);
|
||||
});
|
||||
|
||||
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, info);
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: button,
|
||||
popup,
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
_tx: mpsc::Sender<Self::ReceiveMessage>,
|
||||
rx: glib::Receiver<Self::SendMessage>,
|
||||
_info: &ModuleInfo,
|
||||
) -> 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.append(&clock);
|
||||
|
||||
let calendar = Calendar::builder().name("calendar").build();
|
||||
container.append(&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)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/modules/custom/box.rs
Normal file
36
src/modules/custom/box.rs
Normal file
@@ -0,0 +1,36 @@
|
||||
use super::{try_get_orientation, CustomWidget, CustomWidgetContext};
|
||||
use crate::build;
|
||||
use crate::modules::custom::WidgetConfig;
|
||||
use gtk::prelude::*;
|
||||
use gtk::Orientation;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct BoxWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
orientation: Option<String>,
|
||||
widgets: Option<Vec<WidgetConfig>>,
|
||||
}
|
||||
|
||||
impl CustomWidget for BoxWidget {
|
||||
type Widget = gtk::Box;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
|
||||
let container = build!(self, Self::Widget);
|
||||
|
||||
if let Some(orientation) = self.orientation {
|
||||
container.set_orientation(
|
||||
try_get_orientation(&orientation).unwrap_or(Orientation::Horizontal),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(widgets) = self.widgets {
|
||||
for widget in widgets {
|
||||
widget.widget.add_to(&container, context, widget.common);
|
||||
}
|
||||
}
|
||||
|
||||
container
|
||||
}
|
||||
}
|
||||
52
src/modules/custom/button.rs
Normal file
52
src/modules/custom/button.rs
Normal file
@@ -0,0 +1,52 @@
|
||||
use super::{CustomWidget, CustomWidgetContext, ExecEvent};
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::popup::Popup;
|
||||
use crate::{build, try_send};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, Label};
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ButtonWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
label: Option<String>,
|
||||
on_click: Option<String>,
|
||||
}
|
||||
|
||||
impl CustomWidget for ButtonWidget {
|
||||
type Widget = Button;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
|
||||
let button = build!(self, Self::Widget);
|
||||
|
||||
if let Some(text) = self.label {
|
||||
let label = Label::new(None);
|
||||
label.set_use_markup(true);
|
||||
button.append(&label);
|
||||
|
||||
DynamicString::new(&text, move |string| {
|
||||
label.set_markup(&string);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(exec) = self.on_click {
|
||||
let bar_orientation = context.bar_orientation;
|
||||
let tx = context.tx.clone();
|
||||
|
||||
button.connect_clicked(move |button| {
|
||||
try_send!(
|
||||
tx,
|
||||
ExecEvent {
|
||||
cmd: exec.clone(),
|
||||
args: None,
|
||||
geometry: Popup::widget_geometry(button, bar_orientation),
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
button
|
||||
}
|
||||
}
|
||||
47
src/modules/custom/image.rs
Normal file
47
src/modules/custom/image.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use super::{CustomWidget, CustomWidgetContext};
|
||||
use crate::build;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::image::ImageProvider;
|
||||
use gtk::prelude::*;
|
||||
use gtk::Image;
|
||||
use serde::Deserialize;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ImageWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
src: String,
|
||||
#[serde(default = "default_size")]
|
||||
size: i32,
|
||||
}
|
||||
|
||||
const fn default_size() -> i32 {
|
||||
32
|
||||
}
|
||||
|
||||
impl CustomWidget for ImageWidget {
|
||||
type Widget = Image;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
|
||||
let gtk_image = build!(self, Self::Widget);
|
||||
|
||||
{
|
||||
let gtk_image = gtk_image.clone();
|
||||
let icon_theme = context.icon_theme.clone();
|
||||
|
||||
DynamicString::new(&self.src, move |src| {
|
||||
let res = ImageProvider::parse(&src, &icon_theme, self.size)
|
||||
.and_then(|image| image.load_into_image(gtk_image.clone()));
|
||||
|
||||
if let Err(err) = res {
|
||||
error!("{err:?}");
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
gtk_image
|
||||
}
|
||||
}
|
||||
33
src/modules/custom/label.rs
Normal file
33
src/modules/custom/label.rs
Normal file
@@ -0,0 +1,33 @@
|
||||
use super::{CustomWidget, CustomWidgetContext};
|
||||
use crate::build;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use gtk::prelude::*;
|
||||
use gtk::Label;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LabelWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl CustomWidget for LabelWidget {
|
||||
type Widget = Label;
|
||||
|
||||
fn into_widget(self, _context: CustomWidgetContext) -> Self::Widget {
|
||||
let label = build!(self, Self::Widget);
|
||||
|
||||
label.set_use_markup(true);
|
||||
|
||||
{
|
||||
let label = label.clone();
|
||||
DynamicString::new(&self.label, move |string| {
|
||||
label.set_markup(&string);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
label
|
||||
}
|
||||
}
|
||||
257
src/modules/custom/mod.rs
Normal file
257
src/modules/custom/mod.rs
Normal file
@@ -0,0 +1,257 @@
|
||||
mod r#box;
|
||||
mod button;
|
||||
mod image;
|
||||
mod label;
|
||||
mod progress;
|
||||
mod slider;
|
||||
|
||||
use self::image::ImageWidget;
|
||||
use self::label::LabelWidget;
|
||||
use self::r#box::BoxWidget;
|
||||
use self::slider::SliderWidget;
|
||||
use crate::config::CommonConfig;
|
||||
use crate::modules::custom::button::ButtonWidget;
|
||||
use crate::modules::custom::progress::ProgressWidget;
|
||||
use crate::modules::{
|
||||
wrap_widget, Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext,
|
||||
};
|
||||
use crate::popup::WidgetGeometry;
|
||||
use crate::script::Script;
|
||||
use crate::send_async;
|
||||
use color_eyre::{Report, Result};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconTheme, Orientation};
|
||||
use serde::Deserialize;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::{debug, error};
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct CustomModule {
|
||||
/// Container class name
|
||||
class: Option<String>,
|
||||
/// Widgets to add to the bar container
|
||||
bar: Vec<WidgetConfig>,
|
||||
/// Widgets to add to the popup container
|
||||
popup: Option<Vec<WidgetConfig>>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct WidgetConfig {
|
||||
#[serde(flatten)]
|
||||
widget: Widget,
|
||||
#[serde(flatten)]
|
||||
common: CommonConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum Widget {
|
||||
Box(BoxWidget),
|
||||
Label(LabelWidget),
|
||||
Button(ButtonWidget),
|
||||
Image(ImageWidget),
|
||||
Slider(SliderWidget),
|
||||
Progress(ProgressWidget),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct CustomWidgetContext<'a> {
|
||||
tx: &'a Sender<ExecEvent>,
|
||||
bar_orientation: Orientation,
|
||||
icon_theme: &'a IconTheme,
|
||||
}
|
||||
|
||||
trait CustomWidget {
|
||||
type Widget;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget;
|
||||
}
|
||||
|
||||
/// Creates a new widget of type `ty`,
|
||||
/// setting its name and class based on
|
||||
/// the values available on `self`.
|
||||
#[macro_export]
|
||||
macro_rules! build {
|
||||
($self:ident, $ty:ty) => {{
|
||||
let mut builder = <$ty>::builder();
|
||||
|
||||
if let Some(name) = &$self.name {
|
||||
builder = builder.name(name);
|
||||
}
|
||||
|
||||
let widget = builder.build();
|
||||
|
||||
if let Some(class) = &$self.class {
|
||||
widget.style_context().add_class(class);
|
||||
}
|
||||
|
||||
widget
|
||||
}};
|
||||
}
|
||||
|
||||
/// Sets the widget length,
|
||||
/// using either a width or height request
|
||||
/// based on the bar's orientation.
|
||||
pub fn set_length<W: WidgetExt>(widget: &W, length: i32, bar_orientation: Orientation) {
|
||||
match bar_orientation {
|
||||
Orientation::Horizontal => widget.set_width_request(length),
|
||||
Orientation::Vertical => widget.set_height_request(length),
|
||||
_ => {}
|
||||
};
|
||||
}
|
||||
|
||||
/// Attempts to parse an `Orientation` from `String`.
|
||||
/// Will accept `horizontal`, `vertical`, `h` or `v`.
|
||||
/// Ignores case.
|
||||
fn try_get_orientation(orientation: &str) -> Result<Orientation> {
|
||||
match orientation.to_lowercase().as_str() {
|
||||
"horizontal" | "h" => Ok(Orientation::Horizontal),
|
||||
"vertical" | "v" => Ok(Orientation::Vertical),
|
||||
_ => Err(Report::msg("Invalid orientation string in config")),
|
||||
}
|
||||
}
|
||||
|
||||
impl Widget {
|
||||
/// Creates this widget and adds it to the parent container
|
||||
fn add_to(self, parent: >k::Box, context: CustomWidgetContext, common: CommonConfig) {
|
||||
macro_rules! create {
|
||||
($widget:expr) => {
|
||||
wrap_widget(
|
||||
&$widget.into_widget(context),
|
||||
common,
|
||||
context.bar_orientation,
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
let event_box = match self {
|
||||
Self::Box(widget) => create!(widget),
|
||||
Self::Label(widget) => create!(widget),
|
||||
Self::Button(widget) => create!(widget),
|
||||
Self::Image(widget) => create!(widget),
|
||||
Self::Slider(widget) => create!(widget),
|
||||
Self::Progress(widget) => create!(widget),
|
||||
};
|
||||
|
||||
parent.add(&event_box);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ExecEvent {
|
||||
cmd: String,
|
||||
args: Option<Vec<String>>,
|
||||
geometry: WidgetGeometry,
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for CustomModule {
|
||||
type SendMessage = ();
|
||||
type ReceiveMessage = ExecEvent;
|
||||
|
||||
fn name() -> &'static str {
|
||||
"custom"
|
||||
}
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
mut rx: Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
spawn(async move {
|
||||
while let Some(event) = rx.recv().await {
|
||||
if event.cmd.starts_with('!') {
|
||||
let script = Script::from(&event.cmd[1..]);
|
||||
|
||||
debug!("executing command: '{}'", script.cmd);
|
||||
|
||||
let args = event.args.unwrap_or_default();
|
||||
|
||||
if let Err(err) = script.get_output(Some(&args)).await {
|
||||
error!("{err:?}");
|
||||
}
|
||||
} else if event.cmd == "popup:toggle" {
|
||||
send_async!(tx, ModuleUpdateEvent::TogglePopup(event.geometry));
|
||||
} else if event.cmd == "popup:open" {
|
||||
send_async!(tx, ModuleUpdateEvent::OpenPopup(event.geometry));
|
||||
} else if event.cmd == "popup:close" {
|
||||
send_async!(tx, ModuleUpdateEvent::ClosePopup);
|
||||
} else {
|
||||
error!("Received invalid command: '{}'", event.cmd);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> Result<ModuleWidget<gtk::Box>> {
|
||||
let orientation = info.bar_position.get_orientation();
|
||||
let container = gtk::Box::builder().orientation(orientation).build();
|
||||
|
||||
if let Some(ref class) = self.class {
|
||||
container.style_context().add_class(class);
|
||||
}
|
||||
|
||||
let custom_context = CustomWidgetContext {
|
||||
tx: &context.controller_tx,
|
||||
bar_orientation: orientation,
|
||||
icon_theme: info.icon_theme,
|
||||
};
|
||||
|
||||
self.bar.clone().into_iter().for_each(|widget| {
|
||||
widget
|
||||
.widget
|
||||
.add_to(&container, custom_context, widget.common);
|
||||
});
|
||||
|
||||
let popup = self.into_popup(context.controller_tx, context.popup_rx, info);
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup,
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
tx: Sender<Self::ReceiveMessage>,
|
||||
_rx: glib::Receiver<Self::SendMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> Option<gtk::Box>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
let container = gtk::Box::builder().name("popup-custom").build();
|
||||
|
||||
if let Some(class) = self.class {
|
||||
container
|
||||
.style_context()
|
||||
.add_class(format!("popup-{class}").as_str());
|
||||
}
|
||||
|
||||
if let Some(popup) = self.popup {
|
||||
let custom_context = CustomWidgetContext {
|
||||
tx: &tx,
|
||||
bar_orientation: info.bar_position.get_orientation(),
|
||||
icon_theme: info.icon_theme,
|
||||
};
|
||||
|
||||
for widget in popup {
|
||||
widget
|
||||
.widget
|
||||
.add_to(&container, custom_context, widget.common);
|
||||
}
|
||||
}
|
||||
|
||||
Some(container)
|
||||
}
|
||||
}
|
||||
80
src/modules/custom/progress.rs
Normal file
80
src/modules/custom/progress.rs
Normal file
@@ -0,0 +1,80 @@
|
||||
use super::{try_get_orientation, CustomWidget, CustomWidgetContext};
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::modules::custom::set_length;
|
||||
use crate::script::{OutputStream, Script, ScriptInput};
|
||||
use crate::{build, send};
|
||||
use gtk::prelude::*;
|
||||
use gtk::ProgressBar;
|
||||
use serde::Deserialize;
|
||||
use tokio::spawn;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct ProgressWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
orientation: Option<String>,
|
||||
label: Option<String>,
|
||||
value: Option<ScriptInput>,
|
||||
#[serde(default = "default_max")]
|
||||
max: f64,
|
||||
length: Option<i32>,
|
||||
}
|
||||
|
||||
const fn default_max() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
impl CustomWidget for ProgressWidget {
|
||||
type Widget = ProgressBar;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
|
||||
let progress = build!(self, Self::Widget);
|
||||
|
||||
if let Some(orientation) = self.orientation {
|
||||
progress.set_orientation(
|
||||
try_get_orientation(&orientation).unwrap_or(context.bar_orientation),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(length) = self.length {
|
||||
set_length(&progress, length, context.bar_orientation);
|
||||
}
|
||||
|
||||
if let Some(value) = self.value {
|
||||
let script = Script::from(value);
|
||||
let progress = progress.clone();
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(None, move |stream, _success| match stream {
|
||||
OutputStream::Stdout(out) => match out.parse::<f64>() {
|
||||
Ok(value) => send!(tx, value),
|
||||
Err(err) => error!("{err:?}"),
|
||||
},
|
||||
OutputStream::Stderr(err) => error!("{err:?}"),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
rx.attach(None, move |value| {
|
||||
progress.set_fraction(value / self.max);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(text) = self.label {
|
||||
let progress = progress.clone();
|
||||
progress.set_show_text(true);
|
||||
|
||||
DynamicString::new(&text, move |string| {
|
||||
progress.set_text(Some(&string));
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
progress
|
||||
}
|
||||
}
|
||||
129
src/modules/custom/slider.rs
Normal file
129
src/modules/custom/slider.rs
Normal file
@@ -0,0 +1,129 @@
|
||||
use super::{try_get_orientation, CustomWidget, CustomWidgetContext, ExecEvent};
|
||||
use crate::modules::custom::set_length;
|
||||
use crate::popup::Popup;
|
||||
use crate::script::{OutputStream, Script, ScriptInput};
|
||||
use crate::{build, send, try_send};
|
||||
use gtk::prelude::*;
|
||||
use gtk::Scale;
|
||||
use serde::Deserialize;
|
||||
use std::cell::Cell;
|
||||
use std::ops::Neg;
|
||||
use glib::signal::Inhibit;
|
||||
use tokio::spawn;
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SliderWidget {
|
||||
name: Option<String>,
|
||||
class: Option<String>,
|
||||
orientation: Option<String>,
|
||||
value: Option<ScriptInput>,
|
||||
on_change: Option<String>,
|
||||
#[serde(default = "default_min")]
|
||||
min: f64,
|
||||
#[serde(default = "default_max")]
|
||||
max: f64,
|
||||
step: Option<f64>,
|
||||
length: Option<i32>,
|
||||
#[serde(default = "crate::config::default_true")]
|
||||
show_label: bool,
|
||||
}
|
||||
|
||||
const fn default_min() -> f64 {
|
||||
0.0
|
||||
}
|
||||
|
||||
const fn default_max() -> f64 {
|
||||
100.0
|
||||
}
|
||||
|
||||
impl CustomWidget for SliderWidget {
|
||||
type Widget = Scale;
|
||||
|
||||
fn into_widget(self, context: CustomWidgetContext) -> Self::Widget {
|
||||
let scale = build!(self, Self::Widget);
|
||||
|
||||
if let Some(orientation) = self.orientation {
|
||||
scale.set_orientation(
|
||||
try_get_orientation(&orientation).unwrap_or(context.bar_orientation),
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(length) = self.length {
|
||||
set_length(&scale, length, context.bar_orientation);
|
||||
}
|
||||
|
||||
scale.set_range(self.min, self.max);
|
||||
scale.set_draw_value(self.show_label);
|
||||
|
||||
if let Some(on_change) = self.on_change {
|
||||
let min = self.min;
|
||||
let max = self.max;
|
||||
let step = self.step;
|
||||
let tx = context.tx.clone();
|
||||
|
||||
// GTK will spam the same value over and over
|
||||
let prev_value = Cell::new(scale.value());
|
||||
|
||||
scale.connect_scroll_event(move |scale, event| {
|
||||
let value = scale.value();
|
||||
let delta = event.delta().1.neg();
|
||||
|
||||
let delta = match (step, delta.is_sign_positive()) {
|
||||
(Some(step), true) => step,
|
||||
(Some(step), false) => -step,
|
||||
(None, _) => delta,
|
||||
};
|
||||
|
||||
scale.set_value(value + delta);
|
||||
Inhibit(false)
|
||||
});
|
||||
|
||||
scale.connect_change_value(move |scale, _, val| {
|
||||
// GTK will send values outside min/max range
|
||||
let val = val.clamp(min, max);
|
||||
|
||||
if val != prev_value.get() {
|
||||
try_send!(
|
||||
tx,
|
||||
ExecEvent {
|
||||
cmd: on_change.clone(),
|
||||
args: Some(vec![val.to_string()]),
|
||||
geometry: Popup::widget_geometry(scale, context.bar_orientation),
|
||||
}
|
||||
);
|
||||
|
||||
prev_value.set(val);
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(value) = self.value {
|
||||
let script = Script::from(value);
|
||||
let scale = scale.clone();
|
||||
|
||||
let (tx, rx) = glib::MainContext::channel(glib::PRIORITY_DEFAULT);
|
||||
|
||||
spawn(async move {
|
||||
script
|
||||
.run(None, move |stream, _success| match stream {
|
||||
OutputStream::Stdout(out) => match out.parse() {
|
||||
Ok(value) => send!(tx, value),
|
||||
Err(err) => error!("{err:?}"),
|
||||
},
|
||||
OutputStream::Stderr(err) => error!("{err:?}"),
|
||||
})
|
||||
.await;
|
||||
});
|
||||
|
||||
rx.attach(None, move |value| {
|
||||
scale.set_value(value);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
scale
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
use crate::icon;
|
||||
use crate::modules::{Module, ModuleInfo};
|
||||
use crate::sway::get_client;
|
||||
use crate::clients::wayland::{self, ToplevelChange};
|
||||
use crate::config::{CommonConfig, TruncateMode};
|
||||
use crate::image::ImageProvider;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::{await_sync, read_lock, send_async};
|
||||
use color_eyre::Result;
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconTheme, Image, Label, Orientation};
|
||||
use gtk::Label;
|
||||
use serde::Deserialize;
|
||||
use tokio::task::spawn_blocking;
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc::{Receiver, Sender};
|
||||
use tracing::error;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct FocusedModule {
|
||||
@@ -20,8 +24,11 @@ pub struct FocusedModule {
|
||||
/// Icon size in pixels.
|
||||
#[serde(default = "default_icon_size")]
|
||||
icon_size: i32,
|
||||
/// GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
|
||||
truncate: Option<TruncateMode>,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
const fn default_icon_size() -> i32 {
|
||||
@@ -29,74 +36,99 @@ const fn default_icon_size() -> i32 {
|
||||
}
|
||||
|
||||
impl Module<gtk::Box> for FocusedModule {
|
||||
fn into_widget(self, _info: &ModuleInfo) -> Result<gtk::Box> {
|
||||
let icon_theme = IconTheme::new();
|
||||
type SendMessage = (String, String);
|
||||
type ReceiveMessage = ();
|
||||
|
||||
if let Some(theme) = self.icon_theme {
|
||||
icon_theme.set_custom_theme(Some(&theme));
|
||||
fn name() -> &'static str {
|
||||
"focused"
|
||||
}
|
||||
|
||||
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 = read_lock!(wl.toplevels);
|
||||
|
||||
toplevels
|
||||
.iter()
|
||||
.find(|(_, (top, _))| top.active)
|
||||
.map(|(_, (top, _))| top.clone())
|
||||
});
|
||||
|
||||
if let Some(top) = focused {
|
||||
tx.try_send(ModuleUpdateEvent::Update((top.title.clone(), top.app_id)))?;
|
||||
}
|
||||
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 5);
|
||||
|
||||
let icon = Image::builder().name("icon").build();
|
||||
let label = Label::builder().name("label").build();
|
||||
|
||||
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()
|
||||
spawn(async move {
|
||||
let mut wlrx = {
|
||||
let wl = wayland::get_client().await;
|
||||
wl.subscribe_toplevels()
|
||||
};
|
||||
|
||||
while let Ok(payload) = srx.recv() {
|
||||
let update = match payload.change.as_str() {
|
||||
"focus" => true,
|
||||
"title" => payload.container.focused,
|
||||
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(payload.container)
|
||||
.expect("Failed to sendf focus update");
|
||||
send_async!(
|
||||
tx,
|
||||
ModuleUpdateEvent::Update((event.toplevel.title, event.toplevel.app_id))
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> Result<ModuleWidget<gtk::Box>> {
|
||||
let icon_theme = info.icon_theme;
|
||||
|
||||
let container = gtk::Box::new(info.bar_position.get_orientation(), 5);
|
||||
|
||||
let icon = gtk::Image::builder().name("icon").build();
|
||||
let label = Label::builder().name("label").build();
|
||||
|
||||
if let Some(truncate) = self.truncate {
|
||||
truncate.truncate_label(&label);
|
||||
}
|
||||
|
||||
container.append(&icon);
|
||||
container.append(&label);
|
||||
|
||||
{
|
||||
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);
|
||||
|
||||
let icon_theme = icon_theme.clone();
|
||||
context.widget_rx.attach(None, move |(name, id)| {
|
||||
if self.show_icon {
|
||||
icon.set_pixbuf(pixbuf.as_ref());
|
||||
if let Err(err) = ImageProvider::parse(&id, &icon_theme, self.icon_size)
|
||||
.and_then(|image| image.load_into_image(icon.clone()))
|
||||
{
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
if self.show_title {
|
||||
label.set_label(value);
|
||||
label.set_label(&name);
|
||||
}
|
||||
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(container)
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
62
src/modules/label.rs
Normal file
62
src/modules/label.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::config::CommonConfig;
|
||||
use crate::dynamic_string::DynamicString;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::try_send;
|
||||
use color_eyre::Result;
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::Label;
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct LabelModule {
|
||||
label: String,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
impl Module<Label> for LabelModule {
|
||||
type SendMessage = String;
|
||||
type ReceiveMessage = ();
|
||||
|
||||
fn name() -> &'static str {
|
||||
"label"
|
||||
}
|
||||
|
||||
fn spawn_controller(
|
||||
&self,
|
||||
_info: &ModuleInfo,
|
||||
tx: mpsc::Sender<ModuleUpdateEvent<Self::SendMessage>>,
|
||||
_rx: mpsc::Receiver<Self::ReceiveMessage>,
|
||||
) -> Result<()> {
|
||||
DynamicString::new(&self.label, move |string| {
|
||||
try_send!(tx, ModuleUpdateEvent::Update(string));
|
||||
Continue(true)
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
_info: &ModuleInfo,
|
||||
) -> Result<ModuleWidget<Label>> {
|
||||
let label = Label::new(None);
|
||||
|
||||
{
|
||||
let label = label.clone();
|
||||
context.widget_rx.attach(None, move |string| {
|
||||
label.set_label(&string);
|
||||
Continue(true)
|
||||
});
|
||||
}
|
||||
|
||||
Ok(ModuleWidget {
|
||||
widget: label,
|
||||
popup: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,292 +1,268 @@
|
||||
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::image::ImageProvider;
|
||||
use crate::modules::launcher::{ItemEvent, LauncherUpdate};
|
||||
use crate::modules::ModuleUpdateEvent;
|
||||
use crate::popup::Popup;
|
||||
use crate::{read_lock, try_send};
|
||||
use gtk::prelude::*;
|
||||
use gtk::{Button, IconTheme, Image};
|
||||
use std::process::{Command, Stdio};
|
||||
use gtk::{Button, IconTheme, Orientation};
|
||||
use indexmap::IndexMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use tokio::spawn;
|
||||
use tokio::sync::mpsc;
|
||||
use std::sync::RwLock;
|
||||
use glib::signal::Inhibit;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tracing::error;
|
||||
|
||||
#[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>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct AppearanceOptions {
|
||||
pub show_names: bool,
|
||||
pub show_icons: bool,
|
||||
pub icon_size: i32,
|
||||
}
|
||||
|
||||
impl ItemButton {
|
||||
pub fn new(
|
||||
item: &Item,
|
||||
appearance: AppearanceOptions,
|
||||
icon_theme: &IconTheme,
|
||||
orientation: Orientation,
|
||||
tx: &Sender<ModuleUpdateEvent<LauncherUpdate>>,
|
||||
controller_tx: &Sender<ItemEvent>,
|
||||
) -> Self {
|
||||
let mut button = Button::builder();
|
||||
|
||||
if appearance.show_names {
|
||||
button = button.label(&item.name);
|
||||
}
|
||||
|
||||
let button = button.build();
|
||||
|
||||
if appearance.show_icons {
|
||||
let gtk_image = gtk::Image::new();
|
||||
let image =
|
||||
ImageProvider::parse(&item.app_id.clone(), icon_theme, appearance.icon_size);
|
||||
match image {
|
||||
Ok(image) => {
|
||||
button.set_image(Some(>k_image));
|
||||
button.set_always_show_image(true);
|
||||
|
||||
if let Err(err) = image.load_into_image(gtk_image) {
|
||||
error!("{err:?}");
|
||||
}
|
||||
}
|
||||
Err(err) => error!("{err:?}"),
|
||||
};
|
||||
}
|
||||
|
||||
let style_context = button.style_context();
|
||||
style_context.add_class("item");
|
||||
|
||||
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 :| TODO: Improve this
|
||||
let style_context = button.style_context();
|
||||
if style_context.has_class("open") {
|
||||
try_send!(tx, ItemEvent::FocusItem(app_id.clone()));
|
||||
} else {
|
||||
try_send!(tx, ItemEvent::OpenItem(app_id.clone()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = read_lock!(menu_state);
|
||||
|
||||
if menu_state.num_windows > 1 {
|
||||
try_send!(
|
||||
tx,
|
||||
ModuleUpdateEvent::Update(LauncherUpdate::Hover(app_id.clone(),))
|
||||
);
|
||||
|
||||
try_send!(
|
||||
tx,
|
||||
ModuleUpdateEvent::OpenPopup(Popup::widget_geometry(button, orientation))
|
||||
);
|
||||
} else {
|
||||
try_send!(tx, ModuleUpdateEvent::ClosePopup);
|
||||
}
|
||||
|
||||
Inhibit(false)
|
||||
});
|
||||
}
|
||||
|
||||
Self {
|
||||
button,
|
||||
persistent: item.favorite,
|
||||
show_names: appearance.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,30 @@
|
||||
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::desktop_file::find_desktop_file;
|
||||
use crate::modules::launcher::item::AppearanceOptions;
|
||||
use crate::modules::{Module, ModuleInfo, ModuleUpdateEvent, ModuleWidget, WidgetContext};
|
||||
use crate::{lock, read_lock, try_send, write_lock};
|
||||
use color_eyre::{Help, Report};
|
||||
use glib::Continue;
|
||||
use gtk::prelude::*;
|
||||
use gtk::{IconTheme, Orientation};
|
||||
use gtk::{Button, 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.
|
||||
@@ -30,281 +34,503 @@ pub struct LauncherModule {
|
||||
#[serde(default = "crate::config::default_true")]
|
||||
show_icons: bool,
|
||||
|
||||
/// Name of the GTK icon theme to use.
|
||||
icon_theme: Option<String>,
|
||||
#[serde(default = "default_icon_size")]
|
||||
icon_size: i32,
|
||||
|
||||
#[serde(flatten)]
|
||||
pub common: Option<CommonConfig>,
|
||||
}
|
||||
|
||||
const fn default_icon_size() -> i32 {
|
||||
32
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum LauncherUpdate {
|
||||
/// Adds item
|
||||
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 name() -> &'static str {
|
||||
"launcher"
|
||||
}
|
||||
|
||||
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 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 = read_lock!(wl.toplevels);
|
||||
|
||||
let open_windows = open_windows.clone();
|
||||
for (_, (window, _)) in open_windows {
|
||||
let mut items = lock!(items);
|
||||
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 = lock!(items);
|
||||
let items = items.iter();
|
||||
for (_, item) in items {
|
||||
tx.try_send(ModuleUpdateEvent::Update(LauncherUpdate::AddItem(
|
||||
item.clone(),
|
||||
)))?;
|
||||
}
|
||||
|
||||
Ok::<(), Report>(())
|
||||
});
|
||||
}
|
||||
|
||||
let popup = Popup::new(
|
||||
"popup-launcher",
|
||||
info.app,
|
||||
info.monitor,
|
||||
Orientation::Vertical,
|
||||
info.bar_position,
|
||||
);
|
||||
let container = gtk::Box::new(Orientation::Horizontal, 0);
|
||||
let items2 = Arc::clone(&items);
|
||||
spawn(async move {
|
||||
let items = items2;
|
||||
|
||||
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()
|
||||
let mut wlrx = {
|
||||
let wl = wayland::get_client().await;
|
||||
wl.subscribe_toplevels()
|
||||
};
|
||||
|
||||
while let Ok(payload) = srx.recv() {
|
||||
tx.send(payload)
|
||||
.expect("Failed to send window event payload");
|
||||
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();
|
||||
|
||||
match event.change {
|
||||
ToplevelChange::New => {
|
||||
let new_item = {
|
||||
let mut items = lock!(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 = lock!(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 mut update_title = false;
|
||||
|
||||
if focused {
|
||||
if let Some(item) = lock!(items).get_mut(&app_id) {
|
||||
item.set_window_focused(window.id, true);
|
||||
|
||||
// might be switching focus between windows of same app
|
||||
if item.windows.len() > 1 {
|
||||
item.set_window_name(window.id, window.title.clone());
|
||||
update_title = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) = lock!(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 = lock!(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), // FIXME: Broken on wlroots-git
|
||||
ItemEvent::OpenItem(_) => unreachable!(),
|
||||
};
|
||||
|
||||
if let Some(id) = id {
|
||||
let toplevels = read_lock!(wl.toplevels);
|
||||
let seat = wl.seats.first().expect("Failed to get Wayland seat");
|
||||
if let Some((_top, handle)) = toplevels.get(&id) {
|
||||
handle.activate(seat);
|
||||
};
|
||||
}
|
||||
|
||||
// roundtrip to immediately send activate event
|
||||
wl.roundtrip();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn into_widget(
|
||||
self,
|
||||
context: WidgetContext<Self::SendMessage, Self::ReceiveMessage>,
|
||||
info: &ModuleInfo,
|
||||
) -> crate::Result<ModuleWidget<gtk::Box>> {
|
||||
let icon_theme = info.icon_theme;
|
||||
|
||||
let container = gtk::Box::new(info.bar_position.get_orientation(), 0);
|
||||
|
||||
{
|
||||
let container = container.clone();
|
||||
let icon_theme = icon_theme.clone();
|
||||
|
||||
let controller_tx = context.controller_tx.clone();
|
||||
|
||||
let appearance_options = AppearanceOptions {
|
||||
show_names: self.show_names,
|
||||
show_icons: self.show_icons,
|
||||
icon_size: self.icon_size,
|
||||
};
|
||||
|
||||
let show_names = self.show_names;
|
||||
let orientation = info.bar_position.get_orientation();
|
||||
|
||||
let mut buttons = IndexMap::<String, ItemButton>::new();
|
||||
|
||||
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,
|
||||
appearance_options,
|
||||
&icon_theme,
|
||||
orientation,
|
||||
&context.tx,
|
||||
&controller_tx,
|
||||
);
|
||||
|
||||
container.append(&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 = write_lock!(button.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 = write_lock!(button.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, info);
|
||||
Ok(ModuleWidget {
|
||||
widget: container,
|
||||
popup,
|
||||
})
|
||||
}
|
||||
|
||||
fn into_popup(
|
||||
self,
|
||||
controller_tx: Sender<Self::ReceiveMessage>,
|
||||
rx: glib::Receiver<Self::SendMessage>,
|
||||
_info: &ModuleInfo,
|
||||
) -> 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.append(&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| {
|
||||
try_send!(tx, ItemEvent::FocusWindow(win.id));
|
||||
|
||||
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| {
|
||||
try_send!(tx, ItemEvent::FocusWindow(win.id));
|
||||
|
||||
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.append(button);
|
||||
}
|
||||
|
||||
container.set_width_request(MAX_WIDTH);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -312,22 +538,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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user