24 Commits
Author SHA1 Message Date
Bendik Aagaard LynghaugandClaude Fable 5.1 4f055935d4 backlight: run without a backlight device instead of panicking (0.4.3)
Release / package (x86_64, /var/local/cargo-target/iris-cargo-home, bare, /usr/bin/sccache, /var/local/rustup, /var/local/cargo-target) (push) Successful in 43s
Release / package (aarch64, , aarch64, , , ) (push) Successful in 2m20s
A config carrying a !backlight widget on a machine with nothing under
/sys/class/backlight (a laptop config on a desktop) hit
expect("did not find backlight device") in Backlight::new, which with
panic = "abort" killed wldash before a window appeared.

The device path is now Option<PathBuf>. A missing device, or a
configured `device` name that does not exist, prints one line on stderr
and leaves the widget in place so layout indices stay aligned: it
renders empty, skips sysfs reads and writes, and ignores clicks.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-05 13:54:13 +02:00
Bendik Aagaard LynghaugandClaude Fable 5.1 63179b9c47 fonts: fall back to fontconfig when a pinned font path is missing (0.4.2)
Release / package (x86_64, /var/local/cargo-target/iris-cargo-home, bare, /usr/bin/sccache, /var/local/rustup, /var/local/cargo-target) (push) Failing after 0s
Release / package (aarch64, , aarch64, , , ) (push) Failing after 1s
A config generated on one machine pinned
/usr/share/fonts/google-noto/NotoSans-Regular.ttf; on another host the
same package installs under fonts/noto, so the shared config made the
fontloader thread panic ("Font file not found") and, with panic =
"abort", kill wldash before a window appeared.

resolve_font_path() now uses a pinned path only if the file exists,
otherwise asks fontconfig for the name (what fc-match does) and says so
on stderr. find_font() and FontLoader::from_path() return errors
instead of unwrapping, and an unresolvable font exits with one clear
line. Generated configs leave font_paths unset so names resolve per
machine; pinning remains possible by hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:50:02 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 d1df30d6d0 release 0.4.1
Release / package (x86_64, /var/local/cargo-target/iris-cargo-home, bare, /usr/bin/sccache, /var/local/rustup, /var/local/cargo-target) (push) Successful in 42s
Release / package (aarch64, , aarch64, , , ) (push) Successful in 2m14s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:42:49 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 9953865128 launcher: scan desktop files directly, keep state in XDG_STATE_HOME
The desktop-entry cache lived in $XDG_CACHE_HOME/wldash/desktop.json.
On an NFS-shared home that is a remote read and write on every start
(unless the session happens to export a machine-local XDG_CACHE_HOME),
and it went stale whenever a package was installed until someone ran
--desktop-refresh. Scanning the applications directories takes a few
milliseconds over local /usr/share and already happened on a background
thread, so the cache bought nothing: drop it, along with simd-json and
the serde derives on Desktop. --desktop-refresh stays as a no-op so old
key bindings keep working.

Calculator history and the prompt saved on Escape are state, not cache:
they move to $XDG_STATE_HOME/wldash (~/.local/state), written only on
Enter/Escape, and the old cache location is still read as a fallback.

With the serde derives gone, two Desktop fields were unused; use them:
Type=Link entries now open their URL with xdg-open, and Terminal=true
entries run under $TERMINAL -e when it is set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:33:12 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 03a3c681b0 packaging: PKGBUILD, changelog and tag-driven release workflow (0.4.0)
Release / package (x86_64, /var/local/cargo-target/iris-cargo-home, bare, /usr/bin/sccache, /var/local/rustup, /var/local/cargo-target) (push) Successful in 1m12s
Release / package (aarch64, , aarch64, , , ) (push) Successful in 2m31s
Same shape as uhhm/gdo: a v* tag runs makepkg on the x86_64 (ergo,
"bare") and aarch64 (klokka) runners, uploads to the [uhhm] Arch
registry under namespace bl, and creates a release with the changelog
section as notes. The tag must agree with PKGBUILD's pkgver and
Cargo.toml's version. Without a REGISTRY_TOKEN secret the publish step
warns and skips so the build is still verified.

Version jumps to 0.4.0 because upstream already tagged v0.2.1 and
v0.3.0 on this history before the rewrite reset Cargo.toml to 0.2.0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:28:22 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 1c6a536026 clippy: clean up all warnings
Mostly `cargo clippy --fix`: needless borrows, redundant field names,
match -> if let, io::Error::other, Option::filter/map, let chains, c""
literals. By hand: drop unused impl lifetimes, a type alias for the
font preload map, Iterator::find in next_buffer, contains_key, and
allow new_ret_no_self where constructors intentionally return the
boxed/generic wrapper type.

No functional change; tests and release build unchanged.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:20:25 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 42ffe9c492 cargo fmt
Pure formatting, no functional change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:16:15 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 856f8d8bf4 readme: document the fork and /etc/wldash/config.yml precedence
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:57 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 eea3a78140 main: clear error messages and exit codes instead of panics
Missing/invalid config, unwritable config target, failed Wayland connect
and bad arguments now print one line to stderr and exit 1 rather than
panicking with "uh" or a serde backtrace.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:56 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 8c319cffc6 keyboard/state: handle repeat rate 0, let widgets see Escape
- wl_keyboard.repeat_info with rate 0 means "no repeat"; we divided by it.
- an unknown keymap format hit unreachable!() instead of being ignored.
- raw keycode 1 (Escape) returned before widgets were dispatched, so the
  launcher's Escape handler (which saves the prompt for Ctrl+R) never ran.
  Dispatch first, then fall back to quitting.
- always lay out on the first layer-surface configure, even when the
  compositor hands us exactly the placeholder dimensions.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:56 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 450a20bf63 widget: fix Geometry::expand using other.height twice
The bounding-box height was computed from other.height + other.height
instead of other.height + other.y, so the combined damage rectangle sent
to the compositor was wrong whenever a widget's drawn area changed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:55 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 6af81b587c buffer: switch to memmap2, destroy shm pools, never underflow refcounts
memmap 0.7 is unmaintained (RUSTSEC-2020-0077); memmap2 is a drop-in
replacement. Drop the unused shlex dependency.

Each resize created a new wl_shm_pool that was never destroyed; keep the
pool on the buffer and destroy it with it. A wl_buffer.release for a
buffer we didn't consider acquired used to underflow refcnt and wedge
rendering. Fix the lifetime-elision warnings while here.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:54 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 c033335912 widgets: stop aborting on machines without backlight/battery/pulseaudio
The release profile builds with panic = "abort", so a panic in any helper
thread took the whole dashboard down:

- backlight: WalkDir yields /sys/class/backlight itself, so detect() was
  always true and new() picked the class dir as the device, then panicked
  reading brightness. Use min_depth(1) and tolerate read errors.
- battery: detect() only checked that UPower's DisplayDevice answered,
  which it does on every machine; use IsPresent. The monitor thread now
  returns an error instead of unwrapping, and publishes its first reading.
- pulseaudio: a failed connection panicked ("damn"); clicks before the
  first sink query unwrapped a None channel map. Both are now handled, and
  the connect loop no longer busy-spins.
- bar widget: don't underflow when narrower than the label column.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:54 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 01af00b5ea desktop: hash entries the same way they compare, only parse *.desktop
Desktop derived Hash over every field but PartialEq compared only the
name, violating the Hash/Eq contract the launcher's HashMap relies on
(clippy denies this by default, so `cargo clippy` failed to build).

Also skip directories and files without a .desktop extension (mimeinfo
cache, .directory files, ...) and stop unwrapping non-UTF-8 paths.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:53 +00:00
Bendik Aagaard LynghaugandClaude Fable 5.1 2bed8b7fac launcher: make the prompt cursor consistently grapheme-based
set(), end() and move_cursor() used the byte length of the input as the
cursor bound while append/backspace/delete/clear_* index a grapheme table.
Any non-ASCII input (æøå, emoji) followed by End or typing past the end
indexed out of bounds and, with panic = "abort" in release, killed wldash.

While here:
- clamp Up so the selection can't run past the last row (every widget now
  reports its row count), and make mouse selection underflow-safe
- strip all desktop-entry field codes from Exec (%i %c %k ... and %% -> %),
  not just %f/%F/%u/%U
- report a failed spawn instead of panicking
- size the highlight colour table by chars, matching the drawing code

Adds unit tests for the prompt and the field-code stripping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 09:15:53 +00:00
Bendik Aagaard LynghaugandClaude Opus 4.8 1367bd8591 config: prefer machine-local /etc/wldash/config.yml when present
On a shared (NFS-roamed) home, ~/.config/wldash/config.yml is identical on
every machine. Prefer a host-specific /etc/wldash/config.yml when it exists so
each machine can keep its own layout without conflicting; --config still
overrides both. Enables the 'factor host-specific config to /etc' pattern for
shared-home fleets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-16 14:16:22 +02:00
Alejandro Visiedo 1156b3503a fix: ensure dirs exist
Ensure ~/.config/wldash and ~/.cache/wldash exist.
Provide hints when no configuration exists.

