Compare commits

..

14 Commits

Author SHA1 Message Date
jory 7603975062 refactor: changed effects, add shell completions
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Extract all apply_* effect methods from screenshot.rs into dedicated
  src/effects.rs (blur, vignette, pixelate, swirl) — screenshot.rs
- Rewrite apply_blur: replace broken fastblur::gaussian_blur (horizontal-
  only box blur, backbuf dropped) with correct inline two-pass sliding-
  window box blur operating directly on Cairo surface bytes — ~10×
  faster, no ImageBuffer round-trip, no fastblur dependency
- Remove melting effect entirely (seems to be hard to implement
  properely, tried multiple itterations)
- Drop unused dependencies: fastblur, rand
- Add clap_complete dependency + --completions {bash,fish,zsh} flag,
  with CompletionShell enum in config.rs
- Fix show_* flags defaulting to true: remove default_value_t=true from
  show_media, show_battery, show_network, show_bluetooth, show_album_art,
  show_caps_lock_text — all now opt-in (matching user expectation)
- Convert peek toggle to hold: toggle_peek() → set_peek_held(held: bool),
  Press calls set_peek_held(true), new Release handler iterates all
  surfaces and calls set_peek_held(false), matching Ctrl-hold behavior
- Fix cursor rendering: increase font size 11→14 for peeked chars,
  handle cursor_position == 0 (prevent negative t wrapping to far ring
  end)
- Fix peek hit-test: use shape-aware point_in_shape() instead of simple
  circle pill/square/diamond/hexagon now detect clicks correctly within
  their area
- Remove per-frame DEBUG logs: PEEK/DOT mode in lock.rs,
  set_password_display/peek_password in render/mod.rs
- Update README: embed rustlock-effects.webp demo, remove melting
  references, correct show_* defaults and options table
2026-06-28 18:17:53 +02:00
jory 833706f5a5 feat: peek-to-reveal on click/ctrl, pre-commit hooks, devShell, fmt fixes
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Click indicator ring to toggle password peek (persistent click-to-toggle)
- Ctrl-hold transient peek refactored to share peek path
- Cursor always visible (previously hidden at position 0, now shows at top of ring)
- .pre-commit-config.yaml: standard hooks + cargo fmt/clippy
- flake.nix: devShell with cargo, rustc, rustfmt, clippy, cargo-audit, prek
- cargo fmt pass across all source files (trailing whitespace, line wrapping)
- MPRIS error logging (silent failures now logged)
- Dependabot versioning-strategy: "increase" → "auto"
2026-06-21 20:44:59 +02:00
jory c2c093595a feat: ring shapes, auth reuse, input tests, README docs, CI improvements
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Add --ring-shape (circle/square/diamond/hexagon/pill) with geometry module
- Reuse PAM context across auth attempts (no reload per-keystroke)
- Proper error propagation in screenshot effects (unwrap → Result + context)
- Remove theme presets (modern/pixel/glass) and --theme flag
- Add input cooldown after failed attempt (400ms debounce)
- Add Ctrl+held peek password, Home/End/Delete cursor keys
- Add all input unit tests (23 new tests)
- Add --max-dots, feedback duration, timeout, interval config options
- Add --log-path, --auth-timeout, verifying_color config fields
- Remove unused deps: futures, gio, env_logger, num-traits, thiserror, bytemuck
- Fix media bar: remove stop button, fix art+text overlap, uniform hit areas
- Fix CI: source-only releases, nightly prereleases, dependabot groups, labeler paths
- Update issue templates with structured forms
- Add stale workflow for inactive issues/PRs
- Update README: full options table (28 flags), remove theme docs, add shapes
2026-06-21 17:28:58 +02:00
jory db3e797e36 Merge pull request #7 from josephdunn/perf/lock-idle-redraw-dirty-tracking
perf: only redraw the lock screen when something changes
2026-06-20 14:54:35 +02:00
jory c4ae3e9651 Merge pull request #6 from josephdunn/fix/lock-surface-cleanup-order
fix: clean lock-surface teardown + roundtrip on unlock (Hyprland multi-output)
2026-06-20 14:54:22 +02:00
jory 4372c7f7c0 ci: audit and harden GitHub Actions workflows
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Bump all actions to latest versions (checkout@v6, upload-artifact@v7,
  download-artifact@v8, labeler@v6, gh-release@v3, gpg-import@v7)
- Replace fragile curl|bash tool install with taiki-e/install-action@v2
- Fix Swatinem/rust-cache ordering: must run AFTER toolchain install
  for correct cache key derivation
- Add --all-targets to clippy (CI now matches local testing)
- Add RUSTDOCFLAGS=-D warnings to doc step (fail on broken links)
- Fix ARM64 build order: install Rust toolchain before adding target
  and configuring linker
- Add rust-toolchain.toml pinning Rust 1.94.0 for deterministic builds
- Update Cargo.lock: rustls-webpki v0.103.11->v0.103.13 (fixes 3 CVEs)
- Fix softprops/action-gh-release@v3: include checksums in files list
  (v3 removed the checksum input parameter)
- Fix clippy manual_checked_ops lint in screenshot.rs
- Clean up trailing whitespace and missing newlines in YAML files
2026-06-20 12:57:48 +02:00
Joseph Dunn e9550f71f5 perf: only redraw the lock screen when something changes
The lock screen re-rendered and re-committed every output's full
screen-sized cairo surface on every 16ms timer tick regardless of
whether anything had changed, burning 60%+ of a core the entire time
the session was locked.

Track a dirty flag per surface and render only when state actually
changes: a keystroke, a system-status change, the clock minute rolling
over, or an in-flight animation. update() now reports whether it
redrew, so the timer commits only the surfaces that changed.

Also fix the fade-in never formally completing. The eased alpha
approaches 1.0 asymptotically while the 0.001 step throttle suppresses
the final sub-threshold increments, leaving fade_alpha stuck just under
1.0. Since "fade_alpha < 1.0" is the "still animating" signal, that kept
it permanently true and forced a full render every frame. Snap alpha to
exactly 1.0 once the fade duration elapses so the animation completes.

Idle CPU while locked drops from 60%+ to ~2%.
2026-06-04 11:02:08 -05:00
Joseph Dunn ff20423a84 fix: clean up lock surfaces and drain compositor events on unlock
Hyprland was treating multi-output rustlock unlocks as client
crashes and showing its "lockscreen died" failsafe instead of
unlocking. Two issues:

1. Order. ext-session-lock-v1 recommends destroying every
   ext_session_lock_surface_v1 before issuing unlock_and_destroy
   on ext_session_lock_v1. rustlock did the opposite. Move
   lock_surfaces.clear() into handle_auth_result so it runs
   before session_lock.unlock(), and drop the now-redundant
   clear from the auth-feedback callback.

2. Drain. After unlock_and_destroy the compositor sends
   keyboard/pointer leave events and delete_id acks; if the
   client disconnects before processing them, Hyprland treats
   the disconnect as unclean. Add a conn.roundtrip() after the
   main loop to drain those events before exit.

Single-output setups tolerate both of these (which is why the
bug does not show up on the upstream author's laptop). The
failsafe only reproduces with multiple outputs.
2026-05-22 16:43:31 -05:00
jory 6c2e3fca5a Renamed pam module to rustlock
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
2026-04-12 11:54:41 +02:00
jory c6ec65c851 chore: add GitHub automation and fix rand vulnerability
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
GitHub Actions:
- Add Dependabot for weekly dependency updates (cargo, github-actions)
- Add PR labeler workflow with file-based auto-labeling
- Add issue/PR templates (bug report, feature request, PR template)
- Fix release.yml artifact paths and labeler.yml config syntax
Security:
- Upgrade rand 0.8 → 0.10 to fix RUSTSEC-2026-0097 unsoundness
- Update screenshot.rs for rand 0.10 API (thread_rng → rng, gen_range → random_range)
2026-04-11 18:18:41 +02:00
jory 7a0adc4d82 docs: updated README.md
Code Quality / quality-checks (push) Has been cancelled
2026-04-11 15:49:26 +02:00
jory 016038aca0 feat: full password editing with cursor navigation
- Add arrow key support (Left/Right/Home/End) for cursor movement
- Add Delete key to remove character at cursor position
- Render cursor between password dots with visual indicator
- Modularize render.rs into separate modules (indicator, media_bar, status_bar, feedback)
- Add cubic ease-in-out for fade-in animation
- Optimize media_rects to use &'static str instead of String allocations
2026-04-11 15:43:11 +02:00
jory 05cf0c1d7e Merge pull request #4 from Almamu/fix/image-scale-position
fix: scale up or down images that do not fit the screen
2026-04-11 14:11:06 +02:00
Alexis Maiquez Murcia 3a9be7c696 fix: scale up or down images that do not fit the screen 2026-04-06 05:02:07 +02:00
45 changed files with 3909 additions and 2687 deletions
+55
View File
@@ -0,0 +1,55 @@
---
name: Bug Report
about: Report a crash, visual glitch, or unexpected behavior
labels: bug
---
## Description
A clear and concise description of the bug.
## Steps to Reproduce
1.
2.
3.
## Expected Behavior
What did you expect to happen?
## Actual Behavior
What actually happened? Include any error messages, visual glitches, or crashes.
## Screenshots / Screen Recordings
If applicable, add screenshots or recordings to help explain the problem.
## Environment
- **Compositor:** (e.g., Hyprland, Sway, River, Niri)
- **Compositor version:** (e.g., Hyprland 0.47.0)
- **rustlock version:** (`rustlock --version`)
- **Display configuration:** (single monitor, multi-monitor, mixed DPI, etc.)
- **OS/Distro:** (e.g., Arch Linux, Ubuntu 24.04, Fedora 41)
## Config / CLI flags
```sh
# The exact command you used to start rustlock
# e.g., rustlock --debug --effect-blur 5x2 --theme modern
```
## Logs
```
# Paste any relevant logs here
# Run with --debug --log-file to capture verbose logs
```
## Confirmations
- [ ] I searched existing issues and this is not a duplicate
- [ ] I am running the latest version of rustlock
- [ ] I have included relevant logs and environment details
+16
View File
@@ -0,0 +1,16 @@
---
name: Feature Request
about: Suggest an idea for rustlock
labels: enhancement
---
**Is your feature request related to a problem? Please describe.**
**Describe the solution you'd like**
**Describe alternatives you've considered**
**Additional context**
+23
View File
@@ -0,0 +1,23 @@
# Pull Request Template
## Description
Please include a summary of the change and which issue is fixed (if relevant).
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactoring
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes.
## Checklist
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
+1 -1
View File
@@ -4,7 +4,7 @@ description: Install system dependencies required for building rustlock
runs: runs:
using: composite using: composite
steps: steps:
- name: Install system dependencies (Ubuntu/Debian) - name: Install system dependencies for Debian based distros
shell: bash shell: bash
run: | run: |
sudo apt-get update sudo apt-get update
+44
View File
@@ -0,0 +1,44 @@
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
open-pull-requests-limit: 10
labels:
- "dependencies"
- "rust"
commit-message:
prefix: "deps"
reviewers:
- "JorySeverijnse"
versioning-strategy: "auto"
groups:
rust-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "UTC"
labels:
- "dependencies"
- "github-actions"
groups:
actions-dependencies:
patterns:
- "*"
update-types:
- "minor"
- "patch"
+23
View File
@@ -0,0 +1,23 @@
documentation:
- changed-files:
- any-glob-to-any-file: ['README.md', '*.md', 'docs/**']
features:
- changed-files:
- any-glob-to-any-file: ['src/input.rs', 'src/lock.rs', 'src/config.rs', 'src/auth.rs', 'src/screenshot.rs', 'src/system.rs']
rendering:
- changed-files:
- any-glob-to-any-file: ['src/render/**']
ci:
- changed-files:
- any-glob-to-any-file: ['.github/**', '.github/workflows/**']
dependencies:
- changed-files:
- any-glob-to-any-file: ['Cargo.toml', 'Cargo.lock']
refactoring:
- changed-files:
- any-glob-to-any-file: ['src/util.rs', 'src/main.rs']
@@ -0,0 +1,38 @@
name: Dependabot Automerge
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: write
pull-requests: write
jobs:
automerge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v2
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-approve minor/patch updates
if: |
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch'
run: gh pr review --approve "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for minor/patch updates
if: |
steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+15
View File
@@ -0,0 +1,15 @@
name: PR Labeler
on:
pull_request:
types: [opened, synchronize]
jobs:
label:
runs-on: ubuntu-latest
steps:
- name: Label PR based on changed files
uses: actions/labeler@v6
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
+50
View File
@@ -0,0 +1,50 @@
name: Nightly Release
on:
push:
branches: [main, master]
workflow_dispatch:
env:
CARGO_TERM_COLOR: always
jobs:
nightly-build:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
- name: Set version
id: version
run: |
DATE=$(date -u +%Y%m%d%H%M)
SHA=$(git rev-parse --short HEAD)
VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d'"' -f2)
echo "VERSION=${VERSION}-nightly.$DATE.$SHA" >> $GITHUB_OUTPUT
- name: Build release binary
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: rustlock-${{ steps.version.outputs.VERSION }}
path: target/release/rustlock
- name: Create nightly release
uses: softprops/action-gh-release@v3
with:
tag_name: ${{ steps.version.outputs.VERSION }}
name: Nightly Build ${{ steps.version.outputs.VERSION }}
prerelease: true
generate_release_notes: true
+8 -5
View File
@@ -14,8 +14,7 @@ jobs:
quality-checks: quality-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2
- uses: ./.github/actions/deps - uses: ./.github/actions/deps
@@ -24,14 +23,18 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- uses: Swatinem/rust-cache@v2
- name: Check formatting - name: Check formatting
run: cargo fmt -- --check run: cargo fmt -- --check
- name: Run clippy - name: Run clippy
run: cargo clippy -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: Run unit tests - name: Run unit tests (all features)
run: cargo test --quiet run: cargo test --all-features --quiet
- name: Check documentation - name: Check documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --no-deps --document-private-items run: cargo doc --no-deps --document-private-items
+25 -89
View File
@@ -5,109 +5,45 @@ on:
tags: tags:
- 'v*' - 'v*'
env:
CARGO_TERM_COLOR: always
jobs: jobs:
release-build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
- variant: "default"
args: ""
distro: "ubuntu"
- variant: "no-networking"
args: "--no-default-features"
distro: "ubuntu"
- variant: "default"
args: ""
distro: "debian"
- variant: "default"
args: ""
distro: "fedora"
- variant: "default"
args: ""
distro: "arch"
container: ${{ matrix.distro == 'ubuntu' && 'ubuntu:latest' || matrix.distro == 'debian' && 'debian:stable-slim' || matrix.distro == 'fedora' && 'fedora:latest' || 'archlinux:latest' }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies (Ubuntu/Debian)
if: matrix.distro == 'ubuntu' || matrix.distro == 'debian'
run: |
apt-get update
apt-get install -y \
curl \
llvm clang libclang-dev \
pkg-config \
libglib2.0-dev libcairo2-dev libpango1.0-dev libatk1.0-dev \
libgdk-pixbuf-2.0-dev libpam0g-dev libdbus-1-dev \
libwayland-dev libxkbcommon-dev
- name: Install system dependencies (Fedora)
if: matrix.distro == 'fedora'
run: |
dnf install -y \
curl \
llvm clang clang-devel \
pkgconfig \
glib2-devel cairo-devel cairo-gobject-devel pango-devel atk-devel \
gdk-pixbuf2-devel pam-devel dbus-devel \
wayland-devel libxkbcommon-devel
- name: Install system dependencies (Arch)
if: matrix.distro == 'arch'
run: |
pacman -Sy --noconfirm \
base-devel \
llvm clang pkgconf \
glib2 cairo pango atk gdk-pixbuf2 \
pam dbus \
wayland libxkbcommon
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Build ${{ matrix.variant }} (${{ matrix.distro }})
run: cargo build --release ${{ matrix.args }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: rustlock-${{ matrix.variant }}-${{ matrix.distro }}
path: target/release/rustlock
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [release-build]
permissions: permissions:
contents: write contents: write
steps: steps:
- name: Checkout - uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Determine tag - name: Determine tag
id: tag id: tag
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
- name: Create Release - name: Create Release
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v3
with: with:
tag_name: ${{ github.ref_name }} tag_name: ${{ github.ref_name }}
name: RustLock v${{ steps.tag.outputs.VERSION }} name: RustLock v${{ steps.tag.outputs.VERSION }}
draft: true draft: true
prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }} prerelease: ${{ contains(github.ref_name, 'alpha') || contains(github.ref_name, 'beta') }}
files: | generate_release_notes: true
artifacts/rustlock-default-ubuntu/rustlock/rustlock:rustlock-default-ubuntu
artifacts/rustlock-no-networking-ubuntu/rustlock/rustlock:rustlock-no-networking-ubuntu publish-crates:
artifacts/rustlock-default-debian/rustlock/rustlock:rustlock-default-debian runs-on: ubuntu-latest
artifacts/rustlock-default-fedora/rustlock/rustlock:rustlock-default-fedora needs: [release]
artifacts/rustlock-default-arch/rustlock/rustlock:rustlock-default-arch steps:
- uses: actions/checkout@v7
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: |
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "CARGO_REGISTRY_TOKEN not set — skipping publish"
exit 0
fi
cargo publish
+14 -25
View File
@@ -11,6 +11,14 @@ on:
- 'Cargo.lock' - 'Cargo.lock'
- 'deny.toml' - 'deny.toml'
- '.github/workflows/security.yml' - '.github/workflows/security.yml'
pull_request:
branches: [main, master]
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'deny.toml'
- '.github/workflows/security.yml'
types: [opened, synchronize, reopened]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -19,17 +27,17 @@ jobs:
security-audit: security-audit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v7
- uses: Swatinem/rust-cache@v2
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install security tools - name: Install security tools
run: | uses: taiki-e/install-action@v2
curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash with:
cargo binstall --no-confirm cargo-audit cargo-deny cargo-cyclonedx cargo-outdated || \ tool: cargo-audit,cargo-deny,cargo-outdated
cargo install cargo-audit cargo-deny cargo-cyclonedx cargo-outdated
- name: Run cargo audit - name: Run cargo audit
run: cargo audit run: cargo audit
@@ -37,24 +45,5 @@ jobs:
- name: Run cargo deny - name: Run cargo deny
run: cargo deny check run: cargo deny check
- name: Generate Software Bill of Materials (SBOM)
run: cargo cyclonedx --format json --override-filename bom
- name: Upload SBOM
uses: actions/upload-artifact@v4
with:
name: sbom
path: bom.json
- name: Check for outdated dependencies - name: Check for outdated dependencies
run: cargo outdated --exit-code 1 || echo "Some dependencies are outdated" run: cargo outdated --exit-code 1 || echo "Some dependencies are outdated"
- name: Security summary
run: |
echo "=== Security Scan Complete ==="
echo "✅ cargo audit - Vulnerability scanning"
echo "✅ cargo deny - Advisory and license checking"
echo "✅ SBOM generated - Software Bill of Materials"
echo "✅ Outdated dependencies checked"
echo ""
echo "Next scheduled scan: Weekly (Sunday 00:00 UTC)"
+26
View File
@@ -0,0 +1,26 @@
name: Stale Issues and PRs
on:
schedule:
- cron: '0 0 * * *' # Daily
workflow_dispatch:
jobs:
stale:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v9
with:
stale-issue-message: 'This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs within 7 days.'
stale-pr-message: 'This PR has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs within 7 days.'
close-issue-message: 'This issue has been automatically closed due to inactivity.'
close-pr-message: 'This PR has been automatically closed due to inactivity.'
days-before-stale: 30
days-before-close: 7
stale-issue-label: 'stale'
stale-pr-label: 'stale'
exempt-issue-labels: 'enhancement,security'
exempt-pr-labels: 'security,work-in-progress'
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: \.md$
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
exclude: ^assets/
- id: check-merge-conflict
- repo: local
hooks:
- id: fmt
name: cargo fmt
entry: cargo fmt
args: ["--", "--check"]
language: system
types: [rust]
pass_filenames: false
- id: clippy
name: cargo clippy
entry: cargo clippy
args: ["--", "-D", "warnings"]
language: system
pass_filenames: false
Generated
+423 -670
View File
File diff suppressed because it is too large Load Diff
+4 -9
View File
@@ -3,6 +3,8 @@ name = "rustlock"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
authors = ["Jory Severijnse"]
description = "A high-performance Wayland screen locker"
[dependencies] [dependencies]
smithay-client-toolkit = { version = "0.19", default-features = false, features = ["calloop", "xkbcommon"] } smithay-client-toolkit = { version = "0.19", default-features = false, features = ["calloop", "xkbcommon"] }
@@ -10,30 +12,23 @@ wayland-client = { version = "0.31" }
wayland-protocols = { version = "0.32", features = ["client"] } wayland-protocols = { version = "0.32", features = ["client"] }
wayland-protocols-wlr = { version = "0.3", features = ["client"] } wayland-protocols-wlr = { version = "0.3", features = ["client"] }
anyhow = "1.0" anyhow = "1.0"
futures = { version = "0.3", default-features = false, features = ["std"] }
cairo-rs = { version = "0.20", default-features = false, features = ["png"] } cairo-rs = { version = "0.20", default-features = false, features = ["png"] }
gdk-pixbuf = { version = "0.20", default-features = false, features = ["v2_40"] } gdk-pixbuf = { version = "0.20", default-features = false, features = ["v2_40"] }
gio = { version = "0.20" }
pangocairo = { version = "0.20" } pangocairo = { version = "0.20" }
clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] } clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] }
clap_complete = { version = "4.6", default-features = false }
toml = { version = "1.1", default-features = false, features = ["parse", "display", "serde"] } toml = { version = "1.1", default-features = false, features = ["parse", "display", "serde"] }
serde = { version = "1.0", default-features = false, features = ["derive", "std"] } serde = { version = "1.0", default-features = false, features = ["derive", "std"] }
zeroize = "1.8" zeroize = "1.8"
log = "0.4" log = "0.4"
env_logger = { version = "0.11", default-features = false, features = ["color", "humantime"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } chrono = { version = "0.4", default-features = false, features = ["clock", "std"] }
whoami = "1.6" whoami = "1.6"
zbus = { version = "5.14", default-features = false, features = ["tokio"] } zbus = { version = "5.14", default-features = false, features = ["tokio"] }
mpris = "2.0" mpris = "2.0"
tokio = { version = "1.51", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } tokio = { version = "1.51", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
num-traits = { version = "0.2" }
rand = { version = "0.8" }
reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true } reqwest = { version = "0.12", default-features = false, features = ["blocking", "rustls-tls"], optional = true }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
fastblur = "0.1" pam-client = "0.5"
pam-client = "0.5"
thiserror = "1.0"
bytemuck = "1.25"
calloop = "0.13" calloop = "0.13"
xkbcommon = "0.7" xkbcommon = "0.7"
calloop-wayland-source = "0.3" calloop-wayland-source = "0.3"
+88 -67
View File
@@ -1,10 +1,14 @@
# 🔒 RustLock # 🔒 RustLock
[![License](https://img.shields.io/badge/license-AGPL--3.0%2B-blue.svg)](https://github.com/yourusername/rustlock/blob/main/LICENSE) [![License](https://img.shields.io/badge/license-GPL-3.0-blue.svg)](https://github.com/yourusername/rustlock/blob/main/LICENSE)
[![Version](https://img.shields.io/badge/version-0.1.0-green.svg)](https://github.com/yourusername/rustlock/releases) [![Version](https://img.shields.io/badge/version-0.1.0-green.svg)](https://github.com/yourusername/rustlock/releases)
A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`. A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`.
<p align="center">
<img src="assets/rustlock-effects.webp" alt="RustLock effects demo" width="800">
</p>
--- ---
## ✨ Features ## ✨ Features
@@ -13,11 +17,13 @@ A high-performance Wayland screen locker written in Rust, inspired by `swaylock-
- 🎨 **Visual Effects**: - 🎨 **Visual Effects**:
- Gaussian blur (configurable radius and passes) - Gaussian blur (configurable radius and passes)
- Vignette effect (configurable base and factor) - Vignette effect (configurable base and factor)
- Pixelate, Swirl, and Melting effects - Pixelate and Swirl effects
- Smooth fade-in animation - Smooth fade-in animation
- 🔐 **Password Indicator**: - 🔐 **Password Indicator**:
- Circular ring with configurable radius and thickness - Circular ring with configurable radius and thickness
- Dynamic key highlight segments that rotate with each keystroke - Dynamic key highlight segments that rotate with each keystroke
- Full password editing with cursor navigation (arrow keys, Home/End)
- Visual cursor indicator between dots
- Caps lock indicator - Caps lock indicator
- 🕐 **Information Display**: - 🕐 **Information Display**:
- Centered clock (HH:MM format) - Centered clock (HH:MM format)
@@ -40,8 +46,8 @@ A high-performance Wayland screen locker written in Rust, inspired by `swaylock-
- PAM-based authentication - PAM-based authentication
- Configurable grace period (any key press within N seconds unlocks without password) - Configurable grace period (any key press within N seconds unlocks without password)
- 🎯 **Customization**: - 🎯 **Customization**:
- Multiple ring shapes: circle, square, diamond, hexagon, pill
- Custom icons for WiFi, Bluetooth, Battery - Custom icons for WiFi, Bluetooth, Battery
- Theme presets (dark, light, nord, dracula)
- Configuration via config file or CLI - Configuration via config file or CLI
--- ---
@@ -63,6 +69,7 @@ rustlock \
--indicator \ --indicator \
--indicator-radius 100 \ --indicator-radius 100 \
--indicator-thickness 7 \ --indicator-thickness 7 \
--ring-shape hexagon \
--effect-blur 7x5 \ --effect-blur 7x5 \
--effect-vignette 0.5:0.5 \ --effect-vignette 0.5:0.5 \
--ring-color 785412 \ --ring-color 785412 \
@@ -70,6 +77,8 @@ rustlock \
--line-color 00000000 \ --line-color 00000000 \
--inside-color 00000088 \ --inside-color 00000088 \
--separator-color 00000000 \ --separator-color 00000000 \
--show-network \
--show-battery \
--grace 2 \ --grace 2 \
--fade-in 0.2 --fade-in 0.2
``` ```
@@ -81,54 +90,89 @@ When locked, use function keys to control the system:
- **F2**: Reboot - **F2**: Reboot
- **F3**: Power Off - **F3**: Power Off
### Password Entry
Use arrow keys to move the cursor while entering your password:
- **Left/Right arrows**: Move cursor one position
- **Home**: Move to start
- **End**: Move to end
- **Delete**: Delete character at cursor
- **Ctrl+U**: Clear entire password
--- ---
## ⚙️ Configuration ## ⚙️ Configuration
Options can be provided via command line or a configuration file at `~/.config/rustlock/config.toml`. CLI arguments take precedence over config file, which takes precedence over theme defaults. Options can be provided via command line or a configuration file at `~/.config/rustlock/config.toml`. CLI arguments take precedence over config file values.
### Options ### Options
| Option | Description | | Option | Default | Description |
|--------|-------------| |--------|---------|-------------|
| **General** | | | **General** | | |
| `--screenshots` | Capture desktop background before locking | | `--screenshots` | — | Capture desktop background before locking |
| `--image <PATH>` | Use custom background image instead of screenshot | | `--image <PATH>` | — | Use custom background image instead of screenshot |
| `--clock` | Display centered clock and date | | `--clock` | — | Display centered clock and date |
| `--indicator` | Show password entry ring (default: true) | | `--indicator` | `true` | Show password entry ring |
| `--indicator-radius <N>` | Ring radius in pixels (default: 100) | | `--hide-password` | `false` | Hide password dots (dots are shown by default) |
| `--indicator-thickness <N>` | Ring thickness in pixels (default: 7) | | `--config <PATH>` | — | Path to config file |
| **Effects** | | | `--debug` | — | Enable debug logging |
| `--effect-blur <R>x<P>` | Gaussian blur: radius x passes (e.g., `7x5`) | | **Ring** | | |
| `--effect-pixelate` | Pixelate effect | | `--indicator-radius <N>` | `100` | Ring radius in pixels |
| `--effect-swirl` | Swirl distortion effect | | `--indicator-thickness <N>` | `7` | Ring thickness in pixels |
| `--effect-melting` | Melting distortion effect | | `--ring-shape <SHAPE>` | `circle` | Ring shape: `circle`, `square`, `diamond`, `hexagon`, `pill` |
| `--effect-vignette <B>:<F>` | Vignette: base:factor (e.g., `0.5:0.5`) | | `--max-dots <N>` | `24` | Maximum password dots in the ring |
| **Colors** | | | **Effects** | | |
| `--ring-color <RRGGBB[AA]>` | Outer ring color (hex, optional alpha) | | `--effect-blur <R>x<P>` | — | Gaussian blur: radius x passes (e.g., `7x5`) |
| `--key-hl-color <RRGGBB[AA]>` | Key highlight segment color | | `--effect-vignette <B>:<F>` | — | Vignette: base : factor (e.g., `0.5:0.5`) |
| `--line-color <RRGGBB[AA]>` | Separator line color | | `--effect-pixelate <S>` | — | Pixelate effect with block size in pixels |
| `--inside-color <RRGGBB[AA]>` | Inner circle color | | `--effect-swirl <A>` | — | Swirl distortion with angle |
| `--separator-color <RRGGBB[AA]>` | Ring separator color | | **Colors** (hex `RRGGBB[AA]`) | | |
| **Display Options** | | | `--ring-color <HEX>` | `#785412` | Outer ring color |
| `--show-media` | Show MPRIS media info (default: true) | | `--line-color <HEX>` | `#00000000` | Separator line color |
| `--show-battery` | Show battery status (default: true) | | `--inside-color <HEX>` | `#00000088` | Inner circle fill color |
| `--show-network` | Show WiFi status (default: true) | | `--separator-color <HEX>` | `#00000000` | Ring segment separator color |
| `--show-bluetooth` | Show Bluetooth status (default: true) | | `--key-hl-color <HEX>` | `#4EAC41` | Key highlight segment color |
| `--show-keyboard-layout` | Show keyboard layout indicator (default: true) | | `--caps-lock-key-hl-color <HEX>` | `#4EAC41` | Key highlight color when caps lock is on |
| `--show-album-art` | Show album art (default: true) | | `--caps-lock-bs-hl-color <HEX>` | `#DB3300` | Backspace highlight color in caps lock |
| **Custom Icons** | | | `--caps-lock-color <HEX>` | `#E5A445` | Caps lock indicator ring color |
| `--wifi-icon <PATH>` | Custom WiFi icon (PNG/SVG) | | `--caps-lock-text-color <HEX>` | `#E5A445` | Caps lock text color |
| `--bluetooth-icon <PATH>` | Custom Bluetooth icon (PNG/SVG) | | `--verifying-color <HEX>` | `#0072FF` | Verifying feedback ring color |
| `--battery-icon <PATH>` | Custom battery icon (PNG/SVG) | | `--show-caps-lock-text` | `false` | Show "CAPS" text when caps lock is active |
| **Other** | | | **Display** | | |
| `--grace <SECONDS>` | Grace period in seconds (default: 2) | | `--show-media` | `false` | Show MPRIS media player information |
| `--fade-in <SECONDS>` | Fade-in animation duration (default: 0.2) | | `--show-battery` | `false` | Show battery status |
| `--pam-service <NAME>` | PAM service name (default: "rustlock") | | `--show-network` | `false` | Show WiFi SSID and signal strength |
| `--config <PATH>` | Path to config file | | `--show-bluetooth` | `false` | Show Bluetooth status |
| `--theme <NAME>` | Theme preset: dark, light, nord, dracula | | `--show-album-art` | `false` | Show album art for media |
| `--debug` | Enable debug logging | | `--show-keyboard-layout` | `false` | Show keyboard layout indicator |
| `--log-file` | Write logs to `~/.rustlock.log` | | **Feedback & Timing** | | |
| `--fade-in <SECONDS>` | `0.2` | Fade-in animation duration |
| `--grace <SECONDS>` | `0` | Grace period — any key press unlocks within N seconds |
| `--auth-timeout <MS>` | `10000` | PAM authentication timeout in milliseconds |
| `--wrong-password-duration <MS>` | `500` | Wrong password feedback animation duration |
| `--key-highlight-duration <MS>` | `300` | Key highlight feedback duration |
| `--cleared-feedback-duration <MS>` | `500` | Cleared password feedback duration |
| `--verifying-timeout <MS>` | `5000` | Verifying feedback fallback timeout |
| `--feedback-window-duration <MS>` | `1000` | Wrong password feedback input window |
| `--key-highlight-window-duration <MS>` | `200` | Key highlight input-side window |
| **System** | | |
| `--pam-service <NAME>` | `rustlock` | PAM service name |
| `--system-poll-interval <S>` | `2` | Polling interval for system status updates |
| `--dbus-reconnect-delay <S>` | `5` | Delay before reconnecting DBus on failure |
| `--command-timeout <S>` | `5` | Timeout for system commands |
| **Custom Icons** (PNG/SVG path) | | |
| `--wifi-icon <PATH>` | — | Custom WiFi icon |
| `--bluetooth-icon <PATH>` | — | Custom Bluetooth icon |
| `--battery-icon <PATH>` | — | Custom battery icon |
| `--media-prev-icon <PATH>` | — | Custom previous track icon |
| `--media-stop-icon <PATH>` | — | Custom stop icon |
| `--media-play-icon <PATH>` | — | Custom play icon |
| `--media-pause-icon <PATH>` | — | Custom pause icon |
| `--media-next-icon <PATH>` | — | Custom next track icon |
| **Logging** | | |
| `--log-file` | — | Write verbose logs to `~/.rustlock.log` |
| `--log-path <PATH>` | — | Path for log file (enables file logging, overrides `--log-file` default path) |
--- ---
@@ -167,29 +211,6 @@ The binary will be available at `target/release/rustlock`.
--- ---
## ✅ Completed
- [x] PAM-based authentication
- [x] Grace period (any key unlocks within N seconds)
- [x] Screenshot capture with blur/vignette/pixelate/swirl/melting effects
- [x] Configuration file support (`~/.config/rustlock/config.toml`) with schema validation
- [x] Debug logging to `~/.rustlock.log`
- [x] Clock and date display
- [x] Password indicator ring with rotating highlights
- [x] Dynamic screen resolution detection
- [x] Full multi-monitor support with different resolutions
- [x] Theme/profile support with presets (dark, light, nord, dracula)
- [x] Wayland protocol stability fixes
- [x] Media control integration (MPRIS support with Album Art)
- [x] Battery, WiFi, and Bluetooth status indicators
- [x] Custom background image support
- [x] Custom icons for status indicators
- [x] Keyboard layout indicator
- [x] Session management (F1-F3 keys)
- [x] Caps lock indicator
---
## 📄 License ## 📄 License
GPL v3 GPL v3
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+21 -2
View File
@@ -28,8 +28,27 @@
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.pkg-config pkgs.pkg-config
pkgs.rustPlatform.bindgenHook pkgs.rustPlatform.bindgenHook
pkgs.rustfmt ];
pkgs.clippy };
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rustfmt
clippy
cargo-audit
cargo-deny
prek
pkg-config
rustPlatform.bindgenHook
cairo
pam
gdk-pixbuf
librsvg
pango
libxkbcommon
dbus
]; ];
}; };
}; };
+3
View File
@@ -0,0 +1,3 @@
[toolchain]
channel = "1.94.0"
components = ["rustfmt", "clippy"]
+39 -30
View File
@@ -1,5 +1,8 @@
use std::ffi::{CStr, CString}; use std::ffi::{CStr, CString};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread; use std::thread;
use std::time::Duration;
use log::{debug, error}; use log::{debug, error};
use pam_client::{Context, ErrorCode, Flag}; use pam_client::{Context, ErrorCode, Flag};
@@ -7,8 +10,10 @@ use smithay_client_toolkit::reexports::{calloop::channel, calloop::EventLoop};
use whoami::username; use whoami::username;
use zeroize::Zeroizing; use zeroize::Zeroizing;
const SERVICE_NAME: &str = "rustlock"; type AuthChannels = (
channel::Sender<(Zeroizing<String>, u64)>,
channel::Channel<(bool, u64)>,
);
pub struct LockConversation { pub struct LockConversation {
pub password: Option<Zeroizing<String>>, pub password: Option<Zeroizing<String>>,
} }
@@ -35,60 +40,64 @@ impl pam_client::ConversationHandler for LockConversation {
} }
} }
pub fn create_and_run_auth_loop( pub fn create_and_run_auth_loop(service_name: String) -> Option<AuthChannels> {
) -> Option<(channel::Sender<Zeroizing<String>>, channel::Channel<bool>)> {
let username = username(); let username = username();
let (auth_req_send, auth_req_recv) = channel::channel::<(Zeroizing<String>, u64)>();
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
thread::spawn(move || {
let mut event_loop: EventLoop<()> = EventLoop::try_new().unwrap();
// Create PAM context once and reuse it for all auth attempts.
// Creating a new context each time is expensive because it
// re-parses configs and re-loads shared libraries for every attempt.
let conversation = LockConversation { password: None }; let conversation = LockConversation { password: None };
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) { let mut context =
Ok(_) => { match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
Ok(ctx) => {
debug!("Prepared to authenticate user '{}'", username); debug!("Prepared to authenticate user '{}'", username);
ctx
} }
Err(err) => { Err(err) => {
error!("Failed to initialize PAM context: {:?}", err); error!("Failed to initialize PAM context: {:?}", err);
error!( error!(
"Ensure that the PAM service '{}' is correctly configured.", "Ensure that the PAM service '{}' is correctly configured.",
SERVICE_NAME service_name
); );
return None; return;
}
} }
};
let (auth_req_send, auth_req_recv) = channel::channel::<Zeroizing<String>>(); let running = Arc::new(AtomicBool::new(true));
let (auth_res_send, auth_res_recv) = channel::channel::<bool>(); let running_clone = running.clone();
thread::spawn(move || {
let mut event_loop: EventLoop<()> = EventLoop::try_new().unwrap();
event_loop event_loop
.handle() .handle()
.insert_source(auth_req_recv, |evt, _metadata, _state| match evt { .insert_source(auth_req_recv, move |evt, _metadata, _state| match evt {
channel::Event::Msg(password) => { channel::Event::Msg((password, seq)) => {
let conversation = LockConversation { context.conversation_mut().password = Some(password);
password: Some(password), match context.authenticate(Flag::NONE) {
};
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
Ok(mut context) => match context.authenticate(Flag::NONE) {
Ok(()) => { Ok(()) => {
auth_res_send.send(true).unwrap(); let _ = auth_res_send.send((true, seq));
} }
Err(err) => { Err(err) => {
error!("Pam authenticate failed with {:?}", err); error!("Pam authenticate failed with {:?}", err);
auth_res_send.send(false).unwrap(); let _ = auth_res_send.send((false, seq));
}
},
Err(err) => {
error!("Failed to re-initialize PAM context: {:?}", err);
auth_res_send.send(false).unwrap();
} }
} }
} }
channel::Event::Closed => {} channel::Event::Closed => {
running_clone.store(false, Ordering::SeqCst);
}
}) })
.unwrap(); .unwrap();
loop { while running.load(Ordering::SeqCst) {
event_loop.dispatch(None, &mut ()).expect("Failed to run"); let _ = event_loop.dispatch(Some(Duration::from_millis(100)), &mut ());
} }
debug!("PAM auth thread exiting cleanly");
}); });
Some((auth_req_send, auth_res_recv)) Some((auth_req_send, auth_res_recv))
+196 -60
View File
@@ -1,7 +1,56 @@
use crate::util; use crate::util;
use clap::Parser; use clap::Parser;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub enum RingShape {
#[default]
Circle,
Square,
Diamond,
Hexagon,
Pill,
}
impl FromStr for RingShape {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"circle" => Ok(RingShape::Circle),
"square" => Ok(RingShape::Square),
"diamond" => Ok(RingShape::Diamond),
"hexagon" => Ok(RingShape::Hexagon),
"pill" => Ok(RingShape::Pill),
_ => Err(format!(
"Unknown ring shape '{}'. Options: circle, square, diamond, hexagon, pill",
s
)),
}
}
}
impl fmt::Display for RingShape {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RingShape::Circle => write!(f, "circle"),
RingShape::Square => write!(f, "square"),
RingShape::Diamond => write!(f, "diamond"),
RingShape::Hexagon => write!(f, "hexagon"),
RingShape::Pill => write!(f, "pill"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
pub enum CompletionShell {
Bash,
Fish,
Zsh,
}
#[derive(Parser, Debug, Clone, Serialize, Deserialize)] #[derive(Parser, Debug, Clone, Serialize, Deserialize)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
@@ -21,6 +70,10 @@ pub struct Config {
#[arg(long, default_value = "7")] #[arg(long, default_value = "7")]
pub indicator_thickness: u32, pub indicator_thickness: u32,
#[arg(long, default_value = "circle", value_parser = clap::value_parser!(RingShape))]
#[serde(default)]
pub ring_shape: RingShape,
#[arg(long, value_parser = util::parse_blur_effect)] #[arg(long, value_parser = util::parse_blur_effect)]
#[serde( #[serde(
deserialize_with = "util::deserialize_blur_effect", deserialize_with = "util::deserialize_blur_effect",
@@ -45,10 +98,6 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub effect_swirl: Option<f32>, pub effect_swirl: Option<f32>,
#[arg(long)]
#[serde(default)]
pub effect_melting: Option<f32>,
#[arg(long, default_value = "785412", value_parser = util::parse_hex_color)] #[arg(long, default_value = "785412", value_parser = util::parse_hex_color)]
#[serde( #[serde(
deserialize_with = "util::deserialize_hex_color", deserialize_with = "util::deserialize_hex_color",
@@ -91,7 +140,14 @@ pub struct Config {
)] )]
pub caps_lock_text_color: (f64, f64, f64, f64), pub caps_lock_text_color: (f64, f64, f64, f64),
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, default_value = "0072FF", value_parser = util::parse_hex_color)]
#[serde(
deserialize_with = "util::deserialize_hex_color",
serialize_with = "util::serialize_hex_color"
)]
pub verifying_color: (f64, f64, f64, f64),
#[arg(long, action = clap::ArgAction::SetTrue)]
pub show_caps_lock_text: bool, pub show_caps_lock_text: bool,
#[arg(long, default_value = "00000000", value_parser = util::parse_hex_color)] #[arg(long, default_value = "00000000", value_parser = util::parse_hex_color)]
@@ -115,7 +171,7 @@ pub struct Config {
)] )]
pub separator_color: (f64, f64, f64, f64), pub separator_color: (f64, f64, f64, f64),
#[arg(long, default_value = "2")] #[arg(long, default_value = "0")]
pub grace: f32, pub grace: f32,
#[arg(long, default_value = "0.2")] #[arg(long, default_value = "0.2")]
@@ -134,19 +190,28 @@ pub struct Config {
#[arg(long)] #[arg(long)]
pub log_file: bool, pub log_file: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] /// Path for log file (enables file logging, overrides --log-file default path)
#[arg(long)]
#[serde(default)]
pub log_path: Option<PathBuf>,
/// Timeout (ms) for PAM authentication before showing failure
#[arg(long, default_value = "10000")]
pub auth_timeout: u64,
#[arg(long, action = clap::ArgAction::SetTrue)]
pub show_media: bool, pub show_media: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_battery: bool, pub show_battery: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_network: bool, pub show_network: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_bluetooth: bool, pub show_bluetooth: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_album_art: bool, pub show_album_art: bool,
#[arg(long, action = clap::ArgAction::SetTrue)] #[arg(long, action = clap::ArgAction::SetTrue)]
@@ -191,10 +256,50 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub media_next_icon: Option<String>, pub media_next_icon: Option<String>,
/// Apply a pre-defined theme preset /// Maximum number of password dots in the indicator ring
#[arg(long)] #[arg(long, default_value = "24")]
#[serde(default)] pub max_dots: u32,
pub theme: Option<String>,
/// Duration (ms) for wrong password feedback animation
#[arg(long, default_value = "500")]
pub wrong_password_duration: u64,
/// Duration (ms) for key highlight feedback animation
#[arg(long, default_value = "300")]
pub key_highlight_duration: u64,
/// Duration (ms) for cleared password feedback animation
#[arg(long, default_value = "500")]
pub cleared_feedback_duration: u64,
/// Duration (ms) for verifying feedback fallback timeout
#[arg(long, default_value = "5000")]
pub verifying_timeout: u64,
/// Duration (ms) that wrong password feedback is shown input-side
#[arg(long, default_value = "1000")]
pub feedback_window_duration: u64,
/// Duration (ms) for key highlight feedback input-side window
#[arg(long, default_value = "200")]
pub key_highlight_window_duration: u64,
/// Polling interval (seconds) for system status updates
#[arg(long, default_value = "2")]
pub system_poll_interval: u64,
/// Delay (seconds) before reconnecting DBus on failure
#[arg(long, default_value = "5")]
pub dbus_reconnect_delay: u64,
/// Timeout (seconds) for system commands (poweroff, reboot, suspend)
#[arg(long, default_value = "5")]
pub command_timeout: u64,
/// Generate shell completions for the given shell
#[arg(long, value_enum)]
#[serde(skip)]
pub completions: Option<CompletionShell>,
} }
impl Config { impl Config {
@@ -202,6 +307,26 @@ impl Config {
use clap::CommandFactory; use clap::CommandFactory;
let mut config = Config::parse(); let mut config = Config::parse();
// Handle --completions early (before config file merge so file can't inject it)
if let Some(shell) = config.completions {
use clap_complete::Shell;
let mut cmd = Config::command();
let name = "rustlock";
match shell {
CompletionShell::Bash => {
clap_complete::generate(Shell::Bash, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Fish => {
clap_complete::generate(Shell::Fish, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Zsh => {
clap_complete::generate(Shell::Zsh, &mut cmd, name, &mut std::io::stdout())
}
}
std::process::exit(0);
}
let cmd = Config::command(); let cmd = Config::command();
let matches = cmd.get_matches(); let matches = cmd.get_matches();
@@ -209,7 +334,7 @@ impl Config {
let is_cli = let is_cli =
|key: &str| matches.value_source(key) == Some(clap::parser::ValueSource::CommandLine); |key: &str| matches.value_source(key) == Some(clap::parser::ValueSource::CommandLine);
// 1. Config file layer (overrides defaults and themes) // Config file layer (overrides defaults, CLI args take precedence)
let config_path = config.config.clone().unwrap_or_else(|| { let config_path = config.config.clone().unwrap_or_else(|| {
let mut path = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default()); let mut path = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
path.push(".config/rustlock/config.toml"); path.push(".config/rustlock/config.toml");
@@ -242,51 +367,62 @@ impl Config {
} }
} }
// 2. Theme presets (applied to fields NOT set on CLI or in File) config.auth_timeout = config.auth_timeout.max(100);
if let Some(theme) = &config.theme { config.max_dots = config.max_dots.max(1);
match theme.as_str() { config.fade_in = config.fade_in.max(0.0);
"modern" => { config.grace = config.grace.max(0.0);
if config.effect_blur.is_none() && !is_cli("effect_blur") {
config.effect_blur = Some((10, 3));
}
if config.effect_vignette.is_none() && !is_cli("effect_vignette") {
config.effect_vignette = Some((0.5, 0.5));
}
if !is_cli("indicator_radius") {
config.indicator_radius = 120;
}
if !is_cli("ring_color") {
config.ring_color = (0.2, 0.6, 0.8, 1.0);
}
}
"pixel" => {
if config.effect_pixelate.is_none() && !is_cli("effect_pixelate") {
config.effect_pixelate = Some(10);
}
if !is_cli("indicator_radius") {
config.indicator_radius = 80;
}
if !is_cli("ring_color") {
config.ring_color = (0.8, 0.2, 0.2, 1.0);
}
}
"glass" => {
if config.effect_blur.is_none() && !is_cli("effect_blur") {
config.effect_blur = Some((20, 5));
}
if !is_cli("inside_color") {
config.inside_color = (1.0, 1.0, 1.0, 0.1);
}
if !is_cli("ring_color") {
config.ring_color = (1.0, 1.0, 1.0, 0.5);
}
}
_ => {
log::warn!("Unknown theme: {}", theme);
}
}
}
config config
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_max_dots_default() {
let config = Config::parse_from(["test"]);
assert_eq!(config.max_dots, 24);
}
#[test]
fn test_auth_timeout_default() {
let config = Config::parse_from(["test"]);
assert_eq!(config.auth_timeout, 10000);
}
#[test]
fn test_auth_timeout_min_clamp() {
let mut config = Config::parse_from(["test", "--auth-timeout", "0"]);
config.auth_timeout = config.auth_timeout.max(100);
assert_eq!(config.auth_timeout, 100);
}
#[test]
fn test_max_dots_min_clamp() {
let mut config = Config::parse_from(["test", "--max-dots", "0"]);
config.max_dots = config.max_dots.max(1);
assert_eq!(config.max_dots, 1);
}
#[test]
fn test_fade_in_negative_clamp() {
let mut config = Config::parse_from(["test", "--fade-in=-1"]);
config.fade_in = config.fade_in.max(0.0);
assert_eq!(config.fade_in, 0.0);
}
#[test]
fn test_grace_negative_clamp() {
let mut config = Config::parse_from(["test", "--grace=-1"]);
config.grace = config.grace.max(0.0);
assert_eq!(config.grace, 0.0);
}
#[test]
fn test_log_path_default_none() {
let config = Config::parse_from(["test"]);
assert!(config.log_path.is_none());
}
}
+285
View File
@@ -0,0 +1,285 @@
//! Visual effects for lock screen backgrounds.
//!
//! Each effect takes a `&mut ImageSurface` and processes it in-place.
//! Effects are applied in order: blur → vignette → pixelate → swirl.
use anyhow::{Context, Result};
use cairo::ImageSurface;
/// Apply a swirl effect (radial rotation around the image centre).
pub fn apply_swirl(surface: &mut ImageSurface, angle: f32) -> Result<()> {
let width = surface.width();
let height = surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let radius = center_x.min(center_y);
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("swirl: failed to read surface data")?;
let original = data.clone();
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let d = (dx * dx + dy * dy).sqrt();
if d < radius {
let percent = (radius - d) / radius;
let theta = percent * percent * angle;
let s = theta.sin();
let c = theta.cos();
let nx = (c * dx - s * dy + center_x) as i32;
let ny = (s * dx + c * dy + center_y) as i32;
if nx >= 0 && nx < width && ny >= 0 && ny < height {
let src_idx = (ny as usize * stride) + (nx as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
data[dst_idx..dst_idx + 4].copy_from_slice(&original[src_idx..src_idx + 4]);
}
}
}
}
let mut surface_data = surface
.data()
.context("swirl: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Pixelate the surface.
pub fn apply_pixelate(surface: &mut ImageSurface, pixel_size: u32) -> Result<()> {
if pixel_size <= 1 {
return Ok(());
}
let width = surface.width();
let height = surface.height();
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("pixelate: failed to read surface data")?;
for y in (0..height).step_by(pixel_size as usize) {
for x in (0..width).step_by(pixel_size as usize) {
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
let mut count = 0u32;
// Average pixels in the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
r += data[index] as u32;
g += data[index + 1] as u32;
b += data[index + 2] as u32;
count += 1;
}
}
}
if count > 0 {
let r = r.checked_div(count).unwrap_or(0) as u8;
let g = g.checked_div(count).unwrap_or(0) as u8;
let b = b.checked_div(count).unwrap_or(0) as u8;
// Fill the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
}
}
}
}
}
}
let mut surface_data = surface
.data()
.context("pixelate: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a box blur effect (fast two-pass sliding-window implementation).
/// Uses multiply-shift to avoid slow integer division in the hot loop.
pub fn apply_blur(surface: &mut ImageSurface, radius: u32, times: u32) -> Result<()> {
if radius == 0 || times == 0 {
return Ok(());
}
let width = surface.width() as usize;
let height = surface.height() as usize;
let stride = surface.stride() as usize;
let r = radius as usize;
let mut data = vec![0u8; stride * height];
surface
.with_data(|src| data.copy_from_slice(src))
.context("blur: failed to read surface data")?;
let mut scratch = vec![0u8; stride * height];
// Precompute ceil(2^32 / c) for every possible window size c.
let max_count = (2 * r + 1).min(width.max(height));
let factor: Vec<u32> = (0..=max_count)
.map(|c| {
if c == 0 {
0
} else {
(1u64 << 32).div_ceil(c as u64) as u32
}
})
.collect();
#[inline(always)]
fn div_mul(n: u32, factor: u32) -> u8 {
((n as u64 * factor as u64) >> 32) as u8
}
for _ in 0..times {
// Horizontal box blur: data -> scratch
for y in 0..height {
let row = y * stride;
let init_end = r.min(width - 1);
let mut b_acc = 0u32;
let mut g_acc = 0u32;
let mut r_acc = 0u32;
for x in 0..=init_end {
let px = row + x * 4;
b_acc += data[px] as u32;
g_acc += data[px + 1] as u32;
r_acc += data[px + 2] as u32;
}
let mut count = (init_end + 1) as u32;
for x in 0..width {
let dst = row + x * 4;
let f = factor[count as usize];
scratch[dst] = div_mul(b_acc, f);
scratch[dst + 1] = div_mul(g_acc, f);
scratch[dst + 2] = div_mul(r_acc, f);
scratch[dst + 3] = data[dst + 3];
if x >= r {
let old = row + (x - r) * 4;
b_acc -= data[old] as u32;
g_acc -= data[old + 1] as u32;
r_acc -= data[old + 2] as u32;
count -= 1;
}
if x + r + 1 < width {
let new = row + (x + r + 1) * 4;
b_acc += data[new] as u32;
g_acc += data[new + 1] as u32;
r_acc += data[new + 2] as u32;
count += 1;
}
}
}
// Vertical box blur: scratch -> data
let init_end = r.min(height - 1);
let mut b_acc = vec![0u32; width];
let mut g_acc = vec![0u32; width];
let mut r_acc = vec![0u32; width];
for y in 0..=init_end {
let row = y * stride;
for x in 0..width {
let px = row + x * 4;
b_acc[x] += scratch[px] as u32;
g_acc[x] += scratch[px + 1] as u32;
r_acc[x] += scratch[px + 2] as u32;
}
}
let mut count = (init_end + 1) as u32;
for y in 0..height {
let f = factor[count as usize];
let dst_row = y * stride;
for x in 0..width {
let dst = dst_row + x * 4;
data[dst] = div_mul(b_acc[x], f);
data[dst + 1] = div_mul(g_acc[x], f);
data[dst + 2] = div_mul(r_acc[x], f);
}
if y >= r {
let old_row = (y - r) * stride;
for x in 0..width {
let px = old_row + x * 4;
b_acc[x] -= scratch[px] as u32;
g_acc[x] -= scratch[px + 1] as u32;
r_acc[x] -= scratch[px + 2] as u32;
}
count -= 1;
}
if y + r + 1 < height {
let new_row = (y + r + 1) * stride;
for x in 0..width {
let px = new_row + x * 4;
b_acc[x] += scratch[px] as u32;
g_acc[x] += scratch[px + 1] as u32;
r_acc[x] += scratch[px + 2] as u32;
}
count += 1;
}
}
}
let mut surface_data = surface.data()?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a vignette effect (darken edges).
pub fn apply_vignette(surface: &mut ImageSurface, base: f32, factor: f32) -> Result<()> {
let width = surface.width();
let height = surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let max_distance = (center_x * center_x + center_y * center_y).sqrt();
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("vignette: failed to read surface data")?;
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let distance = (dx * dx + dy * dy).sqrt();
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
let index = (y as usize * stride) + (x as usize * 4);
for i in 0..3 {
let value = data[index + i] as f32 * vignette_factor;
data[index + i] = value.clamp(0.0, 255.0) as u8;
}
}
}
let mut surface_data = surface
.data()
.context("vignette: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
+335 -9
View File
@@ -7,16 +7,20 @@ pub struct InputHandler {
wrong_password_timer: Option<std::time::Instant>, wrong_password_timer: Option<std::time::Instant>,
key_highlight_timer: Option<std::time::Instant>, key_highlight_timer: Option<std::time::Instant>,
caps_lock: bool, caps_lock: bool,
config: crate::config::Config,
last_failed_attempt: Option<std::time::Instant>,
} }
impl InputHandler { impl InputHandler {
pub fn new(_config: crate::config::Config) -> Self { pub fn new(config: crate::config::Config) -> Self {
Self { Self {
password_buffer: Zeroizing::new(String::new()), password_buffer: Zeroizing::new(String::new()),
cursor_position: 0, cursor_position: 0,
wrong_password_timer: None, wrong_password_timer: None,
key_highlight_timer: None, key_highlight_timer: None,
caps_lock: false, caps_lock: false,
config,
last_failed_attempt: None,
} }
} }
@@ -27,7 +31,10 @@ impl InputHandler {
utf8: Option<String>, utf8: Option<String>,
modifiers: smithay_client_toolkit::seat::keyboard::Modifiers, modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
) -> InputAction { ) -> InputAction {
// Update Caps Lock state if self.is_cooldown() {
return InputAction::None;
}
self.caps_lock = modifiers.caps_lock; self.caps_lock = modifiers.caps_lock;
if modifiers.ctrl && keysym == Keysym::u { if modifiers.ctrl && keysym == Keysym::u {
@@ -43,13 +50,14 @@ impl InputHandler {
use smithay_client_toolkit::seat::keyboard::Keysym; use smithay_client_toolkit::seat::keyboard::Keysym;
match keysym { match keysym {
Keysym::BackSpace => { Keysym::BackSpace => {
if !self.password_buffer.is_empty() && self.cursor_position > 0 { if self.password_buffer.is_empty() || self.cursor_position == 0 {
return InputAction::None;
}
self.cursor_position -= 1; self.cursor_position -= 1;
self.password_buffer.remove(self.cursor_position); self.password_buffer.remove(self.cursor_position);
if self.password_buffer.is_empty() { if self.password_buffer.is_empty() {
return InputAction::PasswordCleared; return InputAction::PasswordCleared;
} }
}
return InputAction::PasswordChanged; return InputAction::PasswordChanged;
} }
Keysym::Return | Keysym::KP_Enter => { Keysym::Return | Keysym::KP_Enter => {
@@ -61,6 +69,44 @@ impl InputHandler {
Keysym::Escape => { Keysym::Escape => {
return InputAction::Cancel; return InputAction::Cancel;
} }
Keysym::Left => {
if self.cursor_position > 0 {
self.cursor_position -= 1;
return InputAction::CursorMoved;
}
return InputAction::None;
}
Keysym::Right => {
if self.cursor_position < self.password_buffer.len() {
self.cursor_position += 1;
return InputAction::CursorMoved;
}
return InputAction::None;
}
Keysym::Home => {
if self.cursor_position > 0 {
self.cursor_position = 0;
return InputAction::CursorMoved;
}
return InputAction::None;
}
Keysym::End => {
if self.cursor_position < self.password_buffer.len() {
self.cursor_position = self.password_buffer.len();
return InputAction::CursorMoved;
}
return InputAction::None;
}
Keysym::Delete => {
if self.cursor_position < self.password_buffer.len() {
self.password_buffer.remove(self.cursor_position);
if self.password_buffer.is_empty() {
return InputAction::PasswordCleared;
}
return InputAction::PasswordChanged;
}
return InputAction::None;
}
_ => {} _ => {}
} }
@@ -78,19 +124,34 @@ impl InputHandler {
InputAction::None InputAction::None
} }
pub fn password_buffer(&self) -> &Zeroizing<String> {
&self.password_buffer
}
pub fn password_length(&self) -> usize { pub fn password_length(&self) -> usize {
self.password_buffer.len() self.password_buffer.len()
} }
pub fn cursor_position(&self) -> usize {
self.cursor_position
}
/// Set wrong password feedback timer /// Set wrong password feedback timer
pub fn set_wrong_password_feedback(&mut self) { pub fn set_wrong_password_feedback(&mut self) {
self.wrong_password_timer = Some(std::time::Instant::now()); self.wrong_password_timer = Some(std::time::Instant::now());
self.last_failed_attempt = Some(std::time::Instant::now());
}
pub fn is_cooldown(&self) -> bool {
self.last_failed_attempt
.map(|t| t.elapsed() < std::time::Duration::from_millis(400))
.unwrap_or(false)
} }
/// Check if wrong password feedback should be shown /// Check if wrong password feedback should be shown
pub fn should_show_wrong_password(&self) -> bool { pub fn should_show_wrong_password(&self) -> bool {
if let Some(timer) = self.wrong_password_timer { if let Some(timer) = self.wrong_password_timer {
timer.elapsed() < std::time::Duration::from_millis(1000) timer.elapsed() < std::time::Duration::from_millis(self.config.feedback_window_duration)
} else { } else {
false false
} }
@@ -104,15 +165,13 @@ impl InputHandler {
/// Check if key highlight should be shown /// Check if key highlight should be shown
pub fn should_show_key_highlight(&self) -> bool { pub fn should_show_key_highlight(&self) -> bool {
if let Some(timer) = self.key_highlight_timer { if let Some(timer) = self.key_highlight_timer {
timer.elapsed() < std::time::Duration::from_millis(200) timer.elapsed()
< std::time::Duration::from_millis(self.config.key_highlight_window_duration)
} else { } else {
false false
} }
} }
/// Update timers (should be called periodically)
pub fn update(&mut self) {}
/// Get the current Caps Lock state /// Get the current Caps Lock state
pub fn caps_lock(&self) -> bool { pub fn caps_lock(&self) -> bool {
self.caps_lock self.caps_lock
@@ -125,6 +184,273 @@ pub enum InputAction {
None, None,
PasswordChanged, PasswordChanged,
PasswordCleared, PasswordCleared,
CursorMoved,
SubmitPassword(Zeroizing<String>), SubmitPassword(Zeroizing<String>),
Cancel, Cancel,
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use clap::Parser;
use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers};
fn test_config() -> Config {
Config::parse_from(["test"])
}
#[test]
fn test_new_handler_defaults() {
let handler = InputHandler::new(test_config());
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
assert!(!handler.caps_lock());
assert!(!handler.should_show_wrong_password());
assert!(!handler.should_show_key_highlight());
}
#[test]
fn test_character_input_appends() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 1);
assert_eq!(handler.cursor_position(), 1);
let action = handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 2);
assert_eq!(handler.cursor_position(), 2);
assert_eq!(&*handler.password_buffer, "ab");
}
#[test]
fn test_backspace_removes_last_char() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert_eq!(handler.password_length(), 2);
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 1);
assert_eq!(handler.cursor_position(), 1);
assert_eq!(&*handler.password_buffer, "a");
}
#[test]
fn test_backspace_on_empty_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.password_length(), 0);
}
#[test]
fn test_backspace_last_char_clears() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::PasswordCleared));
assert_eq!(handler.password_length(), 0);
}
#[test]
fn test_ctrl_u_clears_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers {
ctrl: true,
..Modifiers::default()
};
handler.handle_key_event(Keysym::a, Some("a".to_string()), Modifiers::default());
handler.handle_key_event(Keysym::b, Some("b".to_string()), Modifiers::default());
assert_eq!(handler.password_length(), 2);
let action = handler.handle_key_event(Keysym::u, None, mods);
assert!(matches!(action, InputAction::PasswordCleared));
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_submit_returns_and_clears() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
let action = handler.handle_key_event(Keysym::Return, None, mods);
match action {
InputAction::SubmitPassword(p) => {
assert_eq!(&*p, "ab");
}
_ => panic!("Expected SubmitPassword, got {:?}", action),
}
// Buffer should be cleared after submission
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_submit_enter_kp() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::KP_Enter, None, Modifiers::default());
assert!(matches!(action, InputAction::SubmitPassword(_)));
}
#[test]
fn test_escape_cancels() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::Escape, None, mods);
assert!(matches!(action, InputAction::Cancel));
}
#[test]
fn test_cursor_left_right() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
assert_eq!(handler.cursor_position(), 3);
// Move left
let action = handler.handle_key_event(Keysym::Left, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 2);
// Left again
handler.handle_key_event(Keysym::Left, None, mods);
assert_eq!(handler.cursor_position(), 1);
// Right
let action = handler.handle_key_event(Keysym::Right, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 2);
}
#[test]
fn test_cursor_left_at_start() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::Left, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_cursor_right_at_end() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::Right, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.cursor_position(), 1);
}
#[test]
fn test_home_and_end() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
handler.handle_key_event(Keysym::Left, None, mods);
handler.handle_key_event(Keysym::Left, None, mods);
assert_eq!(handler.cursor_position(), 1);
// Home
let action = handler.handle_key_event(Keysym::Home, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 0);
// End
let action = handler.handle_key_event(Keysym::End, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 3);
}
#[test]
fn test_delete_removes_at_cursor() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
// cursor at 3, delete should be a no-op
let action = handler.handle_key_event(Keysym::Delete, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.password_length(), 3);
// move left, delete at cursor position 2 (removes 'c')
handler.handle_key_event(Keysym::Left, None, mods);
let action = handler.handle_key_event(Keysym::Delete, None, mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 2);
assert_eq!(&*handler.password_buffer, "ab");
}
#[test]
fn test_insert_mid_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
// Move left, insert 'b' between a and c
handler.handle_key_event(Keysym::Left, None, mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert_eq!(&*handler.password_buffer, "abc");
assert_eq!(handler.cursor_position(), 2);
}
#[test]
fn test_caps_lock_tracking() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.caps_lock());
let caps_mods = Modifiers {
caps_lock: true,
..Modifiers::default()
};
handler.handle_key_event(Keysym::a, Some("A".to_string()), caps_mods);
assert!(handler.caps_lock());
}
#[test]
fn test_wrong_password_timer() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.should_show_wrong_password());
handler.set_wrong_password_feedback();
assert!(handler.should_show_wrong_password());
}
#[test]
fn test_key_highlight_timer() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.should_show_key_highlight());
handler.set_key_highlight();
assert!(handler.should_show_key_highlight());
}
}
+134 -16
View File
@@ -24,6 +24,16 @@ pub struct LockedSurface {
wayland_surface: Option<wl_surface::WlSurface>, wayland_surface: Option<wl_surface::WlSurface>,
output: wl_output::WlOutput, output: wl_output::WlOutput,
configured: bool, configured: bool,
/// Set whenever rendered state changes (keystroke, status, clock minute,
/// animation step). update() renders only when this is set or an animation
/// is in flight, so an idle lock screen does no per-frame cairo work.
dirty: bool,
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
last_minute: i64,
ctrl_held: bool,
/// True while the pointer button is held on the indicator ring (hold-to-peek).
/// Cleared on button release, matching Ctrl-hold transient behavior.
peek_toggled: bool,
} }
impl LockedSurface { impl LockedSurface {
@@ -54,6 +64,10 @@ impl LockedSurface {
wayland_surface: None, wayland_surface: None,
output, output,
configured: false, configured: false,
dirty: true,
last_minute: i64::MIN,
ctrl_held: false,
peek_toggled: false,
}) })
} }
@@ -62,6 +76,7 @@ impl LockedSurface {
log::debug!("LockedSurface: Configured, starting animation"); log::debug!("LockedSurface: Configured, starting animation");
self.configured = true; self.configured = true;
self.start_time = Instant::now(); self.start_time = Instant::now();
self.dirty = true;
} }
/// Check if this surface matches the given Wayland surface /// Check if this surface matches the given Wayland surface
@@ -72,22 +87,50 @@ impl LockedSurface {
.is_some_and(|ws| ws.id() == surface.id()) .is_some_and(|ws| ws.id() == surface.id())
} }
/// Update the surface state (called on each frame) /// Update the surface state (called on each frame). Returns `true` if the
pub fn update(&mut self) { /// surface was re-rendered and therefore needs to be committed. An idle
self.input_handler.update(); /// surface (no input, no animation, same clock minute) returns `false` and
/// does no cairo work, which keeps a locked session near-zero CPU.
pub fn update(&mut self) -> bool {
if !self.configured { if !self.configured {
return; return false;
} }
// Update fade animation // Update fade animation
if self.fade_alpha < 1.0 { if self.fade_alpha < 1.0 {
let elapsed = self.start_time.elapsed(); let elapsed = self.start_time.elapsed();
let fade_duration = std::time::Duration::from_secs_f32(self.config.fade_in); let fade_duration = std::time::Duration::from_secs_f32(self.config.fade_in);
let new_alpha = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).min(1.0);
if (new_alpha - self.fade_alpha).abs() > 0.001 { if fade_duration.is_zero() {
self.fade_alpha = 1.0;
self.renderer.set_fade_alpha(1.0);
self.dirty = true;
} else {
// Ease-in-out cubic function
let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0);
let eased_t = if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
};
let new_alpha = eased_t.min(1.0);
if t >= 1.0 {
// The eased curve only approaches 1.0 asymptotically, and the
// 0.001 throttle below suppresses the tiny final steps — which
// would leave fade_alpha stuck just under 1.0 forever. Since
// `fade_alpha < 1.0` is our "still animating" signal, that would
// force a full render every frame. Snap to exactly 1.0 once the
// fade duration has elapsed so the animation cleanly completes.
if self.fade_alpha != 1.0 {
self.fade_alpha = 1.0;
self.renderer.set_fade_alpha(1.0);
self.dirty = true;
}
} else if (new_alpha - self.fade_alpha).abs() > 0.001 {
self.fade_alpha = new_alpha; self.fade_alpha = new_alpha;
self.renderer.set_fade_alpha(self.fade_alpha); self.renderer.set_fade_alpha(self.fade_alpha);
self.dirty = true;
}
} }
} }
@@ -95,6 +138,7 @@ impl LockedSurface {
if self.input_handler.should_show_wrong_password() && !self.wrong_password_shown { if self.input_handler.should_show_wrong_password() && !self.wrong_password_shown {
self.renderer.show_wrong_password(); self.renderer.show_wrong_password();
self.wrong_password_shown = true; self.wrong_password_shown = true;
self.dirty = true;
} else if !self.input_handler.should_show_wrong_password() && self.wrong_password_shown { } else if !self.input_handler.should_show_wrong_password() && self.wrong_password_shown {
self.wrong_password_shown = false; self.wrong_password_shown = false;
} }
@@ -103,25 +147,58 @@ impl LockedSurface {
if self.input_handler.should_show_key_highlight() && !self.key_highlight_shown { if self.input_handler.should_show_key_highlight() && !self.key_highlight_shown {
self.renderer.show_key_highlight(); self.renderer.show_key_highlight();
self.key_highlight_shown = true; self.key_highlight_shown = true;
self.dirty = true;
} else if !self.input_handler.should_show_key_highlight() && self.key_highlight_shown { } else if !self.input_handler.should_show_key_highlight() && self.key_highlight_shown {
self.key_highlight_shown = false; self.key_highlight_shown = false;
} }
// Update caps lock state in renderer // Update caps lock state in renderer
if self.renderer.caps_lock != self.input_handler.caps_lock() {
self.renderer.caps_lock = self.input_handler.caps_lock(); self.renderer.caps_lock = self.input_handler.caps_lock();
self.dirty = true;
}
// Set background if available and not already applied // Set background if available and not already applied
if !self.background_applied { if !self.background_applied {
if let Some(ref background) = self.background { if let Some(ref background) = self.background {
log::info!("Applying background image to renderer"); log::debug!("Applying background image to renderer");
self.renderer.set_background(background.clone()); self.renderer.set_background(background.clone());
self.background_applied = true; self.background_applied = true;
self.dirty = true;
} }
} }
// The clock displays %H:%M, so it only needs a redraw once per minute.
if self.config.clock {
let minute = chrono::Local::now().timestamp().div_euclid(60);
if self.last_minute != minute {
self.last_minute = minute;
self.dirty = true;
}
}
// Keep emitting frames while an animation is in flight so it can run to
// completion even though no new event arrives.
let animating = self.fade_alpha < 1.0 || self.renderer.is_animating();
if !self.dirty && !animating {
return false;
}
if !self.config.hide_password {
let buf = self.input_handler.password_buffer();
let length = self.input_handler.password_length();
if self.ctrl_held || self.peek_toggled {
self.renderer.peek_password(buf.as_str());
} else {
self.renderer.set_password_display(length);
}
}
self.renderer self.renderer
.set_password_display(self.input_handler.password_length()); .set_cursor_position(self.input_handler.cursor_position());
self.renderer.render(); self.renderer.render();
self.dirty = false;
true
} }
/// Commit the rendered frame to the Wayland surface /// Commit the rendered frame to the Wayland surface
@@ -154,21 +231,36 @@ impl LockedSurface {
} }
self.renderer.resize(width, height); self.renderer.resize(width, height);
self.background_applied = false; self.background_applied = false;
self.dirty = true;
} }
pub fn show_wrong_password(&mut self) { pub fn show_wrong_password(&mut self) {
self.renderer.clear_verifying();
self.input_handler.set_wrong_password_feedback(); self.input_handler.set_wrong_password_feedback();
self.wrong_password_shown = false;
self.dirty = true;
}
pub fn show_verifying(&mut self) {
self.renderer.show_verifying();
self.dirty = true;
} }
pub fn handle_key_event( pub fn handle_key_event(
&mut self, &mut self,
event: smithay_client_toolkit::seat::keyboard::KeyEvent, event: KeyEvent,
modifiers: smithay_client_toolkit::seat::keyboard::Modifiers, modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
) -> Option<InputAction> { ) -> Option<InputAction> {
self.ctrl_held = modifiers.ctrl;
let action = self let action = self
.input_handler .input_handler
.handle_key_event(event.keysym, event.utf8, modifiers); .handle_key_event(event.keysym, event.utf8, modifiers);
// Any key event may change the password display, cursor or caps state,
// so request a redraw on the next update().
self.dirty = true;
match action { match action {
InputAction::PasswordChanged => { InputAction::PasswordChanged => {
self.input_handler.set_key_highlight(); self.input_handler.set_key_highlight();
@@ -198,10 +290,30 @@ impl LockedSurface {
pub fn set_background(&mut self, surface: ImageSurface) { pub fn set_background(&mut self, surface: ImageSurface) {
self.background = Some(surface); self.background = Some(surface);
self.background_applied = false; self.background_applied = false;
self.dirty = true;
} }
pub fn set_system_status(&mut self, status: SystemStatus) { pub fn set_system_status(&mut self, status: SystemStatus) {
if self.renderer.system_status != status {
self.renderer.system_status = status; self.renderer.system_status = status;
self.dirty = true;
}
}
pub fn set_ctrl_held(&mut self, held: bool) {
if self.ctrl_held != held {
self.ctrl_held = held;
self.dirty = true;
}
}
/// Set peek mode on/off. Called when the user presses (held=true) or
/// releases (held=false) the mouse button on the indicator ring. Works
/// like Ctrl-hold — peek only lasts while the button is held.
pub fn set_peek_held(&mut self, held: bool) {
self.peek_toggled = held;
log::debug!("set_peek_held: peek_toggled = {}", self.peek_toggled);
self.dirty = true;
} }
} }
@@ -228,12 +340,6 @@ impl LockManager {
} }
} }
pub fn update(&mut self) {
for surface in &mut self.surfaces {
surface.update();
}
}
pub fn surface_count(&self) -> usize { pub fn surface_count(&self) -> usize {
self.surfaces.len() self.surfaces.len()
} }
@@ -259,12 +365,24 @@ impl LockManager {
let mut action = None; let mut action = None;
for surface in &mut self.surfaces { for surface in &mut self.surfaces {
if let Some(a) = surface.handle_key_event(event.clone(), modifiers) { if let Some(a) = surface.handle_key_event(event.clone(), modifiers) {
if let crate::input::InputAction::SubmitPassword(p) = &a {
if !p.is_empty() {
return Some(a);
}
} else {
action = Some(a); action = Some(a);
} }
} }
}
action action
} }
pub fn set_ctrl_held(&mut self, held: bool) {
for surface in &mut self.surfaces {
surface.set_ctrl_held(held);
}
}
pub fn remove_surface_by_output(&mut self, output: &wl_output::WlOutput) -> Option<usize> { pub fn remove_surface_by_output(&mut self, output: &wl_output::WlOutput) -> Option<usize> {
use wayland_client::Proxy; use wayland_client::Proxy;
let output_id = Proxy::id(output); let output_id = Proxy::id(output);
+211 -52
View File
@@ -1,5 +1,6 @@
mod auth; mod auth;
mod config; mod config;
mod effects;
mod input; mod input;
mod lock; mod lock;
mod render; mod render;
@@ -48,21 +49,39 @@ use smithay_client_toolkit::{
static FILE_LOGGER: std::sync::LazyLock<std::sync::Mutex<Option<std::fs::File>>> = static FILE_LOGGER: std::sync::LazyLock<std::sync::Mutex<Option<std::fs::File>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(None)); std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
fn setup_file_logging(_config: &Config) { fn setup_file_logging(config: &Config) {
let log_path = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) + "/.rustlock.log"; if let Some(ref path) = config.log_path {
match OpenOptions::new() match OpenOptions::new()
.create(true) .create(true)
.write(true) .write(true)
.truncate(true) .truncate(true)
.open(&log_path) .open(path)
{ {
Ok(file) => { Ok(file) => {
*FILE_LOGGER.lock().unwrap() = Some(file); *FILE_LOGGER.lock().unwrap() = Some(file);
eprintln!("Logging to: {}", log_path); eprintln!("Logging to: {}", path.display());
} }
Err(e) => { Err(e) => {
eprintln!("Failed to open log file {}: {}", log_path, e); eprintln!("Failed to open log file {}: {}", path.display(), e);
}
}
} else if config.log_file {
let default_path =
std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
.join(".rustlock.log");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&default_path)
{
Ok(file) => {
*FILE_LOGGER.lock().unwrap() = Some(file);
eprintln!("Logging to: {}", default_path.display());
}
Err(e) => {
eprintln!("Failed to open log file {}: {}", default_path.display(), e);
}
} }
} }
} }
@@ -110,7 +129,9 @@ struct WaylandLock {
lock_manager: Arc<Mutex<LockManager>>, lock_manager: Arc<Mutex<LockManager>>,
config: Config, config: Config,
ctrlc_exit: Arc<std::sync::atomic::AtomicBool>, ctrlc_exit: Arc<std::sync::atomic::AtomicBool>,
auth_tx: Option<calloop::channel::Sender<Zeroizing<String>>>, auth_tx: Option<calloop::channel::Sender<(Zeroizing<String>, u64)>>,
auth_seq: u64,
auth_pending_seq: Option<u64>,
compositor_state: CompositorState, compositor_state: CompositorState,
output_state: OutputState, output_state: OutputState,
registry_state: RegistryState, registry_state: RegistryState,
@@ -127,6 +148,7 @@ struct WaylandLock {
exit: bool, exit: bool,
screenshot_manager: Option<ScreenshotManager>, screenshot_manager: Option<ScreenshotManager>,
grace_until: Option<Instant>, grace_until: Option<Instant>,
auth_pending_at: Option<Instant>,
system_manager: Arc<SystemManager>, system_manager: Arc<SystemManager>,
modifiers: Modifiers, modifiers: Modifiers,
current_layout: u32, current_layout: u32,
@@ -134,22 +156,29 @@ struct WaylandLock {
impl WaylandLock { impl WaylandLock {
fn handle_auth_result(&mut self, success: bool) { fn handle_auth_result(&mut self, success: bool) {
// Clear grace period on any auth result // Clear grace period and auth pending on any auth result
self.grace_until = None; self.grace_until = None;
self.auth_pending_at = None;
self.auth_pending_seq = None;
if success { if success {
log::info!("✅ Authentication successful - unlocking session"); log::info!("✅ Authentication successful - unlocking session");
// ext-session-lock-v1 recommends destroying lock surfaces before
// issuing unlock_and_destroy. With multiple outputs Hyprland
// otherwise treats the unlock as a client crash and shows its
// failsafe screen.
self.lock_surfaces.clear();
if let Some(session_lock) = &self.session_lock { if let Some(session_lock) = &self.session_lock {
session_lock.unlock(); session_lock.unlock();
let _ = self.conn.flush(); let _ = self.conn.flush();
self.exit = true; self.exit = true;
log::debug!("Unlock requested - exiting"); log::info!("Unlock requested - exiting");
} else { } else {
log::error!("No session_lock available to unlock!"); log::error!("No session_lock available to unlock!");
self.exit = true; self.exit = true;
} }
} else { } else {
log::warn!("❌ Authentication failed - wrong password"); log::error!("❌ Authentication failed - wrong password");
if let Ok(mut lock_manager) = self.lock_manager.lock() { if let Ok(mut lock_manager) = self.lock_manager.lock() {
for surface in &mut lock_manager.surfaces { for surface in &mut lock_manager.surfaces {
surface.show_wrong_password(); surface.show_wrong_password();
@@ -188,17 +217,17 @@ impl WaylandLock {
} }
Keysym::F1 => { Keysym::F1 => {
self.system_manager self.system_manager
.send_command(system::SystemCommand::Suspend); .send_command(system::BackendCommand::Suspend);
return; return;
} }
Keysym::F2 => { Keysym::F2 => {
self.system_manager self.system_manager
.send_command(system::SystemCommand::Reboot); .send_command(system::BackendCommand::Reboot);
return; return;
} }
Keysym::F3 => { Keysym::F3 => {
self.system_manager self.system_manager
.send_command(system::SystemCommand::PowerOff); .send_command(system::BackendCommand::PowerOff);
return; return;
} }
_ => {} _ => {}
@@ -215,30 +244,44 @@ impl WaylandLock {
} }
if event.keysym == Keysym::Return { if event.keysym == Keysym::Return {
// Debounce: skip if auth is already pending (user pressed Enter twice)
if self.auth_pending_at.is_some() {
return;
}
log::info!("Enter pressed - submitting password"); log::info!("Enter pressed - submitting password");
let password: Option<Zeroizing<String>> = self
.lock_manager
.lock()
.ok()
.and_then(|mut lm| lm.handle_key_event(event, self.modifiers))
.and_then(|action| {
if let InputAction::SubmitPassword(p) = action {
(!p.is_empty()).then_some(p)
} else {
None
}
});
if let Some(password) = password {
self.auth_seq += 1;
self.auth_pending_at = Some(Instant::now());
self.auth_pending_seq = Some(self.auth_seq);
// Show verifying feedback on ALL surfaces BEFORE sending to PAM.
if let Ok(mut lock_manager) = self.lock_manager.lock() { if let Ok(mut lock_manager) = self.lock_manager.lock() {
let mut password = Zeroizing::new(String::new());
let modifiers = self.modifiers;
for surface in &mut lock_manager.surfaces { for surface in &mut lock_manager.surfaces {
if let Some(InputAction::SubmitPassword(p)) = surface.show_verifying();
surface.handle_key_event(event.clone(), modifiers)
{
password = p;
} }
} }
if !password.is_empty() {
if let Some(tx) = &self.auth_tx { if let Some(tx) = &self.auth_tx {
let _ = tx.send(password); let _ = tx.send((password, self.auth_seq));
}
} }
} }
} else { } else {
let modifiers = self.modifiers; self.lock_manager
let _action = self
.lock_manager
.lock() .lock()
.map(|mut lm| lm.handle_key_event(event, modifiers)) .ok()
.unwrap_or(None); .and_then(|mut lm| lm.handle_key_event(event, self.modifiers));
} }
} }
} }
@@ -463,8 +506,19 @@ impl KeyboardHandler for WaylandLock {
modifiers: Modifiers, modifiers: Modifiers,
layout: u32, layout: u32,
) { ) {
let ctrl_changed = self.modifiers.ctrl != modifiers.ctrl;
self.modifiers = modifiers; self.modifiers = modifiers;
self.current_layout = layout; self.current_layout = layout;
if ctrl_changed {
log::debug!(
"update_modifiers: ctrl {} -> {}",
!modifiers.ctrl,
modifiers.ctrl
);
}
if let Ok(mut lock_manager) = self.lock_manager.lock() {
lock_manager.set_ctrl_held(modifiers.ctrl);
}
} }
} }
@@ -547,7 +601,10 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
height, height,
stride, stride,
} => { } => {
let format = format.into_result().unwrap(); let Ok(format) = format.into_result() else {
log::error!("Screencopy: invalid buffer format, skipping capture");
return;
};
let mut info = data.info.lock().unwrap(); let mut info = data.info.lock().unwrap();
*info = Some(screenshot::BufferInfo { *info = Some(screenshot::BufferInfo {
@@ -576,7 +633,11 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
} }
} }
Event::Flags { flags } => { Event::Flags { flags } => {
*data.flags.lock().unwrap() = Some(flags.into_result().unwrap()); if let Ok(f) = flags.into_result() {
*data.flags.lock().unwrap() = Some(f);
} else {
log::error!("Screencopy: invalid flags, skipping");
}
} }
Event::Ready { .. } => { Event::Ready { .. } => {
log::info!("Screencopy: Ready for output {}", data.output_idx); log::info!("Screencopy: Ready for output {}", data.output_idx);
@@ -596,7 +657,12 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
}; };
if let Ok(surface) = mgr.buffer_to_surface(handle, &mut pool) { if let Ok(surface) = mgr.buffer_to_surface(handle, &mut pool) {
let mut ss = Screenshot::new(surface); let mut ss = Screenshot::new(surface);
let _ = ss.apply_effects(&state.config); if let Err(e) = ss.apply_effects(&state.config) {
log::error!(
"Failed to apply effects to screenshot {}: {e}",
data.output_idx
);
}
if data.output_idx < state.captured_backgrounds.len() { if data.output_idx < state.captured_backgrounds.len() {
state.captured_backgrounds[data.output_idx] = Some(ss.into_inner()); state.captured_backgrounds[data.output_idx] = Some(ss.into_inner());
} }
@@ -639,30 +705,73 @@ impl PointerHandler for WaylandLock {
events: &[PointerEvent], events: &[PointerEvent],
) { ) {
for event in events { for event in events {
if let PointerEventKind::Press { button, .. } = event.kind { match event.kind {
if button == 0x110 { PointerEventKind::Press { button: 0x110, .. } => {
let (x, y) = event.position; let (x, y) = event.position;
if let Ok(lm) = self.lock_manager.lock() { if let Ok(lm) = self.lock_manager.lock() {
for surface in &lm.surfaces { for surface in &lm.surfaces {
if surface.matches_surface(&event.surface) { if surface.matches_surface(&event.surface) {
// Check media controls first
let mut handled = false;
for (action, rx, ry, rw, rh) in &surface.renderer.media_rects { for (action, rx, ry, rw, rh) in &surface.renderer.media_rects {
if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh { if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh {
match action.as_str() { match *action {
"play_pause" => self.system_manager.media_play_pause(), "play_pause" => self.system_manager.media_play_pause(),
"stop" => self.system_manager.media_stop(), "stop" => self.system_manager.media_stop(),
"next" => self.system_manager.media_next(), "next" => self.system_manager.media_next(),
"prev" => self.system_manager.media_prev(), "prev" => self.system_manager.media_prev(),
_ => {} _ => {}
} }
handled = true;
break;
}
}
if handled {
return;
}
// Check indicator ring hit — hold to peek (like Ctrl)
let cx = surface.renderer.width as f64 / 2.0;
let cy = surface.renderer.height as f64 / 2.0;
let r = surface.renderer.config.indicator_radius as f64;
let shape = surface.renderer.config.ring_shape;
if crate::render::ring_shape::point_in_shape(cx, cy, r, shape, x, y)
{
drop(lm);
if let Ok(mut lm) = self.lock_manager.lock() {
if let Some(s) = lm
.surfaces
.iter_mut()
.find(|s| s.matches_surface(&event.surface))
{
s.set_peek_held(true);
s.update();
let _ = s.commit(&mut self.pool);
}
}
return; return;
} }
} }
} }
} }
} }
PointerEventKind::Release { button: 0x110, .. } => {
if let Ok(mut lm) = self.lock_manager.lock() {
// Clear peek on ALL surfaces. Wayland button release events
// can reference a different wl_surface proxy than the press
// event (compositor-dependent pointer grab semantics), so
// matching by surface might miss the LockedSurface that has
// peek_toggled=true. Iterating every surface guarantees peek
// always ends on button release — matching Ctrl-hold behavior.
for surface in &mut lm.surfaces {
surface.set_peek_held(false);
surface.update();
let _ = surface.commit(&mut self.pool);
} }
} }
} }
_ => {}
}
}
} }
} }
@@ -678,9 +787,15 @@ wayland_client::delegate_noop!(WaylandLock: ignore wayland_client::protocol::wl_
fn main() -> Result<(), Box<dyn Error>> { fn main() -> Result<(), Box<dyn Error>> {
let config = Config::load(); let config = Config::load();
setup_file_logging(&config); setup_file_logging(&config);
static LOGGER: DualLogger = DualLogger; static LOGGER: DualLogger = DualLogger;
log::set_logger(&LOGGER).map(|()| log::set_max_level(log::LevelFilter::Debug))?; let max_level = if config.debug {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_logger(&LOGGER).map(|()| log::set_max_level(max_level))?;
log::info!("Starting rustlock v{}", env!("CARGO_PKG_VERSION")); log::info!("Starting rustlock v{}", env!("CARGO_PKG_VERSION"));
#[allow(clippy::arc_with_non_send_sync)] #[allow(clippy::arc_with_non_send_sync)]
@@ -694,13 +809,18 @@ fn main() -> Result<(), Box<dyn Error>> {
let shm_state = Shm::bind(&globals, &qh).map_err(|_| "wl_shm not supported")?; let shm_state = Shm::bind(&globals, &qh).map_err(|_| "wl_shm not supported")?;
let system_manager = Arc::new(SystemManager::new()); let system_manager = Arc::new(SystemManager::new(&config));
let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop() { let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop(
config.pam_service.clone(),
) {
Some(channels) => channels, Some(channels) => channels,
None => { None => {
log::error!("Failed to initialize authentication. This usually means PAM is not configured correctly."); log::error!("Failed to initialize authentication. This usually means PAM is not configured correctly.");
log::error!("Please ensure you have a PAM service file at /etc/pam.d/rustlock"); log::error!(
"Please ensure you have a PAM service file at /etc/pam.d/{}",
config.pam_service
);
std::process::exit(1); std::process::exit(1);
} }
}; };
@@ -735,6 +855,9 @@ fn main() -> Result<(), Box<dyn Error>> {
exit: false, exit: false,
screenshot_manager: ScreenshotManager::new(&globals, &qh).ok(), screenshot_manager: ScreenshotManager::new(&globals, &qh).ok(),
grace_until: None, grace_until: None,
auth_pending_at: None,
auth_seq: 0,
auth_pending_seq: None,
system_manager: system_manager.clone(), system_manager: system_manager.clone(),
modifiers: Modifiers::default(), modifiers: Modifiers::default(),
current_layout: 0, current_layout: 0,
@@ -748,31 +871,41 @@ fn main() -> Result<(), Box<dyn Error>> {
if let Ok(img) = image::open(image_path) { if let Ok(img) = image::open(image_path) {
let img = img.to_rgba8(); let img = img.to_rgba8();
let (w, h) = img.dimensions(); let (w, h) = img.dimensions();
let mut surface = if let Ok(mut surface) =
cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32).unwrap(); cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32)
{ {
let mut surface_data = surface.data().unwrap(); let image_ok = {
if let Ok(mut surface_data) = surface.data() {
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
let pixel = img.get_pixel(x, y); let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize; let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2]; // B surface_data[idx] = pixel[2];
surface_data[idx + 1] = pixel[1]; // G surface_data[idx + 1] = pixel[1];
surface_data[idx + 2] = pixel[0]; // R surface_data[idx + 2] = pixel[0];
surface_data[idx + 3] = pixel[3]; // A surface_data[idx + 3] = pixel[3];
} }
} }
true
} else {
log::error!("Failed to get background image surface data");
false
} }
};
if image_ok {
let mut ss = Screenshot::new(surface); let mut ss = Screenshot::new(surface);
let _ = ss.apply_effects(&state.config); if let Err(e) = ss.apply_effects(&state.config) {
log::error!("Failed to apply effects to custom background image: {e}");
}
let surface = ss.into_inner(); let surface = ss.into_inner();
let num_outputs = state.output_state.outputs().count(); let num_outputs = state.output_state.outputs().count();
state.captured_backgrounds = vec![Some(surface); num_outputs]; state.captured_backgrounds = vec![Some(surface); num_outputs];
// Disable screenshots if image was successfully loaded
state.config.screenshots = false; state.config.screenshots = false;
}
} else {
log::error!("Failed to create Cairo surface for background image");
}
} else { } else {
log::error!( log::error!(
"Failed to load custom background image from {:?}", "Failed to load custom background image from {:?}",
@@ -806,10 +939,10 @@ fn main() -> Result<(), Box<dyn Error>> {
event_loop event_loop
.handle() .handle()
.insert_source(auth_feedback_rx_actual, |event, _, state| { .insert_source(auth_feedback_rx_actual, |event, _, state| {
if let calloop::channel::Event::Msg(success) = event { if let calloop::channel::Event::Msg((success, seq)) = event {
// Ignore stale auth results from previous requests (e.g. after timeout or retry).
if state.auth_pending_seq == Some(seq) {
state.handle_auth_result(success); state.handle_auth_result(success);
if success {
state.lock_surfaces.clear();
} }
} }
})?; })?;
@@ -823,17 +956,37 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
} }
// Auth timeout: if PAM thread doesn't respond within config.auth_timeout ms,
// treat as auth failure so the user gets feedback instead of hanging forever.
if state.auth_pending_seq.is_some() {
if let Some(at) = state.auth_pending_at {
if Instant::now().duration_since(at)
>= Duration::from_millis(state.config.auth_timeout)
{
log::warn!(
"Authentication timed out after {} ms",
state.config.auth_timeout
);
// Clear pending seq so the eventual PAM result is ignored as stale
state.auth_pending_seq = None;
state.handle_auth_result(false);
}
}
}
let mut status = state.system_manager.get_status(); let mut status = state.system_manager.get_status();
status.keyboard_layout = Some(state.current_layout.to_string()); status.keyboard_layout = Some(state.current_layout.to_string());
if let Ok(mut lm) = state.lock_manager.lock() { if let Ok(mut lm) = state.lock_manager.lock() {
lm.set_system_status(status); lm.set_system_status(status);
lm.update(); // Only commit surfaces that actually re-rendered this tick. An idle
// lock screen renders nothing and commits nothing.
for surface in &mut lm.surfaces { for surface in &mut lm.surfaces {
if surface.update() {
let _ = surface.commit(&mut state.pool); let _ = surface.commit(&mut state.pool);
} }
} }
}
if state.exit { if state.exit {
calloop::timer::TimeoutAction::Drop calloop::timer::TimeoutAction::Drop
} else { } else {
@@ -907,6 +1060,12 @@ fn main() -> Result<(), Box<dyn Error>> {
event_loop.dispatch(Duration::from_millis(16), &mut state)?; event_loop.dispatch(Duration::from_millis(16), &mut state)?;
} }
// After unlock_and_destroy the compositor sends keyboard/pointer leave and
// delete_id events that must be processed before we disconnect, otherwise
// Hyprland treats the disconnect as a client crash and shows its failsafe
// screen (only reproducible on multi-output setups).
let _ = state.conn.roundtrip();
log::info!("Exiting rustlock"); log::info!("Exiting rustlock");
Ok(()) Ok(())
} }
-1286
View File
File diff suppressed because it is too large Load Diff
+247
View File
@@ -0,0 +1,247 @@
use crate::render::ring_shape;
use crate::render::Renderer;
use std::time::Instant;
impl Renderer {
pub(crate) fn draw_verifying_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let (r, g, b, a) = self.config.verifying_color;
if a > 0.0 {
self.context.new_path();
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness + 2.0);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_wrong_password_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.wrong_password_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(self.config.wrong_password_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
0.0
}
} else {
0.0
};
if intensity > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
self.context.set_line_width(thickness + 2.0);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_key_highlight_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.key_highlight_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(self.config.key_highlight_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
0.0
}
} else {
0.0
};
if intensity > 0.0 {
let (r, g, b, a) = if self.caps_lock {
self.config.caps_lock_key_hl_color
} else {
self.config.key_hl_color
};
self.context
.set_source_rgba(r, g, b, a * intensity * self.fade_alpha);
self.context.set_line_width(thickness + 1.5);
self.context.new_path();
self.context.set_line_cap(cairo::LineCap::Round);
// Convert angle range to normalized perimeter t (for circle: t = angle / 2π)
let max_dots = self.config.max_dots as f64;
let t_offset = ring_shape::top_centre_offset(self.config.ring_shape);
let global_t = ((self.password_display.len() as f64) / max_dots) + t_offset;
let random_t = self.key_highlight_angle / (2.0 * std::f64::consts::PI);
let t_start = global_t + random_t;
let sector_t = 40.0 / 360.0;
let t_end = t_start + sector_t;
ring_shape::build_sector_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
t_start,
t_end,
);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_cleared_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.cleared_feedback_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(self.config.cleared_feedback_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
0.0
}
} else {
0.0
};
if intensity > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha * 0.5);
ring_shape::build_fill_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
thickness,
self.config.ring_shape,
);
render_try!(self.context.fill());
self.context.new_path();
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
self.context.set_line_width(thickness + 4.0);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
self.context.new_path();
self.context.set_font_size(24.0);
self.context
.set_source_rgba(1.0, 1.0, 1.0, intensity * self.fade_alpha);
let text = "CLEARED";
let te = render_try!(self.context.text_extents(text));
self.context
.move_to(center_x - te.width() / 2.0, center_y - radius - 20.0);
render_try!(self.context.show_text(text));
}
}
/// Whether any feedback animation is currently in flight and therefore
/// requires continued per-frame redraws until it finishes.
pub(crate) fn is_animating(&self) -> bool {
self.wrong_password_start.is_some()
|| self.key_highlight_start.is_some()
|| self.cleared_feedback_start.is_some()
|| self.verifying_start.is_some()
}
pub(crate) fn update_feedback_timers(&mut self) {
self.update_uptime();
if let Some(start) = self.wrong_password_start {
if start.elapsed()
> std::time::Duration::from_millis(self.config.wrong_password_duration)
{
self.wrong_password_shown = false;
self.wrong_password_start = None;
}
}
if let Some(start) = self.key_highlight_start {
if start.elapsed()
> std::time::Duration::from_millis(self.config.key_highlight_duration)
{
self.key_highlight_shown = false;
self.key_highlight_start = None;
}
}
if let Some(start) = self.cleared_feedback_start {
if start.elapsed()
> std::time::Duration::from_millis(self.config.cleared_feedback_duration)
{
self.cleared_feedback_shown = false;
self.cleared_feedback_start = None;
}
}
if let Some(start) = self.verifying_start {
if start.elapsed() > std::time::Duration::from_millis(self.config.auth_timeout) {
self.verifying_shown = false;
self.verifying_start = None;
}
}
}
pub fn show_wrong_password(&mut self) {
self.wrong_password_shown = true;
self.wrong_password_start = Some(Instant::now());
// Clear verifying state — wrong password replaces it
self.verifying_shown = false;
self.verifying_start = None;
}
pub fn show_key_highlight(&mut self) {
self.key_highlight_shown = true;
self.key_highlight_start = Some(Instant::now());
use std::time::SystemTime;
let seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let random_val = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
self.key_highlight_angle = ((random_val % 360) as f64).to_radians();
}
pub fn show_cleared_feedback(&mut self) {
self.cleared_feedback_shown = true;
self.cleared_feedback_start = Some(Instant::now());
}
pub fn show_verifying(&mut self) {
self.verifying_shown = true;
self.verifying_start = Some(Instant::now());
}
pub fn clear_verifying(&mut self) {
self.verifying_shown = false;
self.verifying_start = None;
}
}
+165
View File
@@ -0,0 +1,165 @@
use crate::render::ring_shape;
use crate::render::Renderer;
impl Renderer {
pub(crate) fn draw_indicator(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape;
// Filled center
self.context.new_path();
let (r, g, b, a) = self.config.inside_color;
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
ring_shape::build_fill_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
thickness,
shape,
);
render_try!(self.context.fill());
// Separator line behind the ring
let (lr, lg, lb, la) = self.config.line_color;
if la > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(lr, lg, lb, la * self.fade_alpha);
self.context.set_line_width(1.0);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
shape,
);
render_try!(self.context.stroke());
}
// Outer ring
let (r, g, b, a) = if self.caps_lock {
self.config.caps_lock_color
} else {
self.config.ring_color
};
self.context.new_path();
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(&self.context, center_x, center_y, radius, shape);
render_try!(self.context.stroke());
// Separator line through center
let (r, g, b, a) = self.config.separator_color;
if a > 0.0 {
self.context.new_path();
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(1.0);
self.context.move_to(center_x - radius, center_y);
self.context.line_to(center_x + radius, center_y);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_password_display(&self) {
if self.config.hide_password {
return;
}
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape;
let max_dots = self.config.max_dots as f64;
let dot_radius = radius - thickness - 10.0;
let t_offset = ring_shape::top_centre_offset(shape);
let count = self.password_display.len();
if count == 0 {
return;
}
if self.peeking {
// Draw each character at the same ring-perimeter positions as dots
self.context.set_font_size(14.0);
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for (i, ch) in self.password_display.chars().enumerate() {
let t = (i as f64 / max_dots) + t_offset;
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
let mut ch_str = String::new();
ch_str.push(ch);
let te = render_try!(self.context.text_extents(&ch_str));
self.context.new_path();
self.context.move_to(
x - te.x_bearing() - te.width() / 2.0,
y - te.y_bearing() - te.height() / 2.0,
);
render_try!(self.context.show_text(&ch_str));
}
} else {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for i in 0..count {
let t = (i as f64 / max_dots) + t_offset;
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
self.context.new_path();
self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI);
render_try!(self.context.fill());
}
}
// Cursor indicator (shared between peek and dot modes).
if self.fade_alpha > 0.0 {
// At position 0, place the cursor squarely at the first-dot position
// (t_offset) instead of subtracting 0.5, which would push t negative
// and cause perimeter_point to wrap it to the far end of the ring.
let cursor_t = if self.cursor_position == 0 {
t_offset
} else {
((self.cursor_position as f64 - 0.5) / max_dots) + t_offset
};
let (cx, cy) =
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
let dx = cx - center_x;
let dy = cy - center_y;
let len = (dx * dx + dy * dy).sqrt().max(1.0);
let nx = dx / len;
let ny = dy / len;
let x1 = cx - 8.0 * nx;
let y1 = cy - 8.0 * ny;
let x2 = cx + 8.0 * nx;
let y2 = cy + 8.0 * ny;
self.context.new_path();
self.context.set_source_rgba(0.0, 0.8, 1.0, self.fade_alpha);
self.context.set_line_width(2.0);
self.context.move_to(x1, y1);
self.context.line_to(x2, y2);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_caps_lock_indicator(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
self.context.new_path();
let (r, g, b, a) = self.config.caps_lock_text_color;
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_font_size(24.0);
let text = "Caps Lock";
let te = render_try!(self.context.text_extents(text));
self.context
.move_to(center_x - te.width() / 2.0, center_y - radius - 10.0);
render_try!(self.context.show_text(text));
}
}
+134
View File
@@ -0,0 +1,134 @@
use crate::render::Renderer;
use cairo::{Format, ImageSurface};
impl Renderer {
pub(crate) fn draw_media(&mut self) {
if let Some(ref title) = self.system_status.media_title {
let center_x = self.width as f64 / 2.0;
let start_y = self.height as f64 - 120.0;
let art_size = 56.0;
if self.config.show_album_art && self.system_status.media_art_url != self.last_art_url {
self.last_art_url = self.system_status.media_art_url.clone();
self.media_art_surface = None;
if let Some(ref data) = self.system_status.media_art_data {
if let Ok(img) = image::load_from_memory(data) {
let img = img.to_rgba8();
let (w, h) = img.dimensions();
if let Ok(mut surface) =
ImageSurface::create(Format::ARgb32, w as i32, h as i32)
{
if let Ok(mut surface_data) = surface.data() {
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2];
surface_data[idx + 1] = pixel[1];
surface_data[idx + 2] = pixel[0];
surface_data[idx + 3] = pixel[3];
}
}
} else {
log::error!("Failed to access album art surface data");
}
self.media_art_surface = Some(surface);
} else {
log::error!("Failed to create album art surface");
}
}
}
}
let has_art = self.config.show_album_art && self.media_art_surface.is_some();
self.context.new_path();
self.context
.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * 0.9);
self.context.set_font_size(16.0);
let display_text = if let Some(ref artist) = self.system_status.media_artist {
format!("{} - {}", artist, title)
} else {
title.clone()
};
let te = render_try!(self.context.text_extents(&display_text));
// Center art + text as a group with 16px gap between them
let art_text_gap = 16.0;
let group_width = te.width() + art_size + art_text_gap;
let group_start_x = center_x - group_width / 2.0;
let art_x = group_start_x;
let text_center_x = art_x + art_size + art_text_gap + te.width() / 2.0;
if has_art {
if let Some(ref art) = self.media_art_surface {
render_try!(self.context.save());
let scale = art_size / art.width() as f64;
self.context.translate(art_x, start_y);
self.context.scale(scale, scale);
render_try!(self.context.set_source_surface(art, 0.0, 0.0));
render_try!(self.context.paint_with_alpha(self.fade_alpha));
render_try!(self.context.restore());
}
}
self.context
.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
render_try!(self.context.show_text(&display_text));
// All media buttons on one row, evenly spaced.
// Each gets a 24×24 hit area, matching draw_icon_at's target_size.
let btn_size = 24.0;
let btn_gap = 48.0;
let btn_y = start_y + 50.0;
// Layout: prev | play_pause | next (centered as a group)
let total_buttons: f64 = (self.media_prev_icon_surface.is_some() as u32
+ 1
+ self.media_next_icon_surface.is_some() as u32)
as f64;
let group_width = (total_buttons - 1.0) * btn_gap + btn_size;
let group_start_x = center_x - group_width / 2.0;
let mut btn_x = group_start_x;
if let Some(ref icon) = self.media_prev_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects
.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
btn_x += btn_gap;
}
// Play/pause — always present (at least one of play/pause icon should load)
if self.system_status.media_playing {
if let Some(ref icon) = self.media_pause_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
}
} else if let Some(ref icon) = self.media_play_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
}
btn_x += btn_gap;
if let Some(ref icon) = self.media_next_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects
.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
}
}
}
}
+256
View File
@@ -0,0 +1,256 @@
use cairo::{Context, Format, ImageSurface};
use std::time::Instant;
use crate::config::Config;
use crate::system::SystemStatus;
/// Log cairo errors and return early instead of propagating panics.
/// Defined once here and available to all render submodules.
macro_rules! render_try {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(e) => {
log::error!("cairo error: {:?}", e);
return;
}
}
};
}
mod feedback;
mod indicator;
mod media_bar;
pub(crate) mod ring_shape;
mod status_bar;
pub struct Renderer {
pub(crate) width: i32,
pub(crate) height: i32,
pub(crate) config: Config,
pub(crate) surface: ImageSurface,
pub(crate) context: Context,
pub(crate) fade_alpha: f64,
pub(crate) wrong_password_shown: bool,
pub(crate) key_highlight_shown: bool,
pub(crate) cleared_feedback_shown: bool,
pub(crate) verifying_shown: bool,
pub(crate) wrong_password_start: Option<Instant>,
pub(crate) key_highlight_start: Option<Instant>,
pub(crate) cleared_feedback_start: Option<Instant>,
pub(crate) verifying_start: Option<Instant>,
pub(crate) key_highlight_angle: f64,
pub(crate) background: Option<ImageSurface>,
pub(crate) password_display: String,
pub(crate) peeking: bool,
pub(crate) cursor_position: usize,
pub(crate) uptime_cache: String,
pub(crate) last_uptime_update: Option<Instant>,
pub caps_lock: bool,
pub system_status: SystemStatus,
pub(crate) media_art_surface: Option<ImageSurface>,
pub(crate) last_art_url: Option<String>,
pub(crate) wifi_icon_surface: Option<ImageSurface>,
pub(crate) bluetooth_icon_surface: Option<ImageSurface>,
pub(crate) battery_icon_surface: Option<ImageSurface>,
pub(crate) media_prev_icon_surface: Option<ImageSurface>,
pub(crate) media_stop_icon_surface: Option<ImageSurface>,
pub(crate) media_play_icon_surface: Option<ImageSurface>,
pub(crate) media_pause_icon_surface: Option<ImageSurface>,
pub(crate) media_next_icon_surface: Option<ImageSurface>,
pub media_rects: Vec<(&'static str, f64, f64, f64, f64)>,
}
impl Renderer {
pub fn new(width: i32, height: i32, config: Config) -> Self {
log::debug!("Renderer::new({}, {}, ...) called", width, height);
let surface = ImageSurface::create(Format::ARgb32, width, height)
.expect("Failed to create Cairo surface");
let context = Context::new(&surface).expect("Failed to create Cairo context");
let mut renderer = Self {
width,
height,
config: config.clone(),
surface,
context,
fade_alpha: 0.0,
wrong_password_shown: false,
key_highlight_shown: false,
cleared_feedback_shown: false,
verifying_shown: false,
wrong_password_start: None,
key_highlight_start: None,
cleared_feedback_start: None,
verifying_start: None,
key_highlight_angle: 0.0,
background: None,
password_display: String::new(),
peeking: false,
cursor_position: 0,
uptime_cache: String::new(),
last_uptime_update: None,
caps_lock: false,
system_status: SystemStatus::default(),
media_art_surface: None,
last_art_url: None,
wifi_icon_surface: None,
bluetooth_icon_surface: None,
battery_icon_surface: None,
media_prev_icon_surface: None,
media_stop_icon_surface: None,
media_play_icon_surface: None,
media_pause_icon_surface: None,
media_next_icon_surface: None,
media_rects: Vec::new(),
};
renderer.load_icons();
renderer
}
pub fn resize(&mut self, width: i32, height: i32) {
log::debug!("Renderer::resize({}, {}) called", width, height);
self.width = width;
self.height = height;
self.surface = ImageSurface::create(Format::ARgb32, width, height)
.expect("Failed to create Cairo surface");
self.context = Context::new(&self.surface).expect("Failed to create Cairo context");
}
pub fn set_background(&mut self, background: ImageSurface) {
self.background = Some(background);
}
pub fn set_fade_alpha(&mut self, alpha: f64) {
self.fade_alpha = alpha.clamp(0.0, 1.0);
}
pub fn set_password_display(&mut self, length: usize) {
self.password_display = ".".repeat(length);
self.peeking = false;
}
pub fn peek_password(&mut self, password: &str) {
self.password_display = password.to_string();
self.peeking = true;
}
pub fn set_cursor_position(&mut self, position: usize) {
self.cursor_position = position;
}
pub fn get_pixel_data(&self) -> Result<Vec<u8>, cairo::BorrowError> {
let stride = self.surface.stride() as usize;
let height = self.height as usize;
let mut data = vec![0u8; stride * height];
self.surface.with_data(|src| {
data.copy_from_slice(src);
})?;
Ok(data)
}
pub fn surface_info(&self) -> (i32, i32, i32) {
(self.width, self.height, self.surface.stride())
}
pub fn render(&mut self) {
self.media_rects.clear();
self.context.new_path();
self.context.set_source_rgba(0.0, 0.0, 0.0, 1.0);
self.context.paint().expect("Failed to clear surface");
if let Some(ref background) = self.background {
self.context.save().expect("Failed to save context");
let bg_width = background.width() as f64;
let bg_height = background.height() as f64;
let scale_x = self.width as f64 / bg_width;
let scale_y = self.height as f64 / bg_height;
let scale = scale_x.max(scale_y);
let offset_x = (self.width as f64 - bg_width * scale) / 2.0;
let offset_y = (self.height as f64 - bg_height * scale) / 2.0;
self.context.translate(offset_x, offset_y);
self.context.scale(scale, scale);
self.context.new_path();
self.context
.set_source_surface(background, 0.0, 0.0)
.expect("Failed to set source");
self.context
.paint_with_alpha(self.fade_alpha)
.expect("Failed to paint");
self.context.restore().expect("Failed to restore context");
}
if self.config.indicator {
self.draw_indicator();
}
if self.config.clock {
self.draw_clock();
}
if self.config.show_media {
self.draw_media();
}
if self.config.show_network {
self.draw_network();
}
if self.config.show_battery {
self.draw_status();
}
if self.config.show_bluetooth {
self.draw_bluetooth();
}
if self.config.show_keyboard_layout {
self.draw_keyboard_layout();
}
if !self.password_display.is_empty() {
self.draw_password_display();
}
if self.caps_lock && self.config.show_caps_lock_text {
self.draw_caps_lock_indicator();
}
if self.verifying_shown {
self.draw_verifying_feedback();
}
if self.wrong_password_shown {
self.draw_wrong_password_feedback();
}
if self.key_highlight_shown {
self.draw_key_highlight_feedback();
}
if self.cleared_feedback_shown {
self.draw_cleared_feedback();
}
self.update_feedback_timers();
}
fn update_uptime(&mut self) {
let now = Instant::now();
if let Some(last) = self.last_uptime_update {
if now.duration_since(last).as_secs() < 10 {
return;
}
}
let uptime_secs = std::fs::read_to_string("/proc/uptime")
.ok()
.and_then(|s| s.split_whitespace().next()?.parse::<f64>().ok())
.unwrap_or(0.0) as u64;
self.uptime_cache = format!("up {}h {}m", uptime_secs / 3600, (uptime_secs % 3600) / 60);
self.last_uptime_update = Some(now);
}
}
+257
View File
@@ -0,0 +1,257 @@
use cairo::Context;
use crate::config::RingShape;
/// Number of linear segments used to approximate curved portions of a shape.
/// Higher = smoother, lower = faster.
const SEGMENTS: usize = 120;
/// Return the (x, y) point on the shape's perimeter at normalized position `t` ∈ [0, 1].
/// `t = 0` is a reference point (rightmost for most shapes); `t` increases clockwise.
/// `r` is the shape's characteristic radius (distance from center to side/vertex).
pub(crate) fn perimeter_point(cx: f64, cy: f64, r: f64, shape: RingShape, t: f64) -> (f64, f64) {
// Normalize t to [0, 1). Rust's % preserves sign, and the shape-specific
// functions use floor/truncation that break on negative values.
let t = t - t.floor();
match shape {
RingShape::Circle => {
let angle = t * 2.0 * std::f64::consts::PI;
(cx + r * angle.cos(), cy + r * angle.sin())
}
RingShape::Square => square_perimeter_point(cx, cy, r, t),
RingShape::Diamond => diamond_perimeter_point(cx, cy, r, t),
RingShape::Hexagon => hexagon_perimeter_point(cx, cy, r, t),
RingShape::Pill => pill_perimeter_point(cx, cy, r, t),
}
}
/// Right-top-right-bottom-left-bottom-left-top order (clockwise from right).
fn square_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 4.0).floor() as u32;
let local = t * 4.0 - side as f64;
match side {
0 => {
// Right side: top → bottom
(cx + r, cy - r + local * 2.0 * r)
}
1 => {
// Bottom side: right → left
(cx + r - local * 2.0 * r, cy + r)
}
2 => {
// Left side: bottom → top
(cx - r, cy + r - local * 2.0 * r)
}
_ => {
// Top side: left → right
(cx - r + local * 2.0 * r, cy - r)
}
}
}
/// Right-bottom-left-top order (clockwise from right).
fn diamond_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 4.0).floor() as u32;
let local = t * 4.0 - side as f64;
match side {
0 => {
// Right to bottom
(cx + r - local * r, cy + local * r)
}
1 => {
// Bottom to left
(cx - local * r, cy + r - local * r)
}
2 => {
// Left to top
(cx - r + local * r, cy - local * r)
}
_ => {
// Top to right
(cx + local * r, cy - r + local * r)
}
}
}
/// 0: right → bottom-right (vertex to vertex)
/// 1: bottom edge (right → left)
/// 2: bottom-left → left
/// 3: left → top-left
/// 4: top edge (left → right)
/// 5: top-right → right
fn hexagon_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 6.0).floor() as u32;
let local = t * 6.0 - side as f64;
// Shared helper for edges between two vertices
let vert =
|angle_rad: f64| -> (f64, f64) { (cx + r * angle_rad.cos(), cy + r * angle_rad.sin()) };
// Vertices clockwise from right (angle = 0)
let v = [
vert(0.0), // V0: right
vert(std::f64::consts::PI * (1.0 / 3.0)), // V1: bottom-right
vert(std::f64::consts::PI * (2.0 / 3.0)), // V2: bottom-left
vert(std::f64::consts::PI), // V3: left
vert(std::f64::consts::PI * (4.0 / 3.0)), // V4: top-left
vert(std::f64::consts::PI * (5.0 / 3.0)), // V5: top-right
];
let (x0, y0) = v[side as usize];
let (x1, y1) = v[((side + 1) % 6) as usize];
(x0 + local * (x1 - x0), y0 + local * (y1 - y0))
}
/// Clockwise from top-right corner: right cap (downward) → bottom straight
/// (leftward) → left cap (upward) → top straight (rightward).
///
/// The pill is a stadium / capsule: cap radius = r, straight-section length = 2r,
/// total width = 4r, total height = 2r.
fn pill_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let total_p = 4.0 + 2.0 * std::f64::consts::PI; // 4r + 2πr, normalised by r
let straights = 2.0 / total_p; // each straight segment's t fraction
let caps = std::f64::consts::PI / total_p; // each cap's t fraction
if t < caps {
// Right cap: semicircle, top → bottom, centred at (r, 0)
let local = t / caps;
let angle = -std::f64::consts::PI / 2.0 + local * std::f64::consts::PI;
(cx + r + r * angle.cos(), cy + r * angle.sin())
} else if t < caps + straights {
// Bottom straight: right → left
let local = (t - caps) / straights;
(cx + r - local * 2.0 * r, cy + r)
} else if t < caps + straights + caps {
// Left cap: semicircle, bottom → top, centred at (-r, 0)
let local = (t - caps - straights) / caps;
let angle = std::f64::consts::PI / 2.0 + local * std::f64::consts::PI;
(cx - r + r * angle.cos(), cy + r * angle.sin())
} else {
// Top straight: left → right
let local = (t - 2.0 * caps - straights) / straights;
(cx - r + local * 2.0 * r, cy - r)
}
}
/// Build the full closed path of the shape outline (at radius `r`).
/// Call `stroke()` after this to draw the ring.
pub(crate) fn build_ring_path(ctx: &Context, cx: f64, cy: f64, r: f64, shape: RingShape) {
match shape {
RingShape::Circle => {
ctx.arc(cx, cy, r, 0.0, 2.0 * std::f64::consts::PI);
}
RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => {
let (x0, y0) = perimeter_point(cx, cy, r, shape, 0.0);
ctx.move_to(x0, y0);
// Subdivide perimeter into enough segments for smooth rendering
let n = 80;
for i in 1..=n {
let pt = i as f64 / n as f64;
let (x, y) = perimeter_point(cx, cy, r, shape, pt);
ctx.line_to(x, y);
}
ctx.close_path();
}
}
}
/// Build a partial path along the shape perimeter from normalized position
/// `t_start` to `t_end`. Call `stroke()` after this to draw a sector.
pub(crate) fn build_sector_path(
ctx: &Context,
cx: f64,
cy: f64,
r: f64,
shape: RingShape,
t_start: f64,
t_end: f64,
) {
match shape {
RingShape::Circle => {
let a_start = t_start * 2.0 * std::f64::consts::PI;
let a_end = t_end * 2.0 * std::f64::consts::PI;
ctx.arc(cx, cy, r, a_start, a_end);
}
RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => {
let (x0, y0) = perimeter_point(cx, cy, r, shape, t_start);
ctx.move_to(x0, y0);
for i in 1..=SEGMENTS {
let t = t_start + (t_end - t_start) * (i as f64 / SEGMENTS as f64);
let (x, y) = perimeter_point(cx, cy, r, shape, t);
ctx.line_to(x, y);
}
}
}
}
/// Build the filled interior path (inset from outer ring by `thickness / 2`).
/// Call `fill()` after this.
pub(crate) fn build_fill_path(
ctx: &Context,
cx: f64,
cy: f64,
radius: f64,
thickness: f64,
shape: RingShape,
) {
let inner_r = (radius - thickness / 2.0).max(0.0);
if inner_r <= 0.0 {
return;
}
build_ring_path(ctx, cx, cy, inner_r, shape);
}
/// Check whether a point `(px, py)` lies inside the shape's fill area.
/// This is used for hittesting (e.g. click & hold to peek) so the clickable
/// region matches what the user sees on screen.
pub(crate) fn point_in_shape(cx: f64, cy: f64, r: f64, shape: RingShape, px: f64, py: f64) -> bool {
let dx = (px - cx).abs();
let dy = (py - cy).abs();
match shape {
RingShape::Circle => dx * dx + dy * dy <= r * r,
RingShape::Square => dx <= r && dy <= r,
RingShape::Diamond => dx + dy <= r,
RingShape::Hexagon => {
// Regular hexagon, vertex at (r, 0), edge slopes at ±60°.
// For |dx| ≥ r/2 the sloping edge bounds: |dy| ≤ √3 (r |dx|).
// For |dx| ≤ r/2 the flat top bounds: |dy| ≤ √3 r / 2.
let sqrt3 = 3.0_f64.sqrt();
if dx >= r / 2.0 {
dy <= sqrt3 * (r - dx)
} else {
dy <= r * sqrt3 / 2.0
}
}
RingShape::Pill => {
// Pill = centre rectangle (2r × 2r) + semicircular caps of
// radius r at each end. Total width 4r, height 2r.
if dx <= r {
dy <= r
} else {
let ex = dx - r;
ex * ex + dy * dy <= r * r
}
}
}
}
/// Return the normalized `t` offset that places the first password dot at the
/// visual top-centre of the shape. May be negative; callers should NOT wrap.
pub(crate) fn top_centre_offset(shape: RingShape) -> f64 {
match shape {
// Circle/Diamond: top at t=0.75 → offset -(1-0.75) = -0.25
RingShape::Circle | RingShape::Diamond => -0.25,
// Square: top edge centre at t=0.875 → offset -(1-0.875) = -0.125
RingShape::Square => -0.125,
// Hexagon: top edge centre at t=0.75 → offset -0.25
RingShape::Hexagon => -0.25,
// Pill: top straight centre at t = 1 - 1/(4+2π) ≈ 0.9027
RingShape::Pill => -1.0 / (4.0 + 2.0 * std::f64::consts::PI),
}
}
+568
View File
@@ -0,0 +1,568 @@
use crate::render::Renderer;
use cairo::{Format, ImageSurface};
impl Renderer {
pub(crate) fn load_icons(&mut self) {
log::debug!("Attempting to load status icons...");
let wifi_names = [
"network-wireless-signal-excellent-symbolic",
"network-wireless-signal-excellent",
"network-wireless-symbolic",
"network-wireless",
];
let wifi_path = self
.config
.wifi_icon
.clone()
.or_else(|| {
for name in &wifi_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !wifi_path.is_empty() {
log::debug!("Resolved WiFi icon path: {}", wifi_path);
self.wifi_icon_surface = self.load_icon(&wifi_path);
}
let bt_names = [
"bluetooth-active-symbolic",
"bluetooth-symbolic",
"bluetooth-active",
"bluetooth",
];
let bt_path = self
.config
.bluetooth_icon
.clone()
.or_else(|| {
for name in &bt_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !bt_path.is_empty() {
log::debug!("Resolved Bluetooth icon path: {}", bt_path);
self.bluetooth_icon_surface = self.load_icon(&bt_path);
}
let batt_names = [
"battery-level-100-symbolic",
"battery-full-symbolic",
"battery-full",
"battery-level-100",
"battery",
"battery-symbolic",
];
let batt_path = self
.config
.battery_icon
.clone()
.or_else(|| {
for name in &batt_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !batt_path.is_empty() {
log::debug!("Resolved Battery icon path: {}", batt_path);
self.battery_icon_surface = self.load_icon(&batt_path);
}
let prev_names = [
"media-skip-backward-symbolic",
"media-skip-backward",
"media-playlist-repeat-symbolic",
];
let prev_path = self
.config
.media_prev_icon
.clone()
.or_else(|| {
for name in &prev_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !prev_path.is_empty() {
self.media_prev_icon_surface = self.load_icon(&prev_path);
}
let stop_names = ["media-playback-stop-symbolic", "media-playback-stop"];
let stop_path = self
.config
.media_stop_icon
.clone()
.or_else(|| {
for name in &stop_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !stop_path.is_empty() {
self.media_stop_icon_surface = self.load_icon(&stop_path);
}
let play_names = ["media-playback-start-symbolic", "media-playback-start"];
let play_path = self
.config
.media_play_icon
.clone()
.or_else(|| {
for name in &play_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !play_path.is_empty() {
self.media_play_icon_surface = self.load_icon(&play_path);
}
let pause_names = ["media-playback-pause-symbolic", "media-playback-pause"];
let pause_path = self
.config
.media_pause_icon
.clone()
.or_else(|| {
for name in &pause_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !pause_path.is_empty() {
self.media_pause_icon_surface = self.load_icon(&pause_path);
}
let next_names = ["media-skip-forward-symbolic", "media-skip-forward"];
let next_path = self
.config
.media_next_icon
.clone()
.or_else(|| {
for name in &next_names {
if let Some(path) = self.find_system_icon(name) {
return Some(path);
}
}
None
})
.unwrap_or_default();
if !next_path.is_empty() {
self.media_next_icon_surface = self.load_icon(&next_path);
}
}
pub(crate) fn find_system_icon(&self, name: &str) -> Option<String> {
let data_dirs = std::env::var("XDG_DATA_DIRS").unwrap_or_default();
let mut search_paths = Vec::new();
for dir in data_dirs.split(':') {
let p = std::path::PathBuf::from(dir).join("icons");
if p.exists() {
search_paths.push(p);
}
}
let sys_path = std::path::PathBuf::from("/run/current-system/sw/share/icons");
if sys_path.exists() {
search_paths.push(sys_path);
}
let usr_path = std::path::PathBuf::from("/usr/share/icons");
if usr_path.exists() {
search_paths.push(usr_path);
}
let themes = [
"WhiteSur",
"WhiteSur-dark",
"WhiteSur-light",
"Adwaita",
"hicolor",
"breeze",
"Papirus",
];
let categories = [
"status/symbolic",
"actions/symbolic",
"devices/symbolic",
"status/24",
"status/22",
"status/16",
"status",
"actions",
"devices",
"symbolic/status",
"symbolic/actions",
"symbolic/devices",
"24x24/status",
"22x22/status",
"16x16/status",
"48x48/status",
];
for base in &search_paths {
for theme in &themes {
for cat in &categories {
for ext in [".svg", ".png"] {
let icon_path = base.join(theme).join(cat).join(format!("{}{}", name, ext));
if icon_path.exists() {
return Some(icon_path.to_string_lossy().into_owned());
}
}
}
}
}
for base in &search_paths {
for theme in &themes {
let theme_root = base.join(theme);
if !theme_root.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&theme_root) {
for entry in entries.flatten() {
if entry.path().is_dir() {
for ext in [".svg", ".png"] {
let icon_path = entry.path().join(format!("{}{}", name, ext));
if icon_path.exists() {
return Some(icon_path.to_string_lossy().into_owned());
}
}
}
}
}
}
}
None
}
pub(crate) fn load_icon(&self, identifier: &str) -> Option<ImageSurface> {
let path = if identifier.starts_with('~') {
let home = std::env::var("HOME").unwrap_or_default();
std::path::PathBuf::from(identifier.replacen('~', &home, 1))
} else {
std::path::PathBuf::from(identifier)
};
if !path.exists() {
return None;
}
if path.extension().and_then(|s| s.to_str()) == Some("svg") {
if let Some(surface) = self.load_svg_with_resvg(&path) {
return Some(surface);
}
}
match gdk_pixbuf::Pixbuf::from_file(&path) {
Ok(pixbuf) => {
let w = pixbuf.width();
let h = pixbuf.height();
let mut surface = ImageSurface::create(Format::ARgb32, w, h).ok()?;
{
let mut surface_data = surface.data().ok()?;
let pix_data = unsafe { pixbuf.pixels() };
let n_channels = pixbuf.n_channels();
let rowstride = pixbuf.rowstride() as usize;
for y in 0..h as usize {
for x in 0..w as usize {
let pix_idx = y * rowstride + x * n_channels as usize;
let surf_idx = (y * w as usize + x) * 4;
if n_channels == 4 {
surface_data[surf_idx] = pix_data[pix_idx + 2];
surface_data[surf_idx + 1] = pix_data[pix_idx + 1];
surface_data[surf_idx + 2] = pix_data[pix_idx];
surface_data[surf_idx + 3] = pix_data[pix_idx + 3];
} else if n_channels == 3 {
surface_data[surf_idx] = pix_data[pix_idx + 2];
surface_data[surf_idx + 1] = pix_data[pix_idx + 1];
surface_data[surf_idx + 2] = pix_data[pix_idx];
surface_data[surf_idx + 3] = 255;
}
}
}
}
Some(surface)
}
Err(_) => None,
}
}
pub(crate) fn load_svg_with_resvg(&self, path: &std::path::Path) -> Option<ImageSurface> {
use resvg::usvg;
let opt = usvg::Options::default();
let svg_data = std::fs::read(path).ok()?;
let tree = usvg::Tree::from_data(&svg_data, &opt).ok()?;
let size = tree.size().to_int_size();
let mut pixmap = resvg::tiny_skia::Pixmap::new(size.width(), size.height())?;
resvg::render(
&tree,
resvg::tiny_skia::Transform::default(),
&mut pixmap.as_mut(),
);
let mut surface =
ImageSurface::create(Format::ARgb32, size.width() as i32, size.height() as i32).ok()?;
{
let mut surface_data = surface.data().ok()?;
let pix_data = pixmap.data();
for i in (0..pix_data.len()).step_by(4) {
surface_data[i] = pix_data[i + 2];
surface_data[i + 1] = pix_data[i + 1];
surface_data[i + 2] = pix_data[i];
surface_data[i + 3] = pix_data[i + 3];
}
}
Some(surface)
}
pub(crate) fn draw_clock(&self) {
use chrono::Local;
let now = Local::now();
let time_str = now.format("%H:%M").to_string();
let date_str = now.format("%A, %B %d").to_string();
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(48.0);
let te = render_try!(self.context.text_extents(&time_str));
self.context
.move_to(center_x - te.width() / 2.0, center_y + te.height() / 4.0);
render_try!(self.context.show_text(&time_str));
self.context.new_path();
self.context.set_font_size(14.0);
let de = render_try!(self.context.text_extents(&date_str));
self.context.move_to(
center_x - de.width() / 2.0,
center_y + te.height() / 4.0 + 25.0,
);
render_try!(self.context.show_text(&date_str));
self.context.new_path();
let ue = render_try!(self.context.text_extents(&self.uptime_cache));
self.context.move_to(
center_x - ue.width() / 2.0,
center_y + te.height() / 4.0 + 43.0,
);
render_try!(self.context.show_text(&self.uptime_cache));
}
pub(crate) fn draw_network(&self) {
if !self.config.show_network {
return;
}
let margin = 20.0;
let x = margin;
let y = margin + 20.0;
if let Some(ref ssid) = self.system_status.wifi_ssid {
if let Some(ref icon) = self.wifi_icon_surface {
self.draw_icon_at(x, y - 15.0, icon);
let text_x = x + 24.0 + 10.0;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(text_x, y);
render_try!(self.context.show_text(ssid));
} else {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(x, y);
render_try!(self.context.show_text(ssid));
}
}
}
pub(crate) fn draw_status(&self) {
if let Some(percent) = self.system_status.battery_percent {
let margin = 20.0;
let icon_width = 30.0;
let x = self.width as f64 - margin - icon_width - 50.0;
let y = margin + 20.0;
if let Some(ref icon) = self.battery_icon_surface {
self.draw_icon_at(x, y - 15.0, icon);
let text_x = x + 24.0 + 10.0;
let battery_text = format!("{:.0}%", percent);
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(text_x, y);
render_try!(self.context.show_text(&battery_text));
} else {
self.draw_battery_icon_at(
x,
y - 12.0,
icon_width,
15.0,
percent,
self.system_status.is_charging,
);
let battery_text = format!("{:.0}%", percent);
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(x + icon_width + 10.0, y);
render_try!(self.context.show_text(&battery_text));
}
}
}
pub(crate) fn draw_bluetooth(&self) {
if !self.config.show_bluetooth {
return;
}
let margin = 20.0;
let x = margin;
let y = margin + 50.0;
let (status_text, is_off) = if self.system_status.bluetooth_connected {
(self.system_status.bluetooth_devices.join(", "), false)
} else {
("Bluetooth off".to_string(), true)
};
let alpha_mult = if is_off { 0.5 } else { 1.0 };
if let Some(ref icon) = self.bluetooth_icon_surface {
self.draw_icon_at_with_alpha(x, y - 12.0, icon, alpha_mult);
let text_x = x + 24.0 + 10.0;
self.context.new_path();
self.context
.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult);
self.context.set_font_size(14.0);
self.context.move_to(text_x, y);
render_try!(self.context.show_text(&status_text));
}
}
pub(crate) fn draw_keyboard_layout(&self) {
if self.config.show_keyboard_layout {
if let Some(ref layout) = self.system_status.keyboard_layout {
let margin = 20.0;
let x = margin;
let y = margin + 80.0;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
let text = format!("Layout: {}", layout);
self.context.move_to(x, y);
render_try!(self.context.show_text(&text));
}
}
}
pub(crate) fn draw_icon_at(&self, x: f64, y: f64, surface: &ImageSurface) {
self.context.save().unwrap();
let target_size = 24.0;
let scale =
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
self.context.translate(x, y);
self.context.scale(scale, scale);
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
log::error!("cairo error: {:?}", e);
}
if let Err(e) = self.context.paint_with_alpha(self.fade_alpha) {
log::error!("cairo error: {:?}", e);
}
self.context.restore().unwrap();
}
pub(crate) fn draw_icon_at_with_alpha(
&self,
x: f64,
y: f64,
surface: &ImageSurface,
alpha: f64,
) {
self.context.save().unwrap();
let target_size = 24.0;
let scale =
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
self.context.translate(x, y);
self.context.scale(scale, scale);
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
log::error!("cairo error: {:?}", e);
}
if let Err(e) = self.context.paint_with_alpha(self.fade_alpha * alpha) {
log::error!("cairo error: {:?}", e);
}
self.context.restore().unwrap();
}
pub(crate) fn draw_battery_icon_at(
&self,
x: f64,
y: f64,
width: f64,
height: f64,
percent: f64,
charging: bool,
) {
let alpha = self.fade_alpha;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, alpha * 0.5);
self.context.set_line_width(2.0);
self.context.rectangle(x, y, width, height);
render_try!(self.context.stroke());
self.context.new_path();
self.context
.rectangle(x + width, y + height / 4.0, 3.0, height / 2.0);
render_try!(self.context.fill());
let fill_width = (width - 4.0) * (percent / 100.0);
self.context.new_path();
if percent < 20.0 {
self.context.set_source_rgba(1.0, 0.2, 0.2, alpha);
} else {
self.context.set_source_rgba(0.2, 1.0, 0.2, alpha * 0.8);
}
self.context
.rectangle(x + 2.0, y + 2.0, fill_width, height - 4.0);
render_try!(self.context.fill());
if charging {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 0.0, alpha);
let bx = x + width / 2.0;
let by = y + height / 2.0;
self.context.move_to(bx - 3.0, by + 2.0);
self.context.line_to(bx + 1.0, by - 1.0);
self.context.line_to(bx - 1.0, by - 1.0);
self.context.line_to(bx + 3.0, by - 6.0);
self.context.line_to(bx - 1.0, by - 3.0);
self.context.line_to(bx + 1.0, by - 3.0);
self.context.close_path();
render_try!(self.context.fill());
}
}
}
+11 -234
View File
@@ -4,7 +4,7 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use cairo::ImageSurface; use cairo::ImageSurface;
use log::debug; use log::warn;
use smithay_client_toolkit::shm::{slot::Buffer, slot::SlotPool}; use smithay_client_toolkit::shm::{slot::Buffer, slot::SlotPool};
use std::sync::Mutex; use std::sync::Mutex;
use wayland_client::globals::GlobalList; use wayland_client::globals::GlobalList;
@@ -36,246 +36,19 @@ impl Screenshot {
/// Apply configured visual effects to the screenshot. /// Apply configured visual effects to the screenshot.
pub fn apply_effects(&mut self, config: &Config) -> Result<()> { pub fn apply_effects(&mut self, config: &Config) -> Result<()> {
if let Some((radius, times)) = config.effect_blur { if let Some((radius, times)) = config.effect_blur {
self.apply_blur(radius, times)?; crate::effects::apply_blur(&mut self.surface, radius, times)?;
} }
if let Some((base, factor)) = config.effect_vignette { if let Some((base, factor)) = config.effect_vignette {
self.apply_vignette(base, factor); crate::effects::apply_vignette(&mut self.surface, base, factor)?;
} }
if let Some(pixel_size) = config.effect_pixelate { if let Some(pixel_size) = config.effect_pixelate {
self.apply_pixelate(pixel_size); crate::effects::apply_pixelate(&mut self.surface, pixel_size)?;
} }
if let Some(angle) = config.effect_swirl { if let Some(angle) = config.effect_swirl {
self.apply_swirl(angle); crate::effects::apply_swirl(&mut self.surface, angle)?;
}
if let Some(factor) = config.effect_melting {
self.apply_melting(factor);
} }
Ok(()) Ok(())
} }
/// Apply a swirl effect.
pub fn apply_swirl(&mut self, angle: f32) {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let radius = center_x.min(center_y);
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
let original = data.clone();
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let d = (dx * dx + dy * dy).sqrt();
if d < radius {
let percent = (radius - d) / radius;
let theta = percent * percent * angle;
let s = theta.sin();
let c = theta.cos();
let nx = (c * dx - s * dy + center_x) as i32;
let ny = (s * dx + c * dy + center_y) as i32;
if nx >= 0 && nx < width && ny >= 0 && ny < height {
let src_idx = (ny as usize * stride) + (nx as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
data[dst_idx..dst_idx + 4].copy_from_slice(&original[src_idx..src_idx + 4]);
}
}
}
}
let mut surface_data = self.surface.data().unwrap();
surface_data.copy_from_slice(&data);
}
/// Apply a melting effect (vertical smear).
pub fn apply_melting(&mut self, factor: f32) {
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
use rand::Rng;
let mut rng = rand::thread_rng();
for x in 0..width {
let mut melt_amount = 0.0;
for y in 0..height {
melt_amount += rng.gen_range(0.0..factor);
let src_y = (y as f32 - melt_amount).max(0.0) as i32;
let src_idx = (src_y as usize * stride) + (x as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
// Copy the pixel from above to create a smear
let pixel = [
data[src_idx],
data[src_idx + 1],
data[src_idx + 2],
data[src_idx + 3],
];
data[dst_idx..dst_idx + 4].copy_from_slice(&pixel);
}
}
let mut surface_data = self.surface.data().unwrap();
surface_data.copy_from_slice(&data);
}
/// Pixelate the surface.
pub fn apply_pixelate(&mut self, pixel_size: u32) {
if pixel_size <= 1 {
return;
}
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
for y in (0..height).step_by(pixel_size as usize) {
for x in (0..width).step_by(pixel_size as usize) {
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
let mut count = 0u32;
// Average pixels in the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
r += data[index] as u32;
g += data[index + 1] as u32;
b += data[index + 2] as u32;
count += 1;
}
}
}
if count > 0 {
let r = (r / count) as u8;
let g = (g / count) as u8;
let b = (b / count) as u8;
// Fill the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
}
}
}
}
}
}
let mut surface_data = self.surface.data().unwrap();
surface_data.copy_from_slice(&data);
}
/// Apply a Gaussian blur effect.
pub fn apply_blur(&mut self, radius: u32, times: u32) -> Result<()> {
if radius == 0 || times == 0 {
return Ok(());
}
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("Failed to get surface data")?;
// Convert to image::RgbaImage for processing
let mut img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
image::ImageBuffer::from_raw(width as u32, height as u32, data)
.context("Failed to create image buffer")?;
for _ in 0..times {
let mut rgb_data: Vec<[u8; 3]> =
Vec::with_capacity((width as usize) * (height as usize));
for pixel in img.pixels() {
rgb_data.push([pixel[0], pixel[1], pixel[2]]);
}
fastblur::gaussian_blur(
&mut rgb_data,
width as usize,
height as usize,
radius as f32,
);
for (i, pixel) in img.pixels_mut().enumerate() {
pixel[0] = rgb_data[i][0];
pixel[1] = rgb_data[i][1];
pixel[2] = rgb_data[i][2];
}
}
// Copy back to surface
let new_data = img.into_raw();
let mut surface_data = self.surface.data()?;
surface_data.copy_from_slice(&new_data);
Ok(())
}
/// Apply a vignette effect (darken edges).
pub fn apply_vignette(&mut self, base: f32, factor: f32) {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let max_distance = (center_x * center_x + center_y * center_y).sqrt();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let distance = (dx * dx + dy * dy).sqrt();
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
let index = ((y * width + x) * 4) as usize;
for i in 0..3 {
let value = data[index + i] as f32 * vignette_factor;
data[index + i] = value.clamp(0.0, 255.0) as u8;
}
}
}
let mut surface_data = self.surface.data().unwrap();
surface_data.copy_from_slice(&data);
}
} }
#[derive(Clone)] #[derive(Clone)]
@@ -312,7 +85,7 @@ impl ScreenshotManager {
.ok(); .ok();
if manager.is_none() { if manager.is_none() {
debug!("zwlr_screencopy_manager_v1 not available"); warn!("zwlr_screencopy_manager_v1 not available — backgrounds will not be captured");
} }
Ok(Self { manager }) Ok(Self { manager })
@@ -361,11 +134,15 @@ impl ScreenshotManager {
let raw_data = { let raw_data = {
let mut data = vec![0u8; (info.width * info.height * 4) as usize]; let mut data = vec![0u8; (info.width * info.height * 4) as usize];
let canvas_end = canvas.len();
for row in 0..height { for row in 0..height {
let src_offset = row * stride; let src_offset = row * stride;
let dst_offset = row * pixel_width; let dst_offset = row * pixel_width;
let copy_end = (src_offset + pixel_width).min(canvas_end);
if copy_end > src_offset {
data[dst_offset..dst_offset + pixel_width] data[dst_offset..dst_offset + pixel_width]
.copy_from_slice(&canvas[src_offset..src_offset + pixel_width]); .copy_from_slice(&canvas[src_offset..copy_end]);
}
} }
data data
}; };
+72 -35
View File
@@ -4,7 +4,7 @@ use std::sync::{Arc, Mutex};
use tokio::sync::mpsc; use tokio::sync::mpsc;
use zbus::Connection; use zbus::Connection;
#[derive(Clone, Default)] #[derive(Clone, Default, PartialEq)]
pub struct SystemStatus { pub struct SystemStatus {
pub battery_percent: Option<f64>, pub battery_percent: Option<f64>,
pub is_charging: bool, pub is_charging: bool,
@@ -21,22 +21,30 @@ pub struct SystemStatus {
} }
#[derive(Debug, Clone, Copy)] #[derive(Debug, Clone, Copy)]
pub enum SystemCommand { pub enum BackendCommand {
PowerOff, PowerOff,
Reboot, Reboot,
Suspend, Suspend,
MediaPlayPause,
MediaStop,
MediaNext,
MediaPrev,
} }
pub struct SystemManager { pub struct SystemManager {
status: Arc<Mutex<SystemStatus>>, status: Arc<Mutex<SystemStatus>>,
cmd_tx: mpsc::UnboundedSender<SystemCommand>, cmd_tx: mpsc::UnboundedSender<BackendCommand>,
} }
impl SystemManager { impl SystemManager {
pub fn new() -> Self { pub fn new(config: &crate::config::Config) -> Self {
let poll_interval = tokio::time::Duration::from_secs(config.system_poll_interval);
let reconnect_delay = tokio::time::Duration::from_secs(config.dbus_reconnect_delay);
let command_timeout = tokio::time::Duration::from_secs(config.command_timeout);
let status = Arc::new(Mutex::new(SystemStatus::default())); let status = Arc::new(Mutex::new(SystemStatus::default()));
let s_clone = status.clone(); let s_clone = status.clone();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SystemCommand>(); let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() { let rt = match tokio::runtime::Runtime::new() {
@@ -49,7 +57,7 @@ impl SystemManager {
rt.block_on(async { rt.block_on(async {
let mut conn: Option<Connection> = None; let mut conn: Option<Connection> = None;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(2)); let mut interval = tokio::time::interval(poll_interval);
let mut last_art_url: Option<String> = None; let mut last_art_url: Option<String> = None;
let mut last_art_data: Option<Arc<Vec<u8>>> = None; let mut last_art_data: Option<Arc<Vec<u8>>> = None;
@@ -59,7 +67,9 @@ impl SystemManager {
Ok(c) => conn = Some(c), Ok(c) => conn = Some(c),
Err(e) => { Err(e) => {
error!("Failed to connect to system DBus: {}", e); error!("Failed to connect to system DBus: {}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await; tokio::time::sleep(reconnect_delay).await;
interval = tokio::time::interval(poll_interval);
continue;
} }
} }
} }
@@ -244,16 +254,28 @@ impl SystemManager {
} }
} }
} }
Some(command) = cmd_rx.recv() => { Some(cmd) = cmd_rx.recv() => {
match cmd {
BackendCommand::PowerOff
| BackendCommand::Reboot
| BackendCommand::Suspend => {
if let Some(ref c) = conn { if let Some(ref c) = conn {
let method = match command { // Safety: only PowerOff/Reboot/Suspend reach this
SystemCommand::PowerOff => "PowerOff", // branch due to the outer match arm.
SystemCommand::Reboot => "Reboot", let method = match cmd {
SystemCommand::Suspend => "Suspend", BackendCommand::PowerOff => "PowerOff",
BackendCommand::Reboot => "Reboot",
BackendCommand::Suspend => "Suspend",
BackendCommand::MediaPlayPause
| BackendCommand::MediaStop
| BackendCommand::MediaNext
| BackendCommand::MediaPrev => {
unreachable!("media command in power branch: {:?}", cmd)
}
}; };
debug!("Executing system command: {}", method); debug!("Executing system command: {}", method);
let result = tokio::time::timeout( let result = tokio::time::timeout(
tokio::time::Duration::from_secs(5), command_timeout,
c.call_method( c.call_method(
Some("org.freedesktop.login1"), Some("org.freedesktop.login1"),
"/org/freedesktop/login1", "/org/freedesktop/login1",
@@ -267,6 +289,37 @@ impl SystemManager {
} }
} }
} }
BackendCommand::MediaPlayPause
| BackendCommand::MediaStop
| BackendCommand::MediaNext
| BackendCommand::MediaPrev => {
let action = cmd;
// Fire-and-forget: don't block the polling loop on MPRIS.
tokio::task::spawn_blocking(move || {
match PlayerFinder::new() {
Ok(finder) => {
match finder.find_active() {
Ok(player) => {
let result = match action {
BackendCommand::MediaPlayPause => player.play_pause(),
BackendCommand::MediaStop => player.stop(),
BackendCommand::MediaNext => player.next(),
BackendCommand::MediaPrev => player.previous(),
_ => Ok(()),
};
if let Err(e) = result {
error!("MPRIS command {action:?} failed: {e}");
}
}
Err(e) => error!("No active MPRIS player found: {e}"),
}
}
Err(e) => error!("Failed to create MPRIS PlayerFinder: {e}"),
}
});
}
}
}
} }
} }
}); });
@@ -276,42 +329,26 @@ impl SystemManager {
} }
pub fn get_status(&self) -> SystemStatus { pub fn get_status(&self) -> SystemStatus {
self.status.lock().unwrap().clone() self.status.lock().map(|s| s.clone()).unwrap_or_default()
} }
pub fn send_command(&self, cmd: SystemCommand) { pub fn send_command(&self, cmd: BackendCommand) {
let _ = self.cmd_tx.send(cmd); let _ = self.cmd_tx.send(cmd);
} }
pub fn media_play_pause(&self) { pub fn media_play_pause(&self) {
if let Ok(finder) = PlayerFinder::new() { let _ = self.cmd_tx.send(BackendCommand::MediaPlayPause);
if let Ok(player) = finder.find_active() {
let _ = player.play_pause();
}
}
} }
pub fn media_stop(&self) { pub fn media_stop(&self) {
if let Ok(finder) = PlayerFinder::new() { let _ = self.cmd_tx.send(BackendCommand::MediaStop);
if let Ok(player) = finder.find_active() {
let _ = player.stop();
}
}
} }
pub fn media_next(&self) { pub fn media_next(&self) {
if let Ok(finder) = PlayerFinder::new() { let _ = self.cmd_tx.send(BackendCommand::MediaNext);
if let Ok(player) = finder.find_active() {
let _ = player.next();
}
}
} }
pub fn media_prev(&self) { pub fn media_prev(&self) {
if let Ok(finder) = PlayerFinder::new() { let _ = self.cmd_tx.send(BackendCommand::MediaPrev);
if let Ok(player) = finder.find_active() {
let _ = player.previous();
}
}
} }
} }
+1 -4
View File
@@ -72,10 +72,7 @@ where
} }
} }
pub fn serialize_blur_effect<S>( pub fn serialize_blur_effect<S>(val: &Option<(u32, u32)>, serializer: S) -> Result<S::Ok, S::Error>
val: &Option<(u32, u32)>,
serializer: S,
) -> Result<S::Ok, S::Error>
where where
S: Serializer, S: Serializer,
{ {