Signed-off-by: Alejandro Visiedo <alejandro.visiedo@gmail.com>
2025-05-08 12:59:41 +02:00
Kenny Levinsen d80a64444f cargo: Update lock 2025-04-08 11:51:15 +02:00
Kenny Levinsen 456d61de01 cargo: Set binary name back to wldash
During v2 development, the name was temporarily set to wldash2. Set it
back to wldash so that things work.
2025-03-20 16:57:58 +01:00
Kenny Levinsen ab2655556f readme: Make note of rewrite 2025-02-27 17:42:09 +01:00
Kenny Levinsen f0331e65b1 cargo: Bump to 2024 edition 2025-02-27 17:41:08 +01:00
Kenny Levinsen 25c7322c4b cargo: Bump version to 0.2 2025-02-27 17:40:56 +01:00
Kenny Levinsen 7af5c34856 readme: Update for v2 2025-02-27 17:39:49 +01:00
Kenny Levinsen e8def40fae cargo: Make fontconfig a default feature 2025-02-27 17:39:40 +01:00
29 changed files with 1013 additions and 731 deletions
+114
View File
@@ -0,0 +1,114 @@
name: Release
# A `v*` tag builds the package on each architecture's own runner
# (x86_64 on ergo's bare runner, aarch64 on klokka's) and uploads it
# to the [uhhm] Arch registry, like uhhm/gdo and bl/djdeck. Hosts then
# `pacman -S wldash`. The tag has to agree with PKGBUILD's pkgver and
# Cargo.toml's version, and CHANGELOG.md has to have the section; a
# release with the tag's notes is created from the x86_64 job.
on:
push:
tags: ["v*"]
jobs:
package:
strategy:
matrix:
include:
- arch: x86_64
runs-on: bare
# The bare runner's shared toolchain and caches, with the
# writable cargo home uhhm/iris and uhhm/gdo use.
cargo_home: /var/local/cargo-target/iris-cargo-home
rustc_wrapper: /usr/bin/sccache
rustup_home: /var/local/rustup
target_dir: /var/local/cargo-target
- arch: aarch64
runs-on: aarch64
# klokka's host runner: rustup's stable (step below), defaults everywhere.
cargo_home: ""
rustc_wrapper: ""
rustup_home: ""
target_dir: ""
runs-on: ${{ matrix.runs-on }}
env:
CARGO_HOME: ${{ matrix.cargo_home }}
RUSTC_WRAPPER: ${{ matrix.rustc_wrapper }}
RUSTUP_HOME: ${{ matrix.rustup_home }}
CARGO_TARGET_DIR: ${{ matrix.target_dir }}
PATH: /var/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/bin
SCCACHE_DIR: /var/local/sccache
SCCACHE_SERVER_PORT: "4228"
steps:
# v4 built-in checkout: native Go, needs no Node (the aarch64 host runner
# has none) and keeps the token out of a fetch URL.
- name: Checkout
uses: builtin:checkout
# klokka's runner has rustup but no default toolchain, so its cargo
# proxy refuses to run without this.
- name: Install Rust stable (aarch64)
if: matrix.arch == 'aarch64'
uses: dtolnay/rust-toolchain@stable
- name: Check tag matches pkgver and Cargo.toml
run: |
want="${GITHUB_REF_NAME#v}"
have=$(sed -n 's/^pkgver=//p' PKGBUILD)
[ "$want" = "$have" ] || { echo "tag $GITHUB_REF_NAME != pkgver $have" >&2; exit 1; }
cargo_ver=$(sed -n 's/^version = "\(.*\)"/\1/p' Cargo.toml | head -1)
[ "$want" = "$cargo_ver" ] || { echo "tag $GITHUB_REF_NAME != Cargo.toml version $cargo_ver" >&2; exit 1; }
notes=$(awk -v v="$want" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
[ -n "$(printf "%s" "$notes" | tr -d "[:space:]")" ] || { echo "CHANGELOG.md has no section for $GITHUB_REF_NAME" >&2; exit 1; }
# Bare runners are never mutated by CI: wldash's system libraries
# (dbus, libpulse, libxkbcommon, fontconfig, wayland) must already be
# installed on the host. Fail loud if one is missing.
- name: Check system build deps
run: |
missing=""
for lib in dbus-1 libpulse xkbcommon fontconfig wayland-client; do
pkg-config --exists "$lib" || missing="$missing $lib"
done
[ -z "$missing" ] || { echo "::error::bare runner missing:$missing — install once on the host" >&2; exit 1; }
# -d: cargo is the shared toolchain, not a pacman package the
# runner could verify. Builds and tests from the checkout. Empty
# env values above are unset here so cargo's defaults apply.
- name: Build package
run: |
for v in CARGO_HOME RUSTC_WRAPPER RUSTUP_HOME CARGO_TARGET_DIR; do
eval "[ -n \"\$$v\" ]" || unset "$v"
done
makepkg -f -d --noconfirm
# REGISTRY_TOKEN is a write:package token for bl, a secret on this
# repo. The registry namespace is always bl: pacman.conf's [uhhm]
# points at /api/packages/bl/arch/uhhm. Without the secret the build
# still runs so the tag is verified; re-run the job once it is set.
- name: Publish to the Arch package registry
run: |
set -euo pipefail
if [ -z "${{ secrets.REGISTRY_TOKEN }}" ]; then
echo "::warning::REGISTRY_TOKEN not set — skipping Arch registry publish"
exit 0
fi
for pkg in $(makepkg --packagelist); do
echo "==> $pkg"
curl --fail-with-body --user "bl:${{ secrets.REGISTRY_TOKEN }}" \
--upload-file "$pkg" \
"${{ gitea.server_url }}/api/packages/bl/arch/uhhm"
done
- name: Release notes
if: matrix.arch == 'x86_64'
run: |
set -euo pipefail
tag="${{ github.ref_name }}"
api="${{ github.server_url }}/api/v1/repos/${{ github.repository }}"
auth="Authorization: token ${{ secrets.GITHUB_TOKEN }}"
notes=$(awk -v v="${tag#v}" '/^## /{p = ($2 == v)} p' CHANGELOG.md | tail -n +2)
body=$(jq -n --arg tag "$tag" --arg notes "$notes" '{tag_name: $tag, name: $tag, body: $notes}')
curl -sf -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" > /dev/null \
|| echo "release $tag exists"
+31
View File
@@ -0,0 +1,31 @@
# Changelog
## 0.4.3 (2026-10-05)
- Backlight: a config with a `!backlight` widget on a machine without `/sys/class/backlight` entries (or with a `device` name that does not exist) panicked at start ("did not find backlight device"), fatal with `panic = "abort"`. The widget now warns once on stderr, renders empty and ignores clicks, so one config can be shared between a laptop and a desktop.
## 0.4.2 (2026-10-03)
- Fonts: a `font_paths` entry pointing at a file that does not exist on this machine made the font-loader thread panic and abort wldash ("Font file not found"). Pinned paths that are missing now fall back to fontconfig by name, with one line on stderr; a font that cannot be resolved at all exits with a clear message instead of a backtrace.
- `--write-default-config` no longer writes absolute font paths. Configs shared between machines with different font package layouts (`fonts/noto` vs `fonts/google-noto`) were breaking on the second host; names are resolved per machine instead. Existing configs keep working, and `font_paths` can still pin a file on purpose.
## 0.4.1 (2026-10-03)
- No more desktop-file cache: wldash scanned `~/.cache/wldash/desktop.json` (on NFS for shared homes, and stale after every package install) and needed a manual `--desktop-refresh`. Desktop files are now scanned on a background thread at every start, a few milliseconds over local `/usr/share`. `--desktop-refresh` is a no-op kept for key bindings; `simd-json` is gone.
- Calculator history and the Escape-saved prompt moved from `$XDG_CACHE_HOME/wldash` to `$XDG_STATE_HOME/wldash` (`~/.local/state`), where small roaming state belongs; old files are still read.
- Launcher: `Type=Link` entries open their URL with `xdg-open`; `Terminal=true` entries run inside `$TERMINAL -e` when that variable is set.
## 0.4.0 (2026-10-03)
First release of the project.uhhm.no fork, packaged for the `[uhhm]` Arch registry. Numbering: upstream's old code base was tagged up to v0.3.0 and the rewrite called itself 0.2.0; this fork continues above both so pacman sees a monotonic version.
- Launcher: the prompt cursor was a byte offset in some places and a grapheme index in others, so typing æøå and then End or another key crashed wldash. The cursor is grapheme-based everywhere, Up stops at the last row, mouse selection can't underflow, and all desktop Exec field codes (`%i`, `%c`, `%k`, `%%`, ...) are stripped.
- No more aborting on machines without a backlight, battery or PulseAudio: `/sys/class/backlight` itself was taken for a device, UPower's always-present DisplayDevice for a battery, and a failed audio connection panicked a thread (fatal with `panic = "abort"`).
- Redraws: `Geometry::expand` computed the damage height from the wrong field; buffer refcounts no longer underflow; `wl_shm_pool`s are destroyed on resize.
- Input: a key-repeat rate of 0 divided by zero; Escape is now seen by the launcher first so the prompt is saved for Ctrl+R; the first layer-surface configure always lays out.
- Config: `/etc/wldash/config.yml` takes precedence over `~/.config/wldash/config.yml` when present (machine-local layout on NFS-shared homes); `--config` overrides both. Errors print one line and exit 1 instead of panicking.
- Housekeeping: `memmap` → `memmap2`, unused `shlex` dropped, zero rustc/clippy warnings, rustfmt applied, unit tests for the prompt, Exec parsing and geometry.
## 0.2.0
Upstream (kennylevinsen/wldash) rewrite, the state this fork started from.
Generated
+4 -236
View File
@@ -8,18 +8,6 @@ version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046" checksum = "c71b1793ee61086797f5c80b6efa2b8ffa6d5dd703f118545808a7f2e27f7046"
[[package]]
name = "ahash"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77c3a9648d43b9cd48db467b3f87fdd6e146bcc88ab0180006cef2179fe11d01"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]] [[package]]
name = "android-tzdata" name = "android-tzdata"
version = "0.1.1" version = "0.1.1"
@@ -188,15 +176,6 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
[[package]]
name = "float-cmp"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4"
dependencies = [
"num-traits",
]
[[package]] [[package]]
name = "fontconfig" name = "fontconfig"
version = "0.8.0" version = "0.8.0"
@@ -225,38 +204,6 @@ dependencies = [
"version_check", "version_check",
] ]
[[package]]
name = "getrandom"
version = "0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"wasi",
"wasm-bindgen",
]
[[package]]
name = "halfbrown"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5681137554ddff44396e5f149892c769d45301dd9aa19c51602a89ee214cb0ec"
dependencies = [
"hashbrown 0.13.2",
"serde",
]
[[package]]
name = "hashbrown"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e"
dependencies = [
"ahash",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.3" version = "0.14.3"
@@ -299,7 +246,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "433de089bd45971eecf4668ee0ee8f4cec17db4f8bd8f7bc3197a6ce37aa7d9b" checksum = "433de089bd45971eecf4668ee0ee8f4cec17db4f8bd8f7bc3197a6ce37aa7d9b"
dependencies = [ dependencies = [
"equivalent", "equivalent",
"hashbrown 0.14.3", "hashbrown",
] ]
[[package]] [[package]]
@@ -334,70 +281,6 @@ version = "1.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
[[package]]
name = "lexical-core"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2cde5de06e8d4c2faabc400238f9ae1c74d5412d03a7bd067645ccbc47070e46"
dependencies = [
"lexical-parse-float",
"lexical-parse-integer",
"lexical-util",
"lexical-write-float",
"lexical-write-integer",
]
[[package]]
name = "lexical-parse-float"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683b3a5ebd0130b8fb52ba0bdc718cc56815b6a097e28ae5a6997d0ad17dc05f"
dependencies = [
"lexical-parse-integer",
"lexical-util",
"static_assertions",
]
[[package]]
name = "lexical-parse-integer"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d0994485ed0c312f6d965766754ea177d07f9c00c9b82a5ee62ed5b47945ee9"
dependencies = [
"lexical-util",
"static_assertions",
]
[[package]]
name = "lexical-util"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5255b9ff16ff898710eb9eb63cb39248ea8a5bb036bea8085b1a767ff6c4e3fc"
dependencies = [
"static_assertions",
]
[[package]]
name = "lexical-write-float"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accabaa1c4581f05a3923d1b4cfd124c329352288b7b9da09e766b0668116862"
dependencies = [
"lexical-util",
"lexical-write-integer",
"static_assertions",
]
[[package]]
name = "lexical-write-integer"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1b6f3d1f4422866b68192d62f77bc5c700bee84f3069f2469d7bc8c77852446"
dependencies = [
"lexical-util",
"static_assertions",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.152" version = "0.2.152"
@@ -462,16 +345,6 @@ version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
[[package]]
name = "memmap"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6585fd95e7bb50d6cc31e20d4cf9afb4e2ba16c5846fc76793f11218da9c475b"
dependencies = [
"libc",
"winapi",
]
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.5" version = "0.9.5"
@@ -703,26 +576,6 @@ dependencies = [
"pest_derive", "pest_derive",
] ]
[[package]]
name = "ref-cast"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4846d4c50d1721b1a3bef8af76924eef20d5e723647333798c1b519b3a9473f"
dependencies = [
"ref-cast-impl",
]
[[package]]
name = "ref-cast-impl"
version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fddb4f8d99b0a2ebafc65a87a69a7b9875e4b1ae1f00db265d300ef7f28bccc"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
[[package]] [[package]]
name = "rusttype" name = "rusttype"
version = "0.9.3" version = "0.9.3"
@@ -774,17 +627,6 @@ dependencies = [
"syn 2.0.48", "syn 2.0.48",
] ]
[[package]]
name = "serde_json"
version = "1.0.113"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "serde_yaml" name = "serde_yaml"
version = "0.9.31" version = "0.9.31"
@@ -809,34 +651,6 @@ dependencies = [
"digest", "digest",
] ]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "simd-json"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2faf8f101b9bc484337a6a6b0409cf76c139f2fb70a9e3aee6b6774be7bfbf76"
dependencies = [
"getrandom",
"halfbrown",
"lexical-core",
"ref-cast",
"serde",
"serde_json",
"simdutf8",
"value-trait",
]
[[package]]
name = "simdutf8"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f27f6278552951f1f2b8cf9da965d10969b2efdea95a6ec47987ab46edfe263a"
[[package]] [[package]]
name = "slotmap" name = "slotmap"
version = "1.0.7" version = "1.0.7"
@@ -852,12 +666,6 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
[[package]]
name = "static_assertions"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@@ -946,18 +754,6 @@ version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b" checksum = "ab4c90930b95a82d00dc9e9ac071b4991924390d46cbd0dfe566148667605e4b"
[[package]]
name = "value-trait"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dad8db98c1e677797df21ba03fca7d3bf9bec3ca38db930954e4fe6e1ea27eb4"
dependencies = [
"float-cmp",
"halfbrown",
"itoa",
"ryu",
]
[[package]] [[package]]
name = "vec_map" name = "vec_map"
version = "0.8.2" version = "0.8.2"
@@ -980,12 +776,6 @@ dependencies = [
"winapi-util", "winapi-util",
] ]
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]] [[package]]
name = "wasm-bindgen" name = "wasm-bindgen"
version = "0.2.90" version = "0.2.90"
@@ -1280,8 +1070,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04"
[[package]] [[package]]
name = "wldash2" name = "wldash"
version = "0.1.0" version = "0.4.3"
dependencies = [ dependencies = [
"calloop", "calloop",
"chrono", "chrono",
@@ -1289,14 +1079,12 @@ dependencies = [
"fontconfig", "fontconfig",
"fuzzy-matcher", "fuzzy-matcher",
"libpulse-binding", "libpulse-binding",
"memmap", "memmap2",
"nix 0.26.4", "nix 0.26.4",
"rcalc_lib", "rcalc_lib",
"rusttype", "rusttype",
"serde", "serde",
"serde_yaml", "serde_yaml",
"shlex",
"simd-json",
"unicode-segmentation", "unicode-segmentation",
"walkdir", "walkdir",
"wayland-client", "wayland-client",
@@ -1333,23 +1121,3 @@ dependencies = [
"once_cell", "once_cell",
"pkg-config", "pkg-config",
] ]
[[package]]
name = "zerocopy"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.7.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.48",
]
+7 -6
View File
@@ -1,19 +1,22 @@
[package] [package]
name = "wldash2" name = "wldash"
version = "0.1.0" version = "0.4.3"
edition = "2021" edition = "2024"
[profile.release] [profile.release]
#debug = true #debug = true
lto = "fat" lto = "fat"
panic = "abort" panic = "abort"
[features]
default = ["fontconfig"]
[dependencies] [dependencies]
wayland-client = { version = "0.30", features = ["calloop", "log"] } wayland-client = { version = "0.30", features = ["calloop", "log"] }
wayland-protocols = { version = "0.30", features = ["client", "unstable", "staging"] } wayland-protocols = { version = "0.30", features = ["client", "unstable", "staging"] }
wayland-protocols-wlr = { version = "0.1", features = ["client"] } wayland-protocols-wlr = { version = "0.1", features = ["client"] }
nix = "0.26" # 0.27 is breaking nix = "0.26" # 0.27 is breaking
memmap = "0.7" memmap2 = "0.9"
fontconfig = { version = "0.8.0", optional = true } fontconfig = { version = "0.8.0", optional = true }
rusttype = "0.9" rusttype = "0.9"
# fontdue = "0.7" # fontdue = "0.7"
@@ -24,7 +27,6 @@ walkdir = "2.4"
unicode-segmentation = "1.10" unicode-segmentation = "1.10"
fuzzy-matcher = "0.3" fuzzy-matcher = "0.3"
shlex = "1.3"
xkbcommon = { version = "0.8", features = ["wayland"] } xkbcommon = { version = "0.8", features = ["wayland"] }
@@ -36,5 +38,4 @@ libpulse-binding = "2.28"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_yaml = "0.9" serde_yaml = "0.9"
simd-json = "0.13"
+40
View File
@@ -0,0 +1,40 @@
# Maintainer: Bendik Aagaard Lynghaug <bendik.lynghaug@gmail.com>
# Built from the tagged checkout by .gitea/workflows/release.yml, once
# per architecture, and uploaded to the [uhhm] registry on
# project.uhhm.no (namespace bl). pkgver is the version; the tag has
# to agree with it and with Cargo.toml.
pkgname=wldash
pkgver=0.4.3
pkgrel=1
pkgdesc="Dashboard/launcher/control-panel for wlroots-style Wayland compositors (layer-shell)"
arch=('x86_64' 'aarch64')
url="https://project.uhhm.no/bl/wldash"
license=('GPL-3.0-only')
depends=('glibc' 'gcc-libs' 'dbus' 'libpulse' 'libxkbcommon' 'fontconfig' 'wayland')
makedepends=('cargo')
optdepends=('upower: battery widget'
'pipewire-pulse: volume widget (or pulseaudio)')
# !lto: Cargo.toml already does lto = "fat"; makepkg's -flto in RUSTFLAGS
# would only fight it. !debug: panic = "abort" release, nothing to strip.
options=('!debug' '!lto')
# Sources are the checkout itself: makepkg resolves local sources by
# basename next to the PKGBUILD, so the tagged tree CI builds from is
# the source of truth, as in uhhm/gdo.
build() {
cd "$startdir"
cargo build --release --locked
}
check() {
cd "$startdir"
cargo test --release --locked
}
package() {
cd "$startdir"
install -Dm755 "${CARGO_TARGET_DIR:-target}/release/wldash" "$pkgdir/usr/bin/wldash"
install -Dm644 README.md "$pkgdir/usr/share/doc/$pkgname/README.md"
install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
}
+18 -6
View File
@@ -6,16 +6,31 @@ A dashboard/launcher/control-panel thing for Wayland. Requires wlr-layer-shell-u
Consider this alpha-quality: While it works, *everything* is subject to change with a moments notice. You may end up pulling in the latest changes, and *poof*, wldash suddenly turns into an artisan espresso machine. Consider this alpha-quality: While it works, *everything* is subject to change with a moments notice. You may end up pulling in the latest changes, and *poof*, wldash suddenly turns into an artisan espresso machine.
## Rewrite note
wldash has received a significant rewrite. The old version is available in the wldash1 branch for now.
## This fork
This is a maintained fork of [kennylevinsen/wldash](https://github.com/kennylevinsen/wldash)
living at <https://project.uhhm.no/bl/wldash>. On top of upstream it fixes a number of
crashes (non-ASCII input in the launcher, machines without a backlight, battery or
PulseAudio, key-repeat rate 0, ...), corrects redraw damage tracking, and prefers a
machine-local `/etc/wldash/config.yml` over the per-user config when present, which keeps
per-host layouts apart on NFS-shared homes.
## How to use ## How to use
1. Checkout: `git clone https://github.com/kennylevinsen/wldash` 1. Checkout: `git clone https://project.uhhm.no/bl/wldash`
2. Build: `cargo build --release` 2. Build: `cargo build --release`
3. Put somewhere: `cp target/release/wldash /usr/local/bin/wldash` 3. Put somewhere: `cp target/release/wldash /usr/local/bin/wldash`
4. Run: `wldash` 4. Run: `wldash`
To see the default configuration, run `wldash print-config`. To configure, place a file in `$XDG_CONFIG_HOME/wldash/config.yaml` (or if `XDG_CONFIG_HOME` is not set, `~/.config/wldash/config.yaml`). JSON is also currently supported. To generate a new default configuration, run `wldash --write-default-config`. To configure, edit `$XDG_CONFIG_HOME/wldash/config.yml` (or if `XDG_CONFIG_HOME` is not set, `~/.config/wldash/config.yml`). If `/etc/wldash/config.yml` exists it takes precedence over the per-user file; `--config FILE` overrides both.
Notable settings: `outptuMode` can be `active` or `all`, `scale` can be set to `2` to half the buffer size, and the widgets (and their layout) can be configured. Fonts are named (`sans`, `monospace`, or any fontconfig name) and resolved through fontconfig on the machine wldash runs on. `font_paths` in the config can pin a name to a file; if that file does not exist on the current machine, wldash says so on stderr and falls back to fontconfig rather than failing, so one config can roam between hosts with different font package layouts.
Desktop files are scanned every time wldash starts (on a background thread), so there is no cache to refresh; `--desktop-refresh` is kept as a no-op for old key bindings. Calculator history and the prompt saved on Escape live in `$XDG_STATE_HOME/wldash` (`~/.local/state/wldash`); older installs' files under `~/.cache/wldash` are still read.
For more info, look in `src/config.rs`. For more info, look in `src/config.rs`.
@@ -63,9 +78,6 @@ The launcher also accepts prefix operators to change its mode:
## Notable missing features: ## Notable missing features:
- Scaling of any kind - all sizes are currently hardcoded in pixels - Scaling of any kind - all sizes are currently hardcoded in pixels
- Configurability
- Cleanup and reorganization
- Proper line editor for the launcher
## How to discuss ## How to discuss
+21 -31
View File
@@ -1,5 +1,4 @@
use std::{ use std::{
ffi::CStr,
fs::File, fs::File,
io, io,
os::unix::io::{FromRawFd, RawFd}, os::unix::io::{FromRawFd, RawFd},
@@ -16,11 +15,11 @@ use nix::{
unistd, unistd,
}; };
use memmap::MmapMut; use memmap2::MmapMut;
use wayland_client::{ use wayland_client::{
protocol::{wl_buffer, wl_shm, wl_shm_pool},
Dispatch, QueueHandle, Dispatch, QueueHandle,
protocol::{wl_buffer, wl_shm, wl_shm_pool},
}; };
use crate::color::Color; use crate::color::Color;
@@ -30,10 +29,7 @@ fn create_shm_fd() -> io::Result<RawFd> {
// Only try memfd on linux // Only try memfd on linux
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
loop { loop {
match memfd::memfd_create( match memfd::memfd_create(c"wldash", memfd::MemFdCreateFlag::MFD_CLOEXEC) {
CStr::from_bytes_with_nul(b"wldash\0").unwrap(),
memfd::MemFdCreateFlag::MFD_CLOEXEC,
) {
Ok(fd) => return Ok(fd), Ok(fd) => return Ok(fd),
Err(Errno::EINTR) => continue, Err(Errno::EINTR) => continue,
Err(Errno::ENOSYS) => break, Err(Errno::ENOSYS) => break,
@@ -78,11 +74,13 @@ fn create_shm_fd() -> io::Result<RawFd> {
} }
pub struct ShmBuffer { pub struct ShmBuffer {
// Keeps the memfd alive for as long as the mapping and pool exist.
#[allow(dead_code)]
pub file: File, pub file: File,
pub mmap: MmapMut, pub mmap: MmapMut,
pub pool: wl_shm_pool::WlShmPool,
pub buffer: wl_buffer::WlBuffer, pub buffer: wl_buffer::WlBuffer,
pub refcnt: u32, pub refcnt: u32,
pub id: u32,
pub last_damage: Vec<Geometry>, pub last_damage: Vec<Geometry>,
} }
@@ -94,7 +92,6 @@ impl ShmBuffer {
height: i32, height: i32,
stride: i32, stride: i32,
format: wl_shm::Format, format: wl_shm::Format,
id: u32,
) -> io::Result<ShmBuffer> ) -> io::Result<ShmBuffer>
where where
D: Dispatch<wl_shm_pool::WlShmPool, ()> + Dispatch<wl_buffer::WlBuffer, ()> + 'static, D: Dispatch<wl_shm_pool::WlShmPool, ()> + Dispatch<wl_buffer::WlBuffer, ()> + 'static,
@@ -102,16 +99,16 @@ impl ShmBuffer {
let mem_fd = create_shm_fd()?; let mem_fd = create_shm_fd()?;
let file = unsafe { File::from_raw_fd(mem_fd) }; let file = unsafe { File::from_raw_fd(mem_fd) };
file.set_len((height * stride) as u64)?; file.set_len((height * stride) as u64)?;
let pool = shm.create_pool(mem_fd, height * stride, &qh, ()); let pool = shm.create_pool(mem_fd, height * stride, qh, ());
let mmap = unsafe { MmapMut::map_mut(&file).unwrap() }; let mmap = unsafe { MmapMut::map_mut(&file)? };
let buffer = pool.create_buffer(0, width, height, stride, format, &qh, ()); let buffer = pool.create_buffer(0, width, height, stride, format, qh, ());
Ok(ShmBuffer { Ok(ShmBuffer {
refcnt: 0, refcnt: 0,
file, file,
mmap, mmap,
pool,
buffer, buffer,
id,
last_damage: Vec::new(), last_damage: Vec::new(),
}) })
} }
@@ -121,37 +118,33 @@ impl ShmBuffer {
} }
pub fn release(&mut self) { pub fn release(&mut self) {
self.refcnt -= 1; // A compositor may release a buffer we never considered acquired
// (e.g. after a resize); never underflow and wedge the buffer.
self.refcnt = self.refcnt.saturating_sub(1);
} }
} }
pub struct BufferManager { pub struct BufferManager {
pub buffers: Vec<ShmBuffer>, pub buffers: Vec<ShmBuffer>,
pub next_id: u32,
} }
impl BufferManager { impl BufferManager {
pub fn new() -> BufferManager { pub fn new() -> BufferManager {
BufferManager { BufferManager {
buffers: Vec::new(), buffers: Vec::new(),
next_id: 0,
} }
} }
pub fn clear_buffers(&mut self) { pub fn clear_buffers(&mut self) {
for buf in self.buffers.iter_mut() { for buf in self.buffers.iter_mut() {
buf.buffer.destroy() buf.buffer.destroy();
buf.pool.destroy();
} }
self.buffers.clear() self.buffers.clear()
} }
pub fn next_buffer(&mut self) -> Option<&mut ShmBuffer> { pub fn next_buffer(&mut self) -> Option<&mut ShmBuffer> {
for buf in self.buffers.iter_mut() { self.buffers.iter_mut().find(|buf| buf.refcnt == 0)
if buf.refcnt == 0 {
return Some(buf);
}
}
None
} }
pub fn add_buffer<D>( pub fn add_buffer<D>(
@@ -162,18 +155,15 @@ impl BufferManager {
) where ) where
D: Dispatch<wl_shm_pool::WlShmPool, ()> + Dispatch<wl_buffer::WlBuffer, ()> + 'static, D: Dispatch<wl_shm_pool::WlShmPool, ()> + Dispatch<wl_buffer::WlBuffer, ()> + 'static,
{ {
let mut buf = ShmBuffer::new( let buf = ShmBuffer::new(
wl_shm, wl_shm,
qh, qh,
dimensions.0, dimensions.0,
dimensions.1, dimensions.1,
dimensions.0 * 4, dimensions.0 * 4,
wl_shm::Format::Argb8888, wl_shm::Format::Argb8888,
self.next_id,
) )
.expect("unable to add buffer"); .expect("unable to add buffer");
self.next_id += 1;
BufferView::new(&mut buf.mmap, (dimensions.0 as u32, dimensions.1 as u32));
self.buffers.push(buf); self.buffers.push(buf);
} }
} }
@@ -185,7 +175,7 @@ pub struct BufferView<'a> {
} }
impl<'a> BufferView<'a> { impl<'a> BufferView<'a> {
pub fn new(buf: &'a mut MmapMut, dimensions: (u32, u32)) -> BufferView { pub fn new(buf: &'a mut MmapMut, dimensions: (u32, u32)) -> BufferView<'a> {
let view = unsafe { let view = unsafe {
std::slice::from_raw_parts_mut( std::slice::from_raw_parts_mut(
buf.as_mut_ptr() as *mut u32, buf.as_mut_ptr() as *mut u32,
@@ -208,7 +198,7 @@ impl<'a> BufferView<'a> {
} }
} }
pub fn subdimensions(&mut self, subdimensions: (u32, u32, u32, u32)) -> BufferView { pub fn subdimensions(&mut self, subdimensions: (u32, u32, u32, u32)) -> BufferView<'_> {
let bounds = self.get_bounds(); let bounds = self.get_bounds();
if cfg!(debug_assertions) if cfg!(debug_assertions)
&& (subdimensions.0 + subdimensions.2 > bounds.2 && (subdimensions.0 + subdimensions.2 > bounds.2
@@ -232,11 +222,11 @@ impl<'a> BufferView<'a> {
} }
} }
pub fn subgeometry(&mut self, geo: Geometry) -> BufferView { pub fn subgeometry(&mut self, geo: Geometry) -> BufferView<'_> {
self.subdimensions((geo.x, geo.y, geo.width, geo.height)) self.subdimensions((geo.x, geo.y, geo.width, geo.height))
} }
pub fn offset(&mut self, offset: (u32, u32)) -> BufferView { pub fn offset(&mut self, offset: (u32, u32)) -> BufferView<'_> {
let bounds = self.get_bounds(); let bounds = self.get_bounds();
if cfg!(debug_assertions) && (offset.0 > bounds.2 || offset.1 > bounds.3) { if cfg!(debug_assertions) && (offset.0 > bounds.2 || offset.1 > bounds.3) {
panic!( panic!(
+21 -27
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use crate::{ use crate::{
event::Events, event::Events,
fonts::{find_font, FontMap}, fonts::FontMap,
widgets::{ widgets::{
Backlight, Battery, Calendar, Clock, Date, HorizontalLayout, IndexedLayout, Interface, Backlight, Battery, Calendar, Clock, Date, HorizontalLayout, IndexedLayout, Interface,
InvertedHorizontalLayout, InvertedVerticalLayout, Layout, Line, Margin, PulseAudio, InvertedHorizontalLayout, InvertedVerticalLayout, Layout, Line, Margin, PulseAudio,
@@ -89,8 +89,6 @@ impl Config {
pub fn generate_v1() -> Config { pub fn generate_v1() -> Config {
let has_battery = Battery::detect(); let has_battery = Battery::detect();
let has_backlight = Backlight::detect(); let has_backlight = Backlight::detect();
let sans = find_font("sans");
let monospace = find_font("monospace");
let mut bars = Vec::new(); let mut bars = Vec::new();
if has_battery { if has_battery {
@@ -154,14 +152,13 @@ impl Config {
])), ])),
}; };
let map = HashMap::from([
("sans".to_string(), sans),
("monospace".to_string(), monospace),
]);
Config { Config {
font_paths: Some(map), // Left unset on purpose: "sans"/"monospace" are resolved through
widget: widget, // fontconfig on each machine at start. Pinning absolute paths
// here broke configs shared between hosts with different font
// package layouts. Set font_paths yourself to override.
font_paths: None,
widget,
mode: OperationMode::LayerSurface((1024, 768)), mode: OperationMode::LayerSurface((1024, 768)),
background: Some(0xFAFAFAFA), background: Some(0xFAFAFAFA),
server: None, server: None,
@@ -171,8 +168,6 @@ impl Config {
pub fn generate_v2(clay: bool) -> Config { pub fn generate_v2(clay: bool) -> Config {
let has_battery = Battery::detect(); let has_battery = Battery::detect();
let has_backlight = Backlight::detect(); let has_backlight = Backlight::detect();
let sans = find_font("sans");
let monospace = find_font("monospace");
let mut bars = Vec::new(); let mut bars = Vec::new();
if has_battery { if has_battery {
@@ -244,14 +239,13 @@ impl Config {
]), ]),
]); ]);
let map = HashMap::from([
("sans".to_string(), sans),
("monospace".to_string(), monospace),
]);
Config { Config {
font_paths: Some(map), // Left unset on purpose: "sans"/"monospace" are resolved through
widget: widget, // fontconfig on each machine at start. Pinning absolute paths
// here broke configs shared between hosts with different font
// package layouts. Set font_paths yourself to override.
font_paths: None,
widget,
mode: if clay { mode: if clay {
OperationMode::XdgToplevel OperationMode::XdgToplevel
} else { } else {
@@ -281,7 +275,7 @@ impl Widget {
pub fn construct_widgets( pub fn construct_widgets(
self, self,
v: &mut Vec<Box<dyn RealWidget>>, v: &mut Vec<Box<dyn RealWidget>>,
mut fm: &mut FontMap, fm: &mut FontMap,
events: &Arc<Mutex<Events>>, events: &Arc<Mutex<Events>>,
) { ) {
match self { match self {
@@ -301,12 +295,12 @@ impl Widget {
Widget::HorizontalLine(width) => v.push(Box::new(Line::new(width, false))), Widget::HorizontalLine(width) => v.push(Box::new(Line::new(width, false))),
Widget::VerticalLine(width) => v.push(Box::new(Line::new(width, true))), Widget::VerticalLine(width) => v.push(Box::new(Line::new(width, true))),
Widget::Clock { font, font_size } => v.push(Box::new(Clock::new( Widget::Clock { font, font_size } => v.push(Box::new(Clock::new(
&mut fm, fm,
leak_or_default(font, "sans"), leak_or_default(font, "sans"),
font_size, font_size,
))), ))),
Widget::Date { font, font_size } => v.push(Box::new(Date::new( Widget::Date { font, font_size } => v.push(Box::new(Date::new(
&mut fm, fm,
leak_or_default(font, "sans"), leak_or_default(font, "sans"),
font_size, font_size,
))), ))),
@@ -317,7 +311,7 @@ impl Widget {
sections_x, sections_x,
sections_y, sections_y,
} => v.push(Box::new(Calendar::new( } => v.push(Box::new(Calendar::new(
&mut fm, fm,
leak_or_default(font_primary, "monospace"), leak_or_default(font_primary, "monospace"),
leak_or_default(font_secondary, "sans"), leak_or_default(font_secondary, "sans"),
font_size, font_size,
@@ -330,14 +324,14 @@ impl Widget {
launch_cmd, launch_cmd,
} => v.push(Box::new(Interface::new( } => v.push(Box::new(Interface::new(
events.clone(), events.clone(),
&mut fm, fm,
leak_or_default(font, "sans"), leak_or_default(font, "sans"),
font_size, font_size,
launch_cmd, launch_cmd,
))), ))),
Widget::Battery { font, font_size } => v.push(Box::new(Battery::new( Widget::Battery { font, font_size } => v.push(Box::new(Battery::new(
events.clone(), events.clone(),
&mut fm, fm,
leak_or_default(font, "sans"), leak_or_default(font, "sans"),
font_size, font_size,
))), ))),
@@ -347,13 +341,13 @@ impl Widget {
font_size, font_size,
} => v.push(Box::new(Backlight::new( } => v.push(Box::new(Backlight::new(
leak_or_none(device), leak_or_none(device),
&mut fm, fm,
leak_or_default(font, "sans"), leak_or_default(font, "sans"),
font_size, font_size,
))), ))),
Widget::PulseAudio { font, font_size } => v.push(Box::new(PulseAudio::new( Widget::PulseAudio { font, font_size } => v.push(Box::new(PulseAudio::new(
events.clone(), events.clone(),
&mut fm, fm,
Box::leak(font.unwrap_or_else(|| "sans".to_string()).into_boxed_str()), Box::leak(font.unwrap_or_else(|| "sans".to_string()).into_boxed_str()),
font_size, font_size,
))), ))),
+23 -23
View File
@@ -1,7 +1,7 @@
use crate::buffer::BufferView; use crate::buffer::BufferView;
use crate::{color::Color, fonts::FontRef}; use crate::{color::Color, fonts::FontRef};
use rusttype::{point, Scale}; use rusttype::{Scale, point};
use std::collections::HashMap; use std::collections::HashMap;
struct CachedGlyph { struct CachedGlyph {
@@ -82,7 +82,7 @@ pub struct Font<'a> {
} }
impl<'a> Font<'a> { impl<'a> Font<'a> {
pub fn new(font: FontRef<'a>, size: f32) -> Font { pub fn new(font: FontRef<'a>, size: f32) -> Font<'a> {
Font { Font {
glyphs: HashMap::new(), glyphs: HashMap::new(),
font, font,
@@ -98,7 +98,7 @@ impl<'a> Font<'a> {
pub fn add_str_to_cache(&mut self, s: &str) { pub fn add_str_to_cache(&mut self, s: &str) {
for ch in s.chars() { for ch in s.chars() {
if self.glyphs.get(&ch).is_none() { if !self.glyphs.contains_key(&ch) {
let glyph = CachedGlyph::new(self.font, self.size, ch); let glyph = CachedGlyph::new(self.font, self.size, ch);
self.glyphs.insert(ch, glyph); self.glyphs.insert(ch, glyph);
} }
@@ -118,10 +118,10 @@ impl<'a> Font<'a> {
let glyph = match self.glyphs.get(&ch) { let glyph = match self.glyphs.get(&ch) {
Some(glyph) => glyph, Some(glyph) => glyph,
None => { None => {
return Err(::std::io::Error::new( return Err(::std::io::Error::other(format!(
::std::io::ErrorKind::Other, "glyph for {:} not in cache",
format!("glyph for {:} not in cache", ch), ch
)) )));
} }
}; };
glyphs.push(glyph); glyphs.push(glyph);
@@ -151,10 +151,10 @@ impl<'a> Font<'a> {
let glyph = match self.glyphs.get(&ch) { let glyph = match self.glyphs.get(&ch) {
Some(glyph) => glyph, Some(glyph) => glyph,
None => { None => {
return Err(::std::io::Error::new( return Err(::std::io::Error::other(format!(
::std::io::ErrorKind::Other, "glyph for {:} not in cache",
format!("glyph for {:} not in cache", ch), ch
)) )));
} }
}; };
glyphs.push(glyph); glyphs.push(glyph);
@@ -203,10 +203,10 @@ impl<'a> Font<'a> {
let glyph = match self.glyphs.get(&ch) { let glyph = match self.glyphs.get(&ch) {
Some(glyph) => glyph, Some(glyph) => glyph,
None => { None => {
return Err(::std::io::Error::new( return Err(::std::io::Error::other(format!(
::std::io::ErrorKind::Other, "glyph for {:} not in cache",
format!("glyph for {:} not in cache", ch), ch
)) )));
} }
}; };
let width = glyph.advance; let width = glyph.advance;
@@ -252,10 +252,10 @@ impl<'a> Font<'a> {
let glyph = match self.glyphs.get(&ch) { let glyph = match self.glyphs.get(&ch) {
Some(glyph) => glyph, Some(glyph) => glyph,
None => { None => {
return Err(::std::io::Error::new( return Err(::std::io::Error::other(format!(
::std::io::ErrorKind::Other, "glyph for {:} not in cache",
format!("glyph for {:} not in cache", ch), ch
)) )));
} }
}; };
glyphs.push(glyph); glyphs.push(glyph);
@@ -284,10 +284,10 @@ impl<'a> Font<'a> {
let glyph = match self.glyphs.get(&ch) { let glyph = match self.glyphs.get(&ch) {
Some(glyph) => glyph, Some(glyph) => glyph,
None => { None => {
return Err(::std::io::Error::new( return Err(::std::io::Error::other(format!(
::std::io::ErrorKind::Other, "glyph for {:} not in cache",
format!("glyph for {:} not in cache", ch), ch
)) )));
} }
}; };
glyphs.push(glyph); glyphs.push(glyph);
+1 -1
View File
@@ -56,6 +56,6 @@ impl Events {
pub fn flush(&mut self) -> Vec<Event> { pub fn flush(&mut self) -> Vec<Event> {
self.dirty = false; self.dirty = false;
self.events.drain(..).collect() std::mem::take(&mut self.events)
} }
} }
+88 -22
View File
@@ -9,21 +9,48 @@ use std::{
/// FontRef is used to store Fonts on widgets. /// FontRef is used to store Fonts on widgets.
pub type FontRef<'a> = &'a rusttype::Font<'a>; pub type FontRef<'a> = &'a rusttype::Font<'a>;
/// Ask fontconfig for the file backing a font name ("sans", "monospace",
/// "Noto Sans", ...), the way fc-match does.
#[cfg(feature = "fontconfig")] #[cfg(feature = "fontconfig")]
pub(crate) fn find_font(name: &str) -> String { pub(crate) fn find_font(name: &str) -> Result<String, String> {
use fontconfig::Fontconfig as FontConfig; use fontconfig::Fontconfig as FontConfig;
let fc = FontConfig::new().unwrap(); let fc = FontConfig::new().ok_or("could not initialise fontconfig")?;
fc.find(name, None) let found = fc
.unwrap() .find(name, None)
.ok_or_else(|| format!("fontconfig has no match for {:?}", name))?;
found
.path .path
.to_str() .to_str()
.unwrap() .map(|s| s.to_string())
.to_string() .ok_or_else(|| format!("font path for {:?} is not UTF-8", name))
} }
#[cfg(not(feature = "fontconfig"))] #[cfg(not(feature = "fontconfig"))]
pub(crate) fn find_font(_name: &str) -> String { pub(crate) fn find_font(name: &str) -> Result<String, String> {
panic!("fontconfig not enabled so font search not available"); Err(format!(
"no path configured for font {:?} and wldash was built without fontconfig",
name
))
}
/// Resolve the file to load for `name`: a path pinned in the config if it
/// exists on this machine, otherwise whatever fontconfig picks for the name.
///
/// Configs roam between machines on a shared home, and font packages do
/// not keep the same file layout everywhere (Noto under fonts/noto on one
/// distribution, fonts/google-noto on another), so a pinned path that is
/// missing is a reason to fall back, not to abort.
pub(crate) fn resolve_font_path(name: &str, pinned: Option<&str>) -> Result<String, String> {
if let Some(p) = pinned {
if Path::new(p).is_file() {
return Ok(p.to_string());
}
eprintln!(
"wldash: configured font file for {:?} not found: {}; asking fontconfig instead",
name, p
);
}
find_font(name)
} }
/// FontLoader is a marker struct that is used to load files /// FontLoader is a marker struct that is used to load files
@@ -31,17 +58,20 @@ pub(crate) struct FontLoader;
impl FontLoader { impl FontLoader {
/// Given a path, loads it as a Font, which can be rendered to the screen. /// Given a path, loads it as a Font, which can be rendered to the screen.
pub(crate) fn from_path<'a, P>(path: P) -> Option<Font<'a>> pub(crate) fn from_path<'a, P>(path: P) -> Result<Font<'a>, String>
where where
P: AsRef<Path>, P: AsRef<Path>,
{ {
let mut file = File::open(path).expect("Font file not found"); let path = path.as_ref();
let mut file =
File::open(path).map_err(|e| format!("cannot open {}: {}", path.display(), e))?;
let mut data = match file.metadata() { let mut data = match file.metadata() {
Ok(metadata) => Vec::with_capacity(metadata.len() as usize), Ok(metadata) => Vec::with_capacity(metadata.len() as usize),
Err(_) => vec![], Err(_) => vec![],
}; };
file.read_to_end(&mut data).unwrap(); file.read_to_end(&mut data)
Font::try_from_vec(data) .map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
Font::try_from_vec(data).ok_or_else(|| format!("{} is not a usable font", path.display()))
} }
} }
@@ -70,10 +100,13 @@ impl PartialEq for ComparableF32 {
impl Eq for ComparableF32 {} impl Eq for ComparableF32 {}
/// font name -> (font name, [(size, glyphs to preload)])
type RequiredFonts = HashMap<&'static str, (&'static str, Vec<(f32, &'static str)>)>;
pub struct FontMap { pub struct FontMap {
fonts: HashMap<(&'static str, ComparableF32), draw::Font<'static>>, fonts: HashMap<(&'static str, ComparableF32), draw::Font<'static>>,
font_paths: HashMap<&'static str, String>, font_paths: HashMap<&'static str, String>,
required_fonts: HashMap<&'static str, (&'static str, Vec<(f32, &'static str)>)>, required_fonts: RequiredFonts,
} }
impl FontMap { impl FontMap {
@@ -99,19 +132,28 @@ impl FontMap {
self.font_paths.insert(font_name, font_path); self.font_paths.insert(font_name, font_path);
} }
/// Load every queued font. Runs on its own thread; a font that cannot
/// be found or read is a configuration problem the user has to see, so
/// it is reported in one line and the process exits instead of
/// aborting with a panic backtrace.
pub fn load_fonts(&mut self) { pub fn load_fonts(&mut self) {
for (font_name, v) in self.required_fonts.iter() { for (font_name, v) in self.required_fonts.iter() {
let path = match self.font_paths.get(font_name) { let pinned = self.font_paths.get(font_name).map(|s| s.as_str());
Some(res) => res, let path = match resolve_font_path(font_name, pinned) {
_ => { Ok(p) => p,
let s = find_font(font_name); Err(e) => {
self.font_paths.insert(font_name, s); eprintln!("wldash: cannot resolve font {:?}: {}", font_name, e);
self.font_paths.get(font_name).unwrap() std::process::exit(1);
} }
}; };
let fontref = Box::leak(Box::new( let font = match FontLoader::from_path(&path) {
FontLoader::from_path(path).expect("unable to load font"), Ok(f) => f,
)); Err(e) => {
eprintln!("wldash: cannot load font {:?}: {}", font_name, e);
std::process::exit(1);
}
};
let fontref = Box::leak(Box::new(font));
for (size, preload) in &v.1 { for (size, preload) in &v.1 {
if let Some(font) = self.fonts.get_mut(&(font_name, ComparableF32(*size))) { if let Some(font) = self.fonts.get_mut(&(font_name, ComparableF32(*size))) {
font.add_str_to_cache(preload); font.add_str_to_cache(preload);
@@ -157,3 +199,27 @@ impl MaybeFontMap {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::resolve_font_path;
#[test]
fn pinned_path_wins_when_present() {
let dir = std::env::temp_dir().join(format!("wldash-font-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let f = dir.join("x.ttf");
std::fs::write(&f, b"not really a font").unwrap();
let p = f.to_str().unwrap();
assert_eq!(resolve_font_path("sans", Some(p)).unwrap(), p);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn missing_pinned_path_falls_back_to_lookup() {
// Whatever fontconfig answers (or an error without fontconfig),
// the missing pinned path itself must never be returned.
let r = resolve_font_path("sans", Some("/nonexistent/wldash/font.ttf"));
assert_ne!(r.as_deref().ok(), Some("/nonexistent/wldash/font.ttf"));
}
}
+7 -7
View File
@@ -12,12 +12,9 @@
//! calloop event loop. Not doing so will prevent key repetition to work //! calloop event loop. Not doing so will prevent key repetition to work
//! (but the rest of the functionnality will not be affected). //! (but the rest of the functionnality will not be affected).
use std::{ use std::{default::Default, os::unix::io::OwnedFd};
default::Default,
os::unix::io::{AsRawFd, OwnedFd},
};
use wayland_client::{protocol::wl_keyboard, WEnum}; use wayland_client::{WEnum, protocol::wl_keyboard};
use xkbcommon::xkb; use xkbcommon::xkb;
pub use xkbcommon::xkb::keysyms; pub use xkbcommon::xkb::keysyms;
@@ -31,6 +28,7 @@ pub use self::state::ModifiersState;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct KeyEvent { pub struct KeyEvent {
#[allow(dead_code)]
pub rawkey: u32, pub rawkey: u32,
pub keysym: u32, pub keysym: u32,
pub state: WEnum<wl_keyboard::KeyState>, pub state: WEnum<wl_keyboard::KeyState>,
@@ -46,7 +44,7 @@ pub struct KeymapDescription {
impl KeymapDescription { impl KeymapDescription {
fn new(fd: OwnedFd, size: u32) -> KeymapDescription { fn new(fd: OwnedFd, size: u32) -> KeymapDescription {
KeymapDescription { fd: fd, size: size } KeymapDescription { fd, size }
} }
} }
@@ -77,7 +75,9 @@ impl Keyboard {
WEnum::Value(wl_keyboard::KeymapFormat::NoKeymap) => { WEnum::Value(wl_keyboard::KeymapFormat::NoKeymap) => {
// TODO: how to handle this (hopefully never occuring) case? // TODO: how to handle this (hopefully never occuring) case?
} }
_ => unreachable!(), // Unknown format from a newer protocol version: ignore rather than
// abort the whole process.
_ => (),
} }
} }
+17 -8
View File
@@ -1,11 +1,11 @@
use std::time::Duration; use std::time::Duration;
use calloop::{ use calloop::{
EventSource, Poll, PostAction, Readiness, Token, TokenFactory,
channel::{self, Channel}, channel::{self, Channel},
timer::{TimeoutAction, Timer}, timer::{TimeoutAction, Timer},
EventSource, Poll, PostAction, Readiness, Token, TokenFactory,
}; };
use wayland_client::{protocol::wl_keyboard, WEnum}; use wayland_client::{WEnum, protocol::wl_keyboard};
use super::KeyEvent; use super::KeyEvent;
@@ -89,11 +89,18 @@ impl EventSource for KeyRepeatSource {
_ => (), _ => (),
}, },
RepeatMessage::RepeatInfo((new_rate, new_delay)) => { RepeatMessage::RepeatInfo((new_rate, new_delay)) => {
*rate = Duration::from_micros(1_000_000 / new_rate as u64); // Per wl_keyboard.repeat_info, a rate of 0 disables
*delay = Duration::from_millis(new_delay as u64); // key repeat entirely. Dividing by it would panic.
*disabled = false; if new_rate == 0 {
if key.is_some() { *disabled = true;
timer.set_deadline(now + *delay); key.take();
} else {
*rate = Duration::from_micros(1_000_000 / new_rate as u64);
*delay = Duration::from_millis(new_delay as u64);
*disabled = false;
if key.is_some() {
timer.set_deadline(now + *delay);
}
} }
} }
}, },
@@ -129,7 +136,9 @@ impl EventSource for KeyRepeatSource {
let mut next_deadline = deadline; let mut next_deadline = deadline;
if now - deadline > *rate * 2 { if now - deadline > *rate * 2 {
// We're way behind, let's just set a new time // We're way behind, let's just set a new time
println!("KeyRepeatSource::process_events: More than two repeat cycles behind, resetting"); println!(
"KeyRepeatSource::process_events: More than two repeat cycles behind, resetting"
);
next_deadline = now + *rate; next_deadline = now + *rate;
} else { } else {
while next_deadline < now { while next_deadline < now {
+9 -11
View File
@@ -81,17 +81,15 @@ impl KbState {
} }
pub(crate) fn compose_feed(&mut self, keysym: u32) -> Option<xkb::compose::FeedResult> { pub(crate) fn compose_feed(&mut self, keysym: u32) -> Option<xkb::compose::FeedResult> {
match &mut self.xkb_compose_state { self.xkb_compose_state
Some(compose_state) => Some(compose_state.feed(xkb::Keysym::new(keysym))), .as_mut()
None => None, .map(|compose_state| compose_state.feed(xkb::Keysym::new(keysym)))
}
} }
pub(crate) fn compose_status(&mut self) -> Option<xkb::compose::Status> { pub(crate) fn compose_status(&mut self) -> Option<xkb::compose::Status> {
match &mut self.xkb_compose_state { self.xkb_compose_state
Some(compose_state) => Some(compose_state.status()), .as_mut()
None => None, .map(|compose_state| compose_state.status())
}
} }
pub(crate) fn compose_get_utf8(&mut self) -> Option<String> { pub(crate) fn compose_get_utf8(&mut self) -> Option<String> {
@@ -130,11 +128,11 @@ impl KbState {
pub(crate) fn init_compose(&mut self) { pub(crate) fn init_compose(&mut self) {
let locale = env::var_os("LC_ALL") let locale = env::var_os("LC_ALL")
.and_then(|v| if v.is_empty() { None } else { Some(v) }) .filter(|v| !v.is_empty())
.or_else(|| env::var_os("LC_CTYPE")) .or_else(|| env::var_os("LC_CTYPE"))
.and_then(|v| if v.is_empty() { None } else { Some(v) }) .filter(|v| !v.is_empty())
.or_else(|| env::var_os("LANG")) .or_else(|| env::var_os("LANG"))
.and_then(|v| if v.is_empty() { None } else { Some(v) }) .filter(|v| !v.is_empty())
.unwrap_or_else(|| "C".into()); .unwrap_or_else(|| "C".into());
let compose_table = match xkb::compose::Table::new_from_locale( let compose_table = match xkb::compose::Table::new_from_locale(
+77 -38
View File
@@ -12,8 +12,8 @@ mod widgets;
use wayland_client::{Connection, QueueHandle, WaylandSource}; use wayland_client::{Connection, QueueHandle, WaylandSource};
use calloop::{ use calloop::{
timer::{TimeoutAction, Timer},
EventLoop, EventLoop,
timer::{TimeoutAction, Timer},
}; };
use chrono::{Duration, Local, Timelike}; use chrono::{Duration, Local, Timelike};
@@ -23,17 +23,15 @@ use event::{Event, Events};
use fonts::{FontMap, MaybeFontMap}; use fonts::{FontMap, MaybeFontMap};
use keyboard::{KeyRepeatSource, RepeatMessage}; use keyboard::{KeyRepeatSource, RepeatMessage};
use state::State; use state::State;
use utils::{ use utils::xdg;
desktop::{load_desktop_files, write_desktop_cache},
xdg,
};
use widgets::{Geometry, Widget}; use widgets::{Geometry, Widget};
use std::{ use std::{
env, env, fs,
fs::File, fs::File,
io::{BufRead, BufReader, Write}, io::{BufRead, BufReader, Write},
os::unix::net::{UnixListener, UnixStream}, os::unix::net::{UnixListener, UnixStream},
path::Path,
rc::Rc, rc::Rc,
thread, thread,
}; };
@@ -45,26 +43,58 @@ usage: wldash [OPTIONS]
OPTIONS: OPTIONS:
--config CONFIG_FILE use the specified config file --config CONFIG_FILE use the specified config file
(~/.config/wldash/config.yml by default) (/etc/wldash/config.yml if present,
else ~/.config/wldash/config.yml)
--write-default-config [v1|v2|toplevel] generate and write a new config file --write-default-config [v1|v2|toplevel] generate and write a new config file
(v2 by default) (v2 by default)
--desktop-refresh refresh desktop file cache --desktop-refresh no-op kept for old key bindings
(desktop files are scanned at start)
" "
); );
} }
fn die(msg: String) -> ! {
eprintln!("wldash: {}", msg);
std::process::exit(1);
}
fn ensure_dir_exists(path: &str) {
if let Err(err) = fs::create_dir_all(path) {
die(format!("error creating directory {}: {}", path, err));
}
}
fn create_required_directories() {
let dir_config_wldash = format!("{}/wldash", xdg::config_folder());
ensure_dir_exists(&dir_config_wldash);
}
/// Default config path. On a shared (e.g. NFS-roamed) home, a host-specific
/// config at /etc/wldash/config.yml takes precedence over the roamed
/// ~/.config/wldash/config.yml, so each machine can keep its own layout
/// without conflicting. An explicit --config still overrides both.
fn default_config_file() -> String {
let etc = "/etc/wldash/config.yml";
if Path::new(etc).exists() {
return etc.to_string();
}
format!("{}/wldash/config.yml", xdg::config_folder())
}
fn main() { fn main() {
let mut args = env::args(); let mut args = env::args();
// Skip program name // Skip program name
_ = args.next(); _ = args.next();
let mut config_file = format!("{}/wldash/config.yml", xdg::config_folder()); create_required_directories();
let mut config_file = default_config_file();
loop { loop {
match args.next() { match args.next() {
Some(ref s) if s == "--config" => match args.next() { Some(ref s) if s == "--config" => match args.next() {
Some(ref s) => config_file = s.clone(), Some(ref s) => config_file = s.clone(),
None => panic!("missing argument to --config"), None => die("missing argument to --config".to_string()),
}, },
Some(ref s) if s == "--write-default-config" => { Some(ref s) if s == "--write-default-config" => {
let config = match args.next() { let config = match args.next() {
@@ -72,17 +102,20 @@ fn main() {
Some(ref s) if s == "v2" => Config::generate_v2(false), Some(ref s) if s == "v2" => Config::generate_v2(false),
Some(ref s) if s == "toplevel" => Config::generate_v2(true), Some(ref s) if s == "toplevel" => Config::generate_v2(true),
None => Config::generate_v2(false), None => Config::generate_v2(false),
Some(s) => panic!("unknown argument: {}", s), Some(s) => die(format!("unknown argument to --write-default-config: {}", s)),
}; };
match File::create(config_file) { match File::create(&config_file) {
Ok(f) => serde_yaml::to_writer(f, &config).unwrap(), Ok(f) => {
Err(_) => panic!("uh"), if let Err(e) = serde_yaml::to_writer(f, &config) {
die(format!("could not serialize config: {}", e));
}
}
Err(e) => die(format!("could not write {}: {}", config_file, e)),
} }
std::process::exit(0); std::process::exit(0);
} }
Some(ref s) if s == "--desktop-refresh" => { Some(ref s) if s == "--desktop-refresh" => {
let v = load_desktop_files(); eprintln!("wldash: desktop files are scanned at every start; nothing to refresh");
write_desktop_cache(&v).unwrap();
std::process::exit(0); std::process::exit(0);
} }
Some(ref s) if s == "--help" => { Some(ref s) if s == "--help" => {
@@ -97,7 +130,10 @@ fn main() {
} }
} }
let conn = Connection::connect_to_env().unwrap(); let conn = match Connection::connect_to_env() {
Ok(c) => c,
Err(e) => die(format!("could not connect to the Wayland display: {}", e)),
};
let event_queue = conn.new_event_queue(); let event_queue = conn.new_event_queue();
let qhandle: QueueHandle<State> = event_queue.handle(); let qhandle: QueueHandle<State> = event_queue.handle();
@@ -116,7 +152,7 @@ fn main() {
handle handle
.insert_source( .insert_source(
WaylandSource::new(event_queue).expect("Could not create WaylandSource!"), WaylandSource::new(event_queue).expect("Could not create WaylandSource!"),
|_event, queue, mut state| queue.dispatch_pending(&mut state), |_event, queue, state| queue.dispatch_pending(state),
) )
.expect("Failed to insert event source!"); .expect("Failed to insert event source!");
@@ -173,9 +209,15 @@ fn main() {
let mut fm = FontMap::new(); let mut fm = FontMap::new();
let config: Config = match File::open(config_file) { let config: Config = match File::open(&config_file) {
Ok(f) => serde_yaml::from_reader(f).unwrap(), Ok(f) => match serde_yaml::from_reader(f) {
Err(_) => panic!("configuration file missing"), Ok(c) => c,
Err(e) => die(format!("invalid configuration {}: {}", config_file, e)),
},
Err(e) => die(format!(
"could not open configuration {}: {} (try 'wldash --write-default-config')",
config_file, e
)),
}; };
if let Some(true) = config.server { if let Some(true) = config.server {
@@ -190,23 +232,20 @@ fn main() {
.spawn(move || { .spawn(move || {
let listener = UnixListener::bind(socket_path.clone()).unwrap(); let listener = UnixListener::bind(socket_path.clone()).unwrap();
loop { loop {
match listener.accept() { if let Ok((stream, _)) = listener.accept() {
Ok((stream, _)) => { let _ = thread::Builder::new().name("ipc_client".to_string()).spawn(
let _ = thread::Builder::new().name("ipc_client".to_string()).spawn( move || {
move || { let r = BufReader::new(stream);
let r = BufReader::new(stream); for line in r.lines() {
for line in r.lines() { match line {
match line { Ok(ref cmd) if cmd == "kill" => {
Ok(ref cmd) if cmd == "kill" => { std::process::exit(0);
std::process::exit(0);
}
_ => {}
} }
_ => {}
} }
}, }
); },
} );
_ => (),
} }
} }
}); });
@@ -261,7 +300,7 @@ fn main() {
} }
let mut force = false; let mut force = false;
if state.bufmgr.buffers.len() == 0 { if state.bufmgr.buffers.is_empty() {
state.add_buffer(&qhandle); state.add_buffer(&qhandle);
force = true; force = true;
} }
@@ -285,7 +324,7 @@ fn main() {
} }
}; };
if buf.last_damage.len() == 0 { if buf.last_damage.is_empty() {
for _ in 0..state.widgets.len() { for _ in 0..state.widgets.len() {
buf.last_damage.push(Geometry::new()); buf.last_damage.push(Geometry::new());
} }
+41 -47
View File
@@ -7,11 +7,11 @@ use std::{
}; };
use wayland_client::{ use wayland_client::{
Connection, Dispatch, QueueHandle, WEnum,
protocol::{ protocol::{
wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat, wl_buffer, wl_callback, wl_compositor, wl_keyboard, wl_pointer, wl_registry, wl_seat,
wl_shm, wl_shm_pool, wl_subcompositor, wl_subsurface, wl_surface, wl_shm, wl_shm_pool, wl_subcompositor, wl_subsurface, wl_surface,
}, },
Connection, Dispatch, QueueHandle, WEnum,
}; };
use wayland_protocols::{ use wayland_protocols::{
@@ -133,17 +133,16 @@ impl State {
Ok(token) => token, Ok(token) => token,
Err(_) => return, Err(_) => return,
}; };
match ( if let (Some(surface), Some(activation)) = (
&self.main_surface.wl_surface, &self.main_surface.wl_surface,
&self.protocols.xdg_activation, &self.protocols.xdg_activation,
) { ) {
(Some(surface), Some(activation)) => { activation.activate(key, surface);
activation.activate(key, surface); let activation_token = activation.get_activation_token(qh, ());
let activation_token = activation.get_activation_token(qh, ()); activation_token.commit();
activation_token.commit(); unsafe {
env::remove_var("XDG_ACTIVATION_TOKEN"); env::remove_var("XDG_ACTIVATION_TOKEN");
} }
_ => (),
} }
} }
} }
@@ -331,9 +330,8 @@ impl Dispatch<wl_callback::WlCallback, ()> for State {
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
match event { if let wl_callback::Event::Done { callback_data: _ } = event {
wl_callback::Event::Done { callback_data: _ } => state.ready = true, state.ready = true
_ => {}
} }
} }
} }
@@ -461,12 +459,15 @@ impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for State {
(width, height) = (640, 480); (width, height) = (640, 480);
} }
layer_surface.ack_configure(serial); layer_surface.ack_configure(serial);
let first_configure = !state.configured;
state.configured = true; state.configured = true;
state.dirty = true; state.dirty = true;
let dim = (width as i32, height as i32); let dim = (width as i32, height as i32);
if state.configured && state.dimensions == dim { // Always lay out on the first configure, even if the
// compositor happens to hand us our placeholder dimensions.
if !first_configure && state.dimensions == dim {
return; return;
} }
state.bufmgr.clear_buffers(); state.bufmgr.clear_buffers();
@@ -478,8 +479,8 @@ impl Dispatch<zwlr_layer_surface_v1::ZwlrLayerSurfaceV1, ()> for State {
&Geometry { &Geometry {
x: 0, x: 0,
y: 0, y: 0,
width: width as u32, width,
height: height as u32, height,
}, },
state, state,
); );
@@ -656,24 +657,26 @@ impl Dispatch<wl_keyboard::WlKeyboard, ()> for State {
state: kbstate, state: kbstate,
.. ..
} => { } => {
if key == 1 {
state.running = false;
return;
}
state.keyboard.resolve(); state.keyboard.resolve();
let k = state.keyboard.key(key, kbstate); let k = state.keyboard.key(key, kbstate);
let repeats = k.repeats; let repeats = k.repeats;
let ev = Event::KeyEvent(k); let ev = Event::KeyEvent(k);
// Widgets see the key first so the launcher gets to save its
// prompt on Escape (the early-return used to swallow it).
for widget in state.widgets.iter_mut() { for widget in state.widgets.iter_mut() {
widget.event(&ev); widget.event(&ev);
} }
if repeats { // Raw keycode 1 is KEY_ESC: fall back to quitting even when no
if let Event::KeyEvent(k) = ev { // keymap could be loaded and no widget handled it.
state if key == 1 && kbstate == WEnum::Value(wl_keyboard::KeyState::Pressed) {
.keyrepeat_sender state.running = false;
.send(RepeatMessage::KeyEvent(k)) return;
.unwrap(); }
} if repeats && let Event::KeyEvent(k) = ev {
state
.keyrepeat_sender
.send(RepeatMessage::KeyEvent(k))
.unwrap();
} }
state.dirty = true; state.dirty = true;
} }
@@ -733,8 +736,8 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
state.pointer = Some((surface_x, surface_y)); state.pointer = Some((surface_x, surface_y));
} }
wl_pointer::Event::Leave { .. } => state.pointer = None, wl_pointer::Event::Leave { .. } => state.pointer = None,
wl_pointer::Event::Axis { axis, value, .. } => match state.pointer { wl_pointer::Event::Axis { axis, value, .. } => {
Some((x, y)) => { if let Some((x, y)) = state.pointer {
let axis = match axis { let axis = match axis {
WEnum::Value(wl_pointer::Axis::VerticalScroll) => { WEnum::Value(wl_pointer::Axis::VerticalScroll) => {
PointerButton::ScrollVertical(value) PointerButton::ScrollVertical(value)
@@ -749,24 +752,22 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
let geo = widget.geometry(); let geo = widget.geometry();
if geo.contains(pos) { if geo.contains(pos) {
let pos = (pos.0 - geo.x, pos.1 - geo.y); let pos = (pos.0 - geo.x, pos.1 - geo.y);
let ev = Event::PointerEvent(PointerEvent { let ev = Event::PointerEvent(PointerEvent { button: axis, pos });
button: axis,
pos: pos,
});
widget.event(&ev); widget.event(&ev);
state.dirty = true; state.dirty = true;
break; break;
} }
} }
} }
_ => (), }
},
wl_pointer::Event::Button { wl_pointer::Event::Button {
button, button,
state: button_state, state: button_state,
.. ..
} => match (button, button_state, state.pointer) { } => {
(button, WEnum::Value(wl_pointer::ButtonState::Pressed), Some((x, y))) => { if let (button, WEnum::Value(wl_pointer::ButtonState::Pressed), Some((x, y))) =
(button, button_state, state.pointer)
{
let button = match button { let button = match button {
BTN_LEFT => PointerButton::Left, BTN_LEFT => PointerButton::Left,
BTN_RIGHT => PointerButton::Right, BTN_RIGHT => PointerButton::Right,
@@ -778,18 +779,14 @@ impl Dispatch<wl_pointer::WlPointer, ()> for State {
let geo = widget.geometry(); let geo = widget.geometry();
if geo.contains(pos) { if geo.contains(pos) {
let pos = (pos.0 - geo.x, pos.1 - geo.y); let pos = (pos.0 - geo.x, pos.1 - geo.y);
let ev = Event::PointerEvent(PointerEvent { let ev = Event::PointerEvent(PointerEvent { button, pos });
button: button,
pos: pos,
});
widget.event(&ev); widget.event(&ev);
state.dirty = true; state.dirty = true;
break; break;
} }
} }
} }
_ => (), }
},
_ => (), _ => (),
} }
} }
@@ -852,14 +849,11 @@ impl Dispatch<xdg_activation_token_v1::XdgActivationTokenV1, ()> for State {
_: &Connection, _: &Connection,
_: &QueueHandle<Self>, _: &QueueHandle<Self>,
) { ) {
match event { if let xdg_activation_token_v1::Event::Done { token } = event {
xdg_activation_token_v1::Event::Done { token } => { let ev = Event::TokenUpdate(token);
let ev = Event::TokenUpdate(token); for widget in state.widgets.iter_mut() {
for widget in state.widgets.iter_mut() { widget.event(&ev);
widget.event(&ev);
}
} }
_ => (),
} }
} }
} }
+30 -22
View File
@@ -2,18 +2,17 @@ use std::{
cmp::Ordering, cmp::Ordering,
collections::HashSet, collections::HashSet,
error::Error, error::Error,
fs::{read_to_string, File}, fs::read_to_string,
hash::{Hash, Hasher},
io::{Error as io_error, ErrorKind}, io::{Error as io_error, ErrorKind},
path::Path,
}; };
use serde::{self, Deserialize, Serialize};
use simd_json;
use walkdir::WalkDir; use walkdir::WalkDir;
use crate::utils::{inish, xdg}; use crate::utils::{inish, xdg};
#[derive(Serialize, Deserialize, Clone, Debug, Eq, Hash)] #[derive(Clone, Debug, Eq)]
#[serde(rename_all = "snake_case")]
pub struct Desktop { pub struct Desktop {
pub entry_type: String, pub entry_type: String,
pub name: String, pub name: String,
@@ -26,7 +25,7 @@ pub struct Desktop {
} }
impl Desktop { impl Desktop {
fn parse(f: &str) -> Result<Desktop, Box<dyn Error>> { fn parse(f: &Path) -> Result<Desktop, Box<dyn Error>> {
let s = read_to_string(f)?; let s = read_to_string(f)?;
let config = inish::parse(&s)?; let config = inish::parse(&s)?;
match config.get("Desktop Entry") { match config.get("Desktop Entry") {
@@ -43,10 +42,10 @@ impl Desktop {
.map(|x| { .map(|x| {
x.split(';') x.split(';')
.map(|y| y.trim().to_string()) .map(|y| y.trim().to_string())
.filter(|z| z != "") .filter(|z| !z.is_empty())
.collect() .collect()
}) })
.unwrap_or_else(|| vec![]), .unwrap_or_else(std::vec::Vec::new),
}), }),
None => Err(Box::new(io_error::new( None => Err(Box::new(io_error::new(
ErrorKind::NotFound, ErrorKind::NotFound,
@@ -74,20 +73,20 @@ impl PartialEq for Desktop {
} }
} }
pub fn load_desktop_cache() -> Result<Vec<Desktop>, Box<dyn Error>> { // Must agree with PartialEq: equal entries (same name) must hash equally.
match File::open(format!("{}/wldash/desktop.json", xdg::cache_folder())) { impl Hash for Desktop {
Ok(f) => simd_json::from_reader(f).map_err(|e| e.into()), fn hash<H: Hasher>(&self, state: &mut H) {
Err(e) => Err(e.into()), self.name.hash(state)
}
}
pub fn write_desktop_cache(v: &Vec<Desktop>) -> Result<(), Box<dyn Error>> {
match File::create(format!("{}/wldash/desktop.json", xdg::cache_folder())) {
Ok(f) => simd_json::to_writer(f, v).map_err(|e| e.into()),
Err(e) => Err(e.into()),
} }
} }
/// Scan every XDG applications directory for launchable entries.
///
/// This used to be cached as JSON under XDG_CACHE_HOME and refreshed by
/// hand with `--desktop-refresh`. The scan is a few milliseconds over
/// local /usr/share and runs on a background thread, while the cache sat
/// on the (possibly NFS-mounted) home directory and went stale whenever a
/// package was installed. So: no cache.
pub fn load_desktop_files() -> Vec<Desktop> { pub fn load_desktop_files() -> Vec<Desktop> {
let dirs = xdg::data_folders(); let dirs = xdg::data_folders();
@@ -95,9 +94,18 @@ pub fn load_desktop_files() -> Vec<Desktop> {
let mut seen: HashSet<String> = HashSet::new(); let mut seen: HashSet<String> = HashSet::new();
for dir in dirs { for dir in dirs {
for entry in WalkDir::new(format!("{}/applications", dir)) { for entry in WalkDir::new(format!("{}/applications", dir))
if let Ok(entry) = entry { .into_iter()
if let Ok(d) = Desktop::parse(entry.path().to_str().unwrap()) { .flatten()
{
// Entries are often symlinks, so only rule out directories.
if entry.file_type().is_dir()
|| entry.path().extension().and_then(|e| e.to_str()) != Some("desktop")
{
continue;
}
{
if let Ok(d) = Desktop::parse(entry.path()) {
if d.hidden if d.hidden
|| d.no_display || d.no_display
|| (d.entry_type != "Application" && d.entry_type != "Link") || (d.entry_type != "Application" && d.entry_type != "Link")
+3 -3
View File
@@ -1,6 +1,6 @@
use std::{collections::HashMap, error::Error}; use std::{collections::HashMap, error::Error};
fn parse_field<'a>(line: &'a str) -> Result<(&'a str, &'a str), Box<dyn Error>> { fn parse_field(line: &str) -> Result<(&str, &str), Box<dyn Error>> {
let split = match line.find('=') { let split = match line.find('=') {
Some(v) => v, Some(v) => v,
None => return Err("expected equals sign on line, but found none".into()), None => return Err("expected equals sign on line, but found none".into()),
@@ -30,8 +30,8 @@ pub fn parse<'a>(s: &'a str) -> Result<Inish<'a>, Box<dyn Error>> {
(Some('['), Some(']')) => { (Some('['), Some(']')) => {
sections.insert(current_section_name, current_section); sections.insert(current_section_name, current_section);
current_section = HashMap::new(); current_section = HashMap::new();
let len = line.bytes().count(); let len = line.len();
current_section_name = &line[1..len - 1].trim(); current_section_name = line[1..len - 1].trim();
} }
(Some('['), v) => { (Some('['), v) => {
return Err(format!( return Err(format!(
+12
View File
@@ -10,6 +10,18 @@ pub fn cache_folder() -> String {
} }
} }
/// XDG_STATE_HOME: small persistent state (calculator history, saved
/// prompt). Unlike a cache this is worth roaming with the home directory.
pub fn state_folder() -> String {
match env::var_os("XDG_STATE_HOME") {
Some(s) => s.into_string().unwrap(),
None => {
let home = env::var_os("HOME").unwrap().into_string().unwrap();
format!("{}/.local/state", home)
}
}
}
pub fn config_folder() -> String { pub fn config_folder() -> String {
match env::var_os("XDG_CONFIG_HOME") { match env::var_os("XDG_CONFIG_HOME") {
Some(s) => s.into_string().unwrap(), Some(s) => s.into_string().unwrap(),
+70 -30
View File
@@ -1,6 +1,6 @@
use std::{ use std::{
fs::{self, OpenOptions}, fs::{self, OpenOptions},
io::{Error, ErrorKind, Write}, io::{Error, Write},
path::{Path, PathBuf}, path::{Path, PathBuf},
}; };
@@ -17,7 +17,7 @@ fn read_file_as_u64(path: &Path) -> Result<u64, Error> {
let mut s = fs::read_to_string(path)?; let mut s = fs::read_to_string(path)?;
s.pop(); s.pop();
s.parse::<u64>() s.parse::<u64>()
.map_err(|_e| Error::new(ErrorKind::Other, "unable to parse value")) .map_err(|_e| Error::other("unable to parse value"))
} }
fn write_file_as_u64(path: &Path, value: u64) -> Result<(), Error> { fn write_file_as_u64(path: &Path, value: u64) -> Result<(), Error> {
@@ -26,31 +26,69 @@ fn write_file_as_u64(path: &Path, value: u64) -> Result<(), Error> {
} }
pub struct Backlight { pub struct Backlight {
device_path: PathBuf, /// `None` when no backlight exists on this machine: the widget then
/// renders empty and ignores clicks instead of taking wldash down.
/// Configs are shared between hosts, so a laptop config running on a
/// desktop must not panic here.
device_path: Option<PathBuf>,
cur: u64, cur: u64,
max: u64, max: u64,
dirty: bool, dirty: bool,
} }
/// First device under /sys/class/backlight, if any. `min_depth(1)` skips the
/// class directory itself, which otherwise got picked up as a "device".
fn find_device() -> Option<PathBuf> {
WalkDir::new("/sys/class/backlight")
.min_depth(1)
.max_depth(1)
.into_iter()
.flatten()
.map(|e| e.path().to_path_buf())
.next()
}
impl Backlight { impl Backlight {
fn update(&mut self) { fn update(&mut self) {
self.cur = read_file_as_u64(self.device_path.join("brightness").as_path()).unwrap(); let Some(dev) = &self.device_path else {
self.max = read_file_as_u64(self.device_path.join("max_brightness").as_path()).unwrap(); return;
};
if let Ok(cur) = read_file_as_u64(dev.join("brightness").as_path()) {
self.cur = cur;
}
if let Ok(max) = read_file_as_u64(dev.join("max_brightness").as_path()) {
self.max = max;
}
} }
// Wraps the impl in the generic BarWidget on purpose.
#[allow(clippy::new_ret_no_self)]
pub fn new(path: Option<&str>, fm: &mut FontMap, font: &'static str, size: f32) -> BarWidget { pub fn new(path: Option<&str>, fm: &mut FontMap, font: &'static str, size: f32) -> BarWidget {
let mut device_path = None; let device_path = match path {
if let Some(path) = path { Some(path) => {
device_path = Some(Path::new("/sys/class/backlight").to_path_buf().join(path)); let p = Path::new("/sys/class/backlight").join(path);
} else { if p.is_dir() {
for entry in WalkDir::new("/sys/class/backlight") { Some(p)
if let Ok(entry) = entry { } else {
device_path = Some(entry.path().to_path_buf()); eprintln!(
"wldash: backlight device {:?} not found, widget disabled",
p
);
None
} }
} }
} None => {
let p = find_device();
if p.is_none() {
eprintln!(
"wldash: no backlight device under /sys/class/backlight, widget disabled"
);
}
p
}
};
let mut dev = Backlight { let mut dev = Backlight {
device_path: device_path.expect("did not find backlight device"), device_path,
cur: 0, cur: 0,
max: 0, max: 0,
dirty: true, dirty: true,
@@ -61,18 +99,19 @@ impl Backlight {
} }
pub fn detect() -> bool { pub fn detect() -> bool {
for entry in WalkDir::new("/sys/class/backlight") { find_device().is_some()
if entry.is_ok() {
return true;
}
}
return false;
} }
pub fn set(&mut self, brightness: f32) { pub fn set(&mut self, brightness: f32) {
let val = (self.max as f32 * brightness) as u64; let Some(dev) = &self.device_path else {
let _ = write_file_as_u64(self.device_path.join("brightness").as_path(), val); return;
self.cur = read_file_as_u64(self.device_path.join("brightness").as_path()).unwrap(); };
let val = (self.max as f32 * brightness.clamp(0.0, 1.0)) as u64;
let brightness_path = dev.join("brightness");
let _ = write_file_as_u64(&brightness_path, val);
if let Ok(cur) = read_file_as_u64(&brightness_path) {
self.cur = cur;
}
} }
} }
@@ -84,12 +123,14 @@ impl BarWidgetImpl for Backlight {
self.dirty self.dirty
} }
fn value(&mut self) -> f32 { fn value(&mut self) -> f32 {
if self.cur > self.max { self.dirty = false;
// what. if self.device_path.is_none() {
return 0.0;
}
if self.max == 0 || self.cur > self.max {
// Sysfs handed us nonsense; show full rather than a bogus ratio.
return 1.0; return 1.0;
} }
self.dirty = false;
self.cur as f32 / self.max as f32 self.cur as f32 / self.max as f32
} }
fn color(&self) -> Color { fn color(&self) -> Color {
@@ -97,9 +138,8 @@ impl BarWidgetImpl for Backlight {
} }
fn click(&mut self, pos: f32, btn: PointerButton) { fn click(&mut self, pos: f32, btn: PointerButton) {
self.dirty = true; self.dirty = true;
match btn { if let PointerButton::Left = btn {
PointerButton::Left => self.set(pos), self.set(pos)
_ => (),
}; };
} }
} }
+12 -23
View File
@@ -62,21 +62,13 @@ impl Widget for BarWidget {
let val = self.inner_widget.value(); let val = self.inner_widget.value();
let c = self.inner_widget.color(); let c = self.inner_widget.color();
draw_bar( let bar_width = self.geometry.width.saturating_sub(bar_offset);
&mut view.offset((bar_offset, 0)), if bar_width < 2 {
c, return self.geometry;
self.geometry.width - bar_offset, }
size, draw_bar(&mut view.offset((bar_offset, 0)), c, bar_width, size, val).unwrap();
val,
)
.unwrap();
draw_box( draw_box(&mut view.offset((bar_offset, 0)), c, (bar_width, size)).unwrap();
&mut view.offset((bar_offset, 0)),
c,
(self.geometry.width - bar_offset, size),
)
.unwrap();
self.geometry self.geometry
} }
@@ -87,7 +79,7 @@ impl Widget for BarWidget {
x += geometry.width - w; x += geometry.width - w;
} }
self.geometry = Geometry { self.geometry = Geometry {
x: x, x,
y: geometry.y, y: geometry.y,
width: w, width: w,
height: self.size.ceil() as u32, height: self.size.ceil() as u32,
@@ -105,15 +97,12 @@ impl Widget for BarWidget {
} }
fn event(&mut self, event: &Event) { fn event(&mut self, event: &Event) {
match event { if let Event::PointerEvent(ev) = event {
Event::PointerEvent(ev) => { let offset = 4 * self.size.ceil() as u32;
let offset = 4 * self.size.ceil() as u32; if ev.pos.0 >= offset && self.geometry.width > offset {
if ev.pos.0 >= offset { let val = (ev.pos.0 - offset) as f32 / (self.geometry.width - offset) as f32;
let val = (ev.pos.0 - offset) as f32 / (self.geometry.width - offset) as f32; self.inner_widget.click(val, ev.button);
self.inner_widget.click(val, ev.button);
}
} }
_ => (),
} }
} }
} }
+74 -65
View File
@@ -6,8 +6,8 @@ use std::{
}; };
use dbus::blocking::{ use dbus::blocking::{
stdintf::org_freedesktop_dbus::{Properties, PropertiesPropertiesChanged},
LocalConnection, LocalConnection,
stdintf::org_freedesktop_dbus::{Properties, PropertiesPropertiesChanged},
}; };
use crate::{ use crate::{
@@ -47,77 +47,87 @@ struct InnerBattery {
dirty: bool, dirty: bool,
} }
fn get_battery_state() -> Result<UpowerBatteryState, Box<dyn Error>> { const DEVICE_PATH: &str = "/org/freedesktop/UPower/devices/DisplayDevice";
const DEVICE_IFACE: &str = "org.freedesktop.UPower.Device";
/// True if UPower reports a present battery. The DisplayDevice object exists
/// even on machines without a battery, so checking for mere reachability
/// (as before) made every desktop grow a battery widget.
fn battery_present() -> Result<bool, Box<dyn Error>> {
let conn = LocalConnection::new_system()?; let conn = LocalConnection::new_system()?;
let device_path = "/org/freedesktop/UPower/devices/DisplayDevice";
let proxy = conn.with_proxy( let proxy = conn.with_proxy(
"org.freedesktop.UPower", "org.freedesktop.UPower",
device_path, DEVICE_PATH,
Duration::from_millis(500), Duration::from_millis(500),
); );
let state: u32 = proxy.get("org.freedesktop.UPower.Device", "State")?; let present: bool = proxy.get(DEVICE_IFACE, "IsPresent")?;
Ok(UpowerBatteryState::from_dbus(state as u64)) Ok(present)
}
fn monitor(
inner: Arc<Mutex<InnerBattery>>,
events: Arc<Mutex<Events>>,
) -> Result<(), Box<dyn Error>> {
let conn = LocalConnection::new_system()?;
let proxy = conn.with_proxy(
"org.freedesktop.UPower",
DEVICE_PATH,
Duration::from_millis(500),
);
let capacity: f64 = proxy.get(DEVICE_IFACE, "Percentage")?;
let state: u32 = proxy.get(DEVICE_IFACE, "State")?;
{
let mut inner = inner.lock().unwrap();
inner.value = capacity as f32 / 100.;
inner.state = UpowerBatteryState::from_dbus(state as u64);
inner.dirty = true;
}
events.lock().unwrap().add_event(Event::PowerUpdate);
proxy.match_signal(
move |c: PropertiesPropertiesChanged, _: &LocalConnection, _: &dbus::Message| {
if c.interface_name != DEVICE_IFACE {
return true;
}
let mut inner = inner.lock().unwrap();
for (key, value) in c.changed_properties {
match key.as_str() {
"State" => {
if let Some(v) = value.0.as_u64() {
inner.state = UpowerBatteryState::from_dbus(v);
inner.dirty = true;
events.lock().unwrap().add_event(Event::PowerUpdate);
}
}
"Percentage" => {
if let Some(v) = value.0.as_f64() {
inner.value = v as f32 / 100.;
inner.dirty = true;
events.lock().unwrap().add_event(Event::PowerUpdate);
}
}
_ => (),
}
}
true
},
)?;
loop {
conn.process(Duration::from_millis(60000))?;
}
} }
fn start_monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) { fn start_monitor(inner: Arc<Mutex<InnerBattery>>, events: Arc<Mutex<Events>>) {
thread::Builder::new() thread::Builder::new()
.name("battmon".to_string()) .name("battmon".to_string())
.spawn(move || { .spawn(move || {
let conn = LocalConnection::new_system().unwrap(); // The release profile uses panic = "abort": a panic here would take
let device_path = "/org/freedesktop/UPower/devices/DisplayDevice"; // the whole dashboard down, so report and leave the bar at 0.
let proxy = conn.with_proxy( if let Err(e) = monitor(inner, events) {
"org.freedesktop.UPower", eprintln!("wldash: battery monitor stopped: {}", e);
device_path,
Duration::from_millis(500),
);
let capacity: f64 = proxy
.get("org.freedesktop.UPower.Device", "Percentage")
.expect("unable to get property");
let state: u32 = proxy
.get("org.freedesktop.UPower.Device", "State")
.expect("unable to get state");
{
let mut inner = inner.lock().unwrap();
inner.value = capacity as f32 / 100.;
inner.state = UpowerBatteryState::from_dbus(state as u64);
}
proxy
.match_signal(
move |c: PropertiesPropertiesChanged,
_: &LocalConnection,
_: &dbus::Message| {
if c.interface_name != "org.freedesktop.UPower.Device" {
return true;
}
let mut inner = inner.lock().unwrap();
for (key, value) in c.changed_properties {
match key.as_str() {
"State" => {
inner.state =
UpowerBatteryState::from_dbus(value.0.as_u64().unwrap());
inner.dirty = true;
let mut events = events.lock().unwrap();
events.add_event(Event::PowerUpdate);
}
"Percentage" => {
inner.value = value.0.as_f64().unwrap() as f32 / 100.;
inner.dirty = true;
let mut events = events.lock().unwrap();
events.add_event(Event::PowerUpdate);
}
_ => (),
}
}
true
},
)
.unwrap();
loop {
conn.process(Duration::from_millis(60000)).unwrap();
} }
}) })
.unwrap(); .unwrap();
@@ -128,6 +138,8 @@ pub struct Battery {
} }
impl Battery { impl Battery {
// Wraps the impl in the generic BarWidget on purpose.
#[allow(clippy::new_ret_no_self)]
pub fn new( pub fn new(
events: Arc<Mutex<Events>>, events: Arc<Mutex<Events>>,
fm: &mut FontMap, fm: &mut FontMap,
@@ -146,10 +158,7 @@ impl Battery {
} }
pub fn detect() -> bool { pub fn detect() -> bool {
match get_battery_state() { battery_present().unwrap_or(false)
Ok(_) => true,
Err(_) => false,
}
} }
} }
+12 -18
View File
@@ -69,7 +69,7 @@ impl Calendar {
fn draw_month( fn draw_month(
&mut self, &mut self,
fonts: &mut FontMap, fonts: &mut FontMap,
mut view: &mut BufferView, view: &mut BufferView,
orig: NaiveDate, orig: NaiveDate,
mut time: NaiveDate, mut time: NaiveDate,
) { ) {
@@ -100,7 +100,7 @@ impl Calendar {
+ (self.size * DAY_FACTOR * 0.2).ceil() as u32, + (self.size * DAY_FACTOR * 0.2).ceil() as u32,
)), )),
c, c,
&wk_chr, wk_chr,
) )
.unwrap(); .unwrap();
} }
@@ -124,9 +124,7 @@ impl Calendar {
12 => "December", 12 => "December",
_ => panic!("impossible value"), _ => panic!("impossible value"),
}; };
month_font month_font.draw_text(view, Color::WHITE, month_str).unwrap();
.draw_text(&mut view, Color::WHITE, month_str)
.unwrap();
// //
// Draw the year // Draw the year
@@ -199,7 +197,7 @@ impl Calendar {
} }
} }
impl<'a> Widget for Calendar { impl Widget for Calendar {
fn get_dirty(&self) -> bool { fn get_dirty(&self) -> bool {
self.dirty self.dirty
} }
@@ -209,11 +207,8 @@ impl<'a> Widget for Calendar {
} }
fn event(&mut self, event: &Event) { fn event(&mut self, event: &Event) {
match event { if let Event::NewMinute = event {
Event::NewMinute => { self.dirty = true;
self.dirty = true;
}
_ => (),
} }
} }
@@ -299,9 +294,8 @@ impl<'a> Widget for Calendar {
self.geometry = Geometry { self.geometry = Geometry {
x: geometry.x, x: geometry.x,
y: geometry.y, y: geometry.y,
width: cal_width * self.shown_sections_x as u32 width: cal_width * self.shown_sections_x + cal_pad * (self.shown_sections_x - 1),
+ cal_pad * (self.shown_sections_x as u32 - 1), height: cal_height * self.shown_sections_y,
height: cal_height * self.shown_sections_y as u32,
}; };
self.geometry self.geometry
} }
@@ -323,7 +317,7 @@ impl<'a> Widget for Calendar {
y: 0, y: 0,
width: 0, width: 0,
height: 0, height: 0,
} };
} }
x => x, x => x,
}; };
@@ -335,7 +329,7 @@ impl<'a> Widget for Calendar {
y: 0, y: 0,
width: 0, width: 0,
height: 0, height: 0,
} };
} }
y => y, y => y,
}; };
@@ -344,8 +338,8 @@ impl<'a> Widget for Calendar {
Geometry { Geometry {
x: 0, x: 0,
y: 0, y: 0,
width: width, width,
height: height, height,
} }
} }
} }
+4 -7
View File
@@ -19,7 +19,7 @@ pub struct Clock {
colon_width: u32, colon_width: u32,
} }
impl<'a> Clock { impl Clock {
pub fn new(fm: &mut FontMap, font: &'static str, size: f32) -> Clock { pub fn new(fm: &mut FontMap, font: &'static str, size: f32) -> Clock {
fm.queue_font(font, size, "0123456789: "); fm.queue_font(font, size, "0123456789: ");
Clock { Clock {
@@ -33,7 +33,7 @@ impl<'a> Clock {
} }
} }
impl<'a> Widget for Clock { impl Widget for Clock {
fn get_dirty(&self) -> bool { fn get_dirty(&self) -> bool {
self.dirty self.dirty
} }
@@ -43,11 +43,8 @@ impl<'a> Widget for Clock {
} }
fn event(&mut self, event: &Event) { fn event(&mut self, event: &Event) {
match event { if let Event::NewMinute = event {
Event::NewMinute => { self.dirty = true;
self.dirty = true;
}
_ => (),
} }
} }
+3 -6
View File
@@ -29,7 +29,7 @@ impl Date {
} }
} }
impl<'a> Widget for Date { impl Widget for Date {
fn get_dirty(&self) -> bool { fn get_dirty(&self) -> bool {
self.dirty self.dirty
} }
@@ -39,11 +39,8 @@ impl<'a> Widget for Date {
} }
fn event(&mut self, event: &Event) { fn event(&mut self, event: &Event) {
match event { if let Event::NewMinute = event {
Event::NewMinute => { self.dirty = true;
self.dirty = true;
}
_ => (),
} }
} }
+201 -67
View File
@@ -1,10 +1,10 @@
use std::{ use std::{
cmp::{max, min, Ordering}, cmp::{Ordering, max, min},
collections::HashMap, collections::HashMap,
default::Default, default::Default,
fs::{read_to_string, File}, fs::{File, read_to_string},
io::Write, io::Write,
process::{exit, Command}, process::{Command, exit},
sync::{Arc, Mutex}, sync::{Arc, Mutex},
thread, thread,
}; };
@@ -14,18 +14,18 @@ use crate::{
color::Color, color::Color,
event::{Event, Events, PointerButton, PointerEvent}, event::{Event, Events, PointerButton, PointerEvent},
fonts::FontMap, fonts::FontMap,
keyboard::{keysyms, KeyEvent}, keyboard::{KeyEvent, keysyms},
utils::{ utils::{
desktop::{load_desktop_cache, load_desktop_files, write_desktop_cache, Desktop}, desktop::{Desktop, load_desktop_files},
xdg, xdg,
}, },
widgets::{Geometry, Widget}, widgets::{Geometry, Widget},
}; };
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher; use fuzzy_matcher::FuzzyMatcher;
use fuzzy_matcher::skim::SkimMatcherV2;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use wayland_client::{protocol::wl_keyboard, WEnum}; use wayland_client::{WEnum, protocol::wl_keyboard};
struct Matcher { struct Matcher {
matches: HashMap<Desktop, i64>, matches: HashMap<Desktop, i64>,
@@ -96,13 +96,19 @@ impl Prompt {
} }
} }
/// Length of the input in graphemes. The cursor is a grapheme index, not
/// a byte offset, so this (and never `input.len()`) is the upper bound.
fn len(&self) -> usize {
self.input.graphemes(true).count()
}
fn move_cursor(&mut self, distance: isize) { fn move_cursor(&mut self, distance: isize) {
let new_cursor = self.cursor as isize + distance; let new_cursor = self.cursor as isize + distance;
let len = self.len() as isize;
if new_cursor < 0 { if new_cursor < 0 {
self.cursor = 0; self.cursor = 0;
return; } else if new_cursor > len {
} else if new_cursor > self.input.len() as isize { self.cursor = len as usize;
self.cursor = self.input.len()
} else { } else {
self.cursor = new_cursor as usize; self.cursor = new_cursor as usize;
} }
@@ -110,11 +116,11 @@ impl Prompt {
fn set(&mut self, v: &str) { fn set(&mut self, v: &str) {
self.input = v.to_string(); self.input = v.to_string();
self.cursor = self.input.len(); self.cursor = self.len();
} }
fn append(&mut self, v: &str) { fn append(&mut self, v: &str) {
if self.input.len() == 0 { if self.input.is_empty() {
match v { match v {
"!" => { "!" => {
self.mode = PromptMode::Shell; self.mode = PromptMode::Shell;
@@ -128,13 +134,14 @@ impl Prompt {
} }
} }
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
if self.cursor == indices.len() { if self.cursor >= indices.len() {
self.input += &v; self.cursor = indices.len();
self.input += v;
} else { } else {
let index_at = indices[self.cursor].0; let index_at = indices[self.cursor].0;
self.input.insert_str(index_at, &v); self.input.insert_str(index_at, v);
} }
self.cursor += 1; self.cursor += v.graphemes(true).count();
} }
fn backspace(&mut self) { fn backspace(&mut self) {
@@ -168,6 +175,7 @@ impl Prompt {
fn clear_right(&mut self) { fn clear_right(&mut self) {
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
if !indices.is_empty() { if !indices.is_empty() {
self.cursor = min(self.cursor, indices.len());
self.input = indices[0..self.cursor] self.input = indices[0..self.cursor]
.iter() .iter()
.fold("".into(), |acc, el| acc + el.1); .fold("".into(), |acc, el| acc + el.1);
@@ -179,6 +187,7 @@ impl Prompt {
fn clear_left(&mut self) { fn clear_left(&mut self) {
let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect(); let indices: Vec<(usize, &str)> = self.input.grapheme_indices(true).collect();
if !indices.is_empty() { if !indices.is_empty() {
self.cursor = min(self.cursor, indices.len());
self.input = indices[self.cursor..indices.len()] self.input = indices[self.cursor..indices.len()]
.iter() .iter()
.fold("".into(), |acc, el| acc + el.1); .fold("".into(), |acc, el| acc + el.1);
@@ -193,11 +202,55 @@ impl Prompt {
} }
fn end(&mut self) { fn end(&mut self) {
self.cursor = self.input.len(); self.cursor = self.len();
} }
} }
/// Strip desktop-entry field codes (%f, %u, %i, %c, ...) from an Exec line and
/// unescape `%%`. We never pass files/URLs, so the codes expand to nothing.
fn strip_field_codes(exec: &str) -> String {
let mut out = String::with_capacity(exec.len());
let mut chars = exec.chars().peekable();
while let Some(c) = chars.next() {
if c != '%' {
out.push(c);
continue;
}
match chars.next() {
Some('%') => out.push('%'),
Some(_) => (),
None => (),
}
}
out
}
/// Path of a small persistent state file (calc history, saved prompt)
/// under $XDG_STATE_HOME/wldash.
fn state_path(name: &str) -> String {
format!("{}/wldash/{}", xdg::state_folder(), name)
}
/// Read a state file, falling back to where older versions kept it
/// ($XDG_CACHE_HOME/wldash) so nothing is lost on upgrade.
fn read_state(name: &str) -> Option<String> {
read_to_string(state_path(name))
.or_else(|_| read_to_string(format!("{}/wldash/{}", xdg::cache_folder(), name)))
.ok()
}
/// Create (truncate) a state file, making the directory on demand.
fn create_state(name: &str) -> Option<File> {
let path = state_path(name);
if let Some(dir) = std::path::Path::new(&path).parent() {
let _ = std::fs::create_dir_all(dir);
}
File::create(path).ok()
}
trait InterfaceWidget { trait InterfaceWidget {
/// Number of selectable rows (used to clamp the selection).
fn rows(&self) -> usize;
fn trigger(&mut self, intf: &mut InnerInterface); fn trigger(&mut self, intf: &mut InnerInterface);
fn draw( fn draw(
&mut self, &mut self,
@@ -223,16 +276,8 @@ impl Launcher {
thread::Builder::new() thread::Builder::new()
.name("desktopini".to_string()) .name("desktopini".to_string())
.spawn(move || { .spawn(move || {
let mut options = options.lock().unwrap(); let loaded = load_desktop_files();
*options = match load_desktop_cache() { *options.lock().unwrap() = loaded;
Ok(v) => v,
Err(_) => {
let v = load_desktop_files();
write_desktop_cache(&v).unwrap();
v
}
};
drop(options);
let mut events = events.lock().unwrap(); let mut events = events.lock().unwrap();
events.add_event(Event::LauncherUpdate); events.add_event(Event::LauncherUpdate);
}) })
@@ -241,7 +286,7 @@ impl Launcher {
Launcher { Launcher {
next_token: None, next_token: None,
options: options, options,
matches: Vec::new(), matches: Vec::new(),
launch_cmd, launch_cmd,
} }
@@ -258,24 +303,37 @@ impl Launcher {
} else { } else {
cmd.arg(cmdline); cmd.arg(cmdline);
} }
cmd.spawn().unwrap(); match cmd.spawn() {
exit(0); Ok(_) => exit(0),
Err(e) => {
eprintln!("wldash: failed to launch {:?}: {}", cmdline, e);
exit(1);
}
}
} }
} }
impl InterfaceWidget for Launcher { impl InterfaceWidget for Launcher {
fn rows(&self) -> usize {
self.matches.len()
}
fn trigger(&mut self, intf: &mut InnerInterface) { fn trigger(&mut self, intf: &mut InnerInterface) {
if self.matches.len() > intf.selection { if self.matches.len() > intf.selection {
let d = &self.matches[intf.selection]; let d = &self.matches[intf.selection];
if let Some(exec) = &d.exec { let cmd = match (&d.exec, &d.url) {
let exec = exec (Some(exec), _) => strip_field_codes(exec),
.replace("%f", "") // Type=Link entries have no Exec; open the URL instead.
.replace("%F", "") (None, Some(url)) => format!("xdg-open '{}'", url.replace('\'', "'\\''")),
.replace("%u", "") (None, None) => return,
.replace("%U", ""); };
// Terminal=true: run inside the user's terminal when one is
self.exec(&exec); // configured, otherwise as before.
} let cmd = match (d.term, std::env::var("TERMINAL")) {
(true, Ok(term)) if !term.is_empty() => format!("{} -e {}", term, cmd),
_ => cmd,
};
self.exec(&cmd);
} }
} }
@@ -321,7 +379,7 @@ impl InterfaceWidget for Launcher {
.auto_draw_text_with_cursor( .auto_draw_text_with_cursor(
&mut prompt_line, &mut prompt_line,
fg, fg,
&format!(" {}", &intf.prompt.input), &format!(" {}", intf.prompt.input),
intf.prompt.cursor + 3, intf.prompt.cursor + 3,
) )
.unwrap(); .unwrap();
@@ -345,8 +403,9 @@ impl InterfaceWidget for Launcher {
.fuzzy_indices(&m.name.to_lowercase(), &intf.prompt.input.to_lowercase()) .fuzzy_indices(&m.name.to_lowercase(), &intf.prompt.input.to_lowercase())
.unwrap_or((0, vec![])); .unwrap_or((0, vec![]));
let mut colors = Vec::with_capacity(m.name.len()); let nchars = m.name.chars().count();
for pos in 0..m.name.len() { let mut colors = Vec::with_capacity(nchars);
for pos in 0..nchars {
if indices.contains(&pos) { if indices.contains(&pos) {
colors.push(Color::LIGHTORANGE); colors.push(Color::LIGHTORANGE);
} else { } else {
@@ -394,12 +453,21 @@ impl Shell {
if let Some(token) = &self.next_token { if let Some(token) = &self.next_token {
cmd.env("XDG_ACTIVATION_TOKEN", token); cmd.env("XDG_ACTIVATION_TOKEN", token);
} }
cmd.args(&args[1..]).spawn().unwrap(); match cmd.args(&args[1..]).spawn() {
exit(0); Ok(_) => exit(0),
Err(e) => {
eprintln!("wldash: failed to run {:?}: {}", args, e);
exit(1);
}
}
} }
} }
impl InterfaceWidget for Shell { impl InterfaceWidget for Shell {
fn rows(&self) -> usize {
1
}
fn trigger(&mut self, intf: &mut InnerInterface) { fn trigger(&mut self, intf: &mut InnerInterface) {
let args = vec![ let args = vec![
"/bin/sh".to_string(), "/bin/sh".to_string(),
@@ -431,7 +499,7 @@ impl InterfaceWidget for Shell {
.auto_draw_text_with_cursor( .auto_draw_text_with_cursor(
&mut prompt_line, &mut prompt_line,
fg, fg,
&format!(" {}", &intf.prompt.input), &format!(" {}", intf.prompt.input),
intf.prompt.cursor + 3, intf.prompt.cursor + 3,
) )
.unwrap(); .unwrap();
@@ -459,9 +527,9 @@ impl Calc {
thread::Builder::new() thread::Builder::new()
.name("calc".to_string()) .name("calc".to_string())
.spawn(move || { .spawn(move || {
let s = match read_to_string(format!("{}/wldash/calc", xdg::cache_folder())) { let s = match read_state("calc") {
Ok(s) => s, Some(s) => s,
_ => return, None => return,
}; };
let mut old = old_local.lock().unwrap(); let mut old = old_local.lock().unwrap();
@@ -481,20 +549,25 @@ impl Calc {
} }
fn sync(&self) { fn sync(&self) {
let mut f = match File::create(format!("{}/wldash/calc", xdg::cache_folder())) { let mut f = match create_state("calc") {
Ok(f) => f, Some(f) => f,
_ => return, None => return,
}; };
let old = self.old.lock().unwrap(); let old = self.old.lock().unwrap();
for (input, res) in old.iter() { for (input, res) in old.iter() {
write!(f, "{}={}\n", input, res).unwrap(); writeln!(f, "{}={}", input, res).unwrap();
} }
f.sync_data().unwrap(); f.sync_data().unwrap();
} }
} }
impl InterfaceWidget for Calc { impl InterfaceWidget for Calc {
fn rows(&self) -> usize {
// The live result plus the history entries.
self.old.lock().unwrap().len() + 1
}
fn trigger(&mut self, intf: &mut InnerInterface) { fn trigger(&mut self, intf: &mut InnerInterface) {
let mut old = self.old.lock().unwrap(); let mut old = self.old.lock().unwrap();
if intf.selection == 0 { if intf.selection == 0 {
@@ -519,10 +592,7 @@ impl InterfaceWidget for Calc {
rcalc_lib::parse::eval(&intf.prompt.input, &mut rcalc_lib::parse::CalcState::new()) rcalc_lib::parse::eval(&intf.prompt.input, &mut rcalc_lib::parse::CalcState::new())
.map(|x| format!("{}", x)) .map(|x| format!("{}", x))
.map_err(|x| format!("{}", x)); .map_err(|x| format!("{}", x));
self.result = match res { self.result = res.ok();
Ok(v) => Some(v),
Err(_) => None,
};
} }
fn draw( fn draw(
@@ -545,7 +615,7 @@ impl InterfaceWidget for Calc {
font.auto_draw_text_with_cursor( font.auto_draw_text_with_cursor(
&mut prompt_line, &mut prompt_line,
fg, fg,
&format!(" {} ", &intf.prompt.input), &format!(" {} ", intf.prompt.input),
intf.prompt.cursor + 3, intf.prompt.cursor + 3,
) )
.unwrap(); .unwrap();
@@ -641,7 +711,10 @@ impl Interface {
fn pointer_input(&mut self, event: &PointerEvent) { fn pointer_input(&mut self, event: &PointerEvent) {
if let PointerButton::Left = event.button { if let PointerButton::Left = event.button {
let line_height = self.inner.size.ceil() as u32; let line_height = self.inner.size.ceil() as u32;
let height = self.inner.geometry.height - line_height - 8; if line_height == 0 {
return;
}
let height = self.inner.geometry.height.saturating_sub(line_height + 8);
let lines = height / line_height; let lines = height / line_height;
let offset = height % line_height; let offset = height % line_height;
@@ -650,19 +723,24 @@ impl Interface {
} else { } else {
return; return;
}; };
self.inner.selection = (lines - pos / line_height - 1) as usize; let row = pos / line_height;
if row >= lines {
// Click landed on the prompt line or below.
return;
}
self.inner.selection = (lines - row - 1) as usize;
self.inner.dirty = true; self.inner.dirty = true;
} }
} }
fn exit(&mut self) { fn exit(&mut self) {
if self.inner.prompt.input.len() == 0 { if self.inner.prompt.input.is_empty() {
std::process::exit(0); std::process::exit(0);
} }
if let Ok(mut f) = File::create(format!("{}/wldash/prompt", xdg::cache_folder())) { if let Some(mut f) = create_state("prompt") {
write!(f, "{}", self.inner.prompt.input).unwrap(); let _ = write!(f, "{}", self.inner.prompt.input);
f.sync_data().unwrap(); let _ = f.sync_data();
} }
std::process::exit(0); std::process::exit(0);
} }
@@ -691,7 +769,7 @@ impl Interface {
widget.update(&self.inner); widget.update(&self.inner);
} }
keysyms::KEY_r if event.modifiers.ctrl => { keysyms::KEY_r if event.modifiers.ctrl => {
if let Ok(s) = read_to_string(format!("{}/wldash/prompt", xdg::cache_folder())) { if let Some(s) = read_state("prompt") {
self.inner.prompt.set(&s); self.inner.prompt.set(&s);
} }
} }
@@ -712,7 +790,10 @@ impl Interface {
keysyms::KEY_Left => self.inner.prompt.move_cursor(-1), keysyms::KEY_Left => self.inner.prompt.move_cursor(-1),
keysyms::KEY_Right => self.inner.prompt.move_cursor(1), keysyms::KEY_Right => self.inner.prompt.move_cursor(1),
keysyms::KEY_Up => { keysyms::KEY_Up => {
self.inner.selection += 1; let max = widget.rows().saturating_sub(1);
if self.inner.selection < max {
self.inner.selection += 1;
}
} }
keysyms::KEY_Down => { keysyms::KEY_Down => {
if self.inner.selection > 0 { if self.inner.selection > 0 {
@@ -721,7 +802,7 @@ impl Interface {
} }
_ => { _ => {
if let Some(utf8) = &event.utf8 { if let Some(utf8) = &event.utf8 {
self.inner.prompt.append(&utf8); self.inner.prompt.append(utf8);
self.inner.selection = 0; self.inner.selection = 0;
widget.update(&self.inner); widget.update(&self.inner);
} else { } else {
@@ -751,7 +832,7 @@ impl Widget for Interface {
} }
fn geometry_update(&mut self, _fonts: &mut FontMap, geometry: &Geometry) -> Geometry { fn geometry_update(&mut self, _fonts: &mut FontMap, geometry: &Geometry) -> Geometry {
self.inner.geometry = geometry.clone(); self.inner.geometry = *geometry;
self.inner.geometry self.inner.geometry
} }
@@ -781,3 +862,56 @@ impl Widget for Interface {
} }
} }
} }
#[cfg(test)]
mod tests {
use super::{Prompt, PromptMode, strip_field_codes};
#[test]
fn prompt_cursor_is_grapheme_based() {
let mut p = Prompt::new();
p.append("ø");
p.append("å");
assert_eq!(p.cursor, 2);
p.end();
assert_eq!(p.cursor, 2);
// Used to index past the grapheme table and panic.
p.append("a");
assert_eq!(p.input, "øåa");
p.move_cursor(-10);
assert_eq!(p.cursor, 0);
p.move_cursor(100);
assert_eq!(p.cursor, 3);
p.backspace();
assert_eq!(p.input, "øå");
p.home();
p.delete();
assert_eq!(p.input, "å");
p.set("blå");
assert_eq!(p.cursor, 3);
p.move_cursor(-1);
p.clear_right();
assert_eq!(p.input, "bl");
p.clear_left();
assert_eq!(p.input, "");
assert_eq!(p.cursor, 0);
}
#[test]
fn prompt_prefix_switches_mode() {
let mut p = Prompt::new();
p.append("=");
assert!(matches!(p.mode, PromptMode::Calc));
assert_eq!(p.input, "");
p.backspace();
assert!(matches!(p.mode, PromptMode::Normal));
}
#[test]
fn field_codes_are_stripped() {
assert_eq!(strip_field_codes("firefox %u"), "firefox ");
assert_eq!(strip_field_codes("app --icon %i %c %k"), "app --icon ");
assert_eq!(strip_field_codes("echo 100%% %F"), "echo 100% ");
assert_eq!(strip_field_codes("plain"), "plain");
}
}
+10 -7
View File
@@ -1,3 +1,6 @@
// Constructors here deliberately return Box<dyn Layout> so configs compose.
#![allow(clippy::new_ret_no_self)]
use std::cmp::max; use std::cmp::max;
use crate::{fonts::FontMap, state::State, widgets::Geometry}; use crate::{fonts::FontMap, state::State, widgets::Geometry};
@@ -30,7 +33,7 @@ impl Layout for HorizontalLayout {
geometry: &Geometry, geometry: &Geometry,
state: &mut State, state: &mut State,
) -> Geometry { ) -> Geometry {
let mut geo = geometry.clone(); let mut geo = *geometry;
let mut max_width = 0; let mut max_width = 0;
let mut max_height = 0; let mut max_height = 0;
for w in self.widgets.iter() { for w in self.widgets.iter() {
@@ -79,7 +82,7 @@ impl Layout for VerticalLayout {
geometry: &Geometry, geometry: &Geometry,
state: &mut State, state: &mut State,
) -> Geometry { ) -> Geometry {
let mut geo = geometry.clone(); let mut geo = *geometry;
let mut max_width = 0; let mut max_width = 0;
let mut max_height = 0; let mut max_height = 0;
for w in self.widgets.iter() { for w in self.widgets.iter() {
@@ -113,7 +116,7 @@ impl Layout for VerticalLayout {
pub trait WidgetUpdater { pub trait WidgetUpdater {
fn geometry_update(&mut self, idx: usize, fonts: &mut FontMap, geometry: &Geometry) fn geometry_update(&mut self, idx: usize, fonts: &mut FontMap, geometry: &Geometry)
-> Geometry; -> Geometry;
fn minimum_size(&mut self, idx: usize, fonts: &mut FontMap) -> Geometry; fn minimum_size(&mut self, idx: usize, fonts: &mut FontMap) -> Geometry;
} }
@@ -159,10 +162,10 @@ impl Layout for InvertedHorizontalLayout {
geometry: &Geometry, geometry: &Geometry,
state: &mut State, state: &mut State,
) -> Geometry { ) -> Geometry {
let mut geo = geometry.clone(); let mut geo = *geometry;
let mut max_height = 0; let mut max_height = 0;
for w in self.widgets.iter() { for w in self.widgets.iter() {
let mut temp_geo = geo.clone(); let mut temp_geo = geo;
let temp_result = w.geometry_update(fonts, &temp_geo, state); let temp_result = w.geometry_update(fonts, &temp_geo, state);
temp_geo.x = geo.x + (geo.width - temp_result.width); temp_geo.x = geo.x + (geo.width - temp_result.width);
let result = w.geometry_update(fonts, &temp_geo, state); let result = w.geometry_update(fonts, &temp_geo, state);
@@ -207,10 +210,10 @@ impl Layout for InvertedVerticalLayout {
geometry: &Geometry, geometry: &Geometry,
state: &mut State, state: &mut State,
) -> Geometry { ) -> Geometry {
let mut geo = geometry.clone(); let mut geo = *geometry;
let mut max_width = 0; let mut max_width = 0;
for w in self.widgets.iter() { for w in self.widgets.iter() {
let mut temp_geo = geo.clone(); let mut temp_geo = geo;
let temp_result = w.geometry_update(fonts, &temp_geo, state); let temp_result = w.geometry_update(fonts, &temp_geo, state);
temp_geo.y = geo.y + (geo.height - temp_result.height); temp_geo.y = geo.y + (geo.height - temp_result.height);
let result = w.geometry_update(fonts, &temp_geo, state); let result = w.geometry_update(fonts, &temp_geo, state);
+32 -19
View File
@@ -26,13 +26,13 @@ fn monitor(
use libpulse_binding::{ use libpulse_binding::{
callbacks::ListResult, callbacks::ListResult,
context::{ context::{
subscribe::{Facility, InterestMaskSet},
Context, FlagSet, State, Context, FlagSet, State,
subscribe::{Facility, InterestMaskSet},
}, },
mainloop::{ mainloop::{
self, api::Mainloop, events::io::FlagSet as IoFlagSet, standard::IterateResult, self, api::Mainloop, events::io::FlagSet as IoFlagSet, standard::IterateResult,
}, },
proplist::{properties, Proplist}, proplist::{Proplist, properties},
volume::Volume, volume::Volume,
}; };
@@ -48,10 +48,17 @@ fn monitor(
context.borrow_mut().connect(None, FlagSet::NOFLAGS, None)?; context.borrow_mut().connect(None, FlagSet::NOFLAGS, None)?;
loop { loop {
mainloop.iterate(false); match mainloop.iterate(true) {
IterateResult::Quit(_) | IterateResult::Err(_) => {
return Err("pulseaudio mainloop quit while connecting".into());
}
_ => (),
}
match context.borrow().get_state() { match context.borrow().get_state() {
State::Ready => break, State::Ready => break,
State::Failed | State::Terminated => panic!("damn"), State::Failed | State::Terminated => {
return Err("could not connect to pulseaudio".into());
}
_ => (), _ => (),
} }
} }
@@ -60,15 +67,15 @@ fn monitor(
let local_inner = inner.clone(); let local_inner = inner.clone();
let local_events = events.clone(); let local_events = events.clone();
context.borrow_mut().set_subscribe_callback(Some(Box::new( context.borrow_mut().set_subscribe_callback(Some(Box::new(
move |facility, _operation, index| match facility { move |facility, _operation, index| {
Some(Facility::Sink) => { if let Some(Facility::Sink) = facility {
let introspector = local_context.borrow_mut().introspect(); let introspector = local_context.borrow_mut().introspect();
let inner = local_inner.clone(); let inner = local_inner.clone();
let events = local_events.clone(); let events = local_events.clone();
introspector.get_sink_info_by_index(index, move |res| match res { introspector.get_sink_info_by_index(index, move |res| match res {
ListResult::End | ListResult::Error => (), ListResult::End | ListResult::Error => (),
ListResult::Item(sink_info) => { ListResult::Item(sink_info) => {
if let Some(_) = &sink_info.name { if sink_info.name.is_some() {
let avg = sink_info.volume.avg().0 as f32 / Volume::NORMAL.0 as f32; let avg = sink_info.volume.avg().0 as f32 / Volume::NORMAL.0 as f32;
let mut inner = inner.lock().unwrap(); let mut inner = inner.lock().unwrap();
inner.volume = avg; inner.volume = avg;
@@ -81,7 +88,6 @@ fn monitor(
} }
}); });
} }
_ => (),
}, },
))); )));
@@ -94,7 +100,7 @@ fn monitor(
introspector.get_sink_info_by_name("@DEFAULT_SINK@", move |res| match res { introspector.get_sink_info_by_name("@DEFAULT_SINK@", move |res| match res {
ListResult::End | ListResult::Error => (), ListResult::End | ListResult::Error => (),
ListResult::Item(sink_info) => { ListResult::Item(sink_info) => {
if let Some(_) = &sink_info.name { if sink_info.name.is_some() {
let avg = sink_info.volume.avg().0 as f32 / Volume::NORMAL.0 as f32; let avg = sink_info.volume.avg().0 as f32 / Volume::NORMAL.0 as f32;
let mut inner = local_inner.lock().unwrap(); let mut inner = local_inner.lock().unwrap();
inner.volume = avg; inner.volume = avg;
@@ -112,7 +118,7 @@ fn monitor(
let local_context = context.clone(); let local_context = context.clone();
let local_inner = inner.clone(); let local_inner = inner.clone();
let event_source = mainloop.new_io_event( let event_source = mainloop.new_io_event(
pipe as i32, pipe,
IoFlagSet::INPUT, IoFlagSet::INPUT,
Box::new(move |_, _, _| { Box::new(move |_, _, _| {
let mut buf = [0; 8]; let mut buf = [0; 8];
@@ -120,15 +126,14 @@ fn monitor(
let mut inner = local_inner.lock().unwrap(); let mut inner = local_inner.lock().unwrap();
let mut introspector = local_context.borrow_mut().introspect(); let mut introspector = local_context.borrow_mut().introspect();
match inner.last_click.take() { // Clicks arriving before the first sink query finished have no
Some(Change::Volume(volume)) => { // channel map to work with; drop them instead of panicking.
// do something match (inner.last_click.take(), inner.channel_volume) {
let mut vol = inner.channel_volume.unwrap(); (Some(Change::Volume(volume)), Some(mut vol)) => {
vol.scale(Volume((volume * Volume::NORMAL.0 as f32).round() as u32)); vol.scale(Volume((volume * Volume::NORMAL.0 as f32).round() as u32));
introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None); introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None);
} }
Some(Change::VolumeInc(value)) => { (Some(Change::VolumeInc(value)), Some(mut vol)) => {
let mut vol = inner.channel_volume.unwrap();
// apply step to volumes // apply step to volumes
let step = (value * Volume::NORMAL.0 as f32).round() as i32; let step = (value * Volume::NORMAL.0 as f32).round() as i32;
if step > 0 { if step > 0 {
@@ -141,10 +146,10 @@ fn monitor(
inner.channel_volume = Some(vol); inner.channel_volume = Some(vol);
introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None); introspector.set_sink_volume_by_name("@DEFAULT_SINK@", &vol, None);
} }
Some(Change::ToggleMute) => { (Some(Change::ToggleMute), _) => {
introspector.set_sink_mute_by_name("@DEFAULT_SINK@", !inner.mute, None); introspector.set_sink_mute_by_name("@DEFAULT_SINK@", !inner.mute, None);
} }
None => (), _ => (),
} }
}), }),
); );
@@ -165,7 +170,13 @@ fn monitor(
fn start_monitor(events: Arc<Mutex<Events>>, inner: Arc<Mutex<InnerAudio>>, pipe: RawFd) { fn start_monitor(events: Arc<Mutex<Events>>, inner: Arc<Mutex<InnerAudio>>, pipe: RawFd) {
thread::Builder::new() thread::Builder::new()
.name("audiomon".to_string()) .name("audiomon".to_string())
.spawn(move || monitor(events, inner, pipe).unwrap()) .spawn(move || {
// panic = "abort" in release: a failed audio connection must not
// kill the dashboard. The widget simply stays at 0.
if let Err(e) = monitor(events, inner, pipe) {
eprintln!("wldash: audio monitor stopped: {}", e);
}
})
.unwrap(); .unwrap();
} }
@@ -189,6 +200,8 @@ pub struct PulseAudio {
} }
impl PulseAudio { impl PulseAudio {
// Wraps the impl in the generic BarWidget on purpose.
#[allow(clippy::new_ret_no_self)]
pub fn new( pub fn new(
events: Arc<Mutex<Events>>, events: Arc<Mutex<Events>>,
fm: &mut FontMap, fm: &mut FontMap,
+31 -1
View File
@@ -40,7 +40,7 @@ impl Geometry {
x, x,
y, y,
width: max(self.width + self.x, other.width + other.x) - x, width: max(self.width + self.x, other.width + other.x) - x,
height: max(self.height + self.y, other.height + other.height) - y, height: max(self.height + self.y, other.height + other.y) - y,
} }
} }
@@ -64,3 +64,33 @@ pub trait Widget {
fn draw(&mut self, fonts: &mut FontMap, view: &mut BufferView) -> Geometry; fn draw(&mut self, fonts: &mut FontMap, view: &mut BufferView) -> Geometry;
fn event(&mut self, _event: &Event) {} fn event(&mut self, _event: &Event) {}
} }
#[cfg(test)]
mod tests {
use super::Geometry;
#[test]
fn expand_is_bounding_box() {
let a = Geometry {
x: 10,
y: 20,
width: 5,
height: 5,
};
let b = Geometry {
x: 0,
y: 100,
width: 2,
height: 2,
};
let e = a.expand(b);
assert_eq!((e.x, e.y), (0, 20));
assert_eq!(e.x + e.width, 15);
assert_eq!(e.y + e.height, 102);
let e2 = b.expand(a);
assert_eq!(
(e2.x, e2.y, e2.width, e2.height),
(e.x, e.y, e.width, e.height)
);
}
}