Compare commits
14 Commits
dab90e081e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
7603975062
|
|||
|
833706f5a5
|
|||
|
c2c093595a
|
|||
| db3e797e36 | |||
| c4ae3e9651 | |||
|
4372c7f7c0
|
|||
| e9550f71f5 | |||
| ff20423a84 | |||
|
6c2e3fca5a
|
|||
|
c6ec65c851
|
|||
|
7a0adc4d82
|
|||
|
016038aca0
|
|||
| 05cf0c1d7e | |||
| 3a9be7c696 |
@@ -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
|
||||||
@@ -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**
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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 }}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)"
|
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
# 🔒 RustLock
|
# 🔒 RustLock
|
||||||
|
|
||||||
[](https://github.com/yourusername/rustlock/blob/main/LICENSE)
|
[](https://github.com/yourusername/rustlock/blob/main/LICENSE)
|
||||||
[](https://github.com/yourusername/rustlock/releases)
|
[](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
|
||||||
|
|||||||
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 443 KiB |
@@ -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
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.94.0"
|
||||||
|
components = ["rustfmt", "clippy"]
|
||||||
@@ -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 conversation = LockConversation { password: None };
|
let (auth_req_send, auth_req_recv) = channel::channel::<(Zeroizing<String>, u64)>();
|
||||||
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
|
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
|
||||||
Ok(_) => {
|
|
||||||
debug!("Prepared to authenticate user '{}'", username);
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!("Failed to initialize PAM context: {:?}", err);
|
|
||||||
error!(
|
|
||||||
"Ensure that the PAM service '{}' is correctly configured.",
|
|
||||||
SERVICE_NAME
|
|
||||||
);
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (auth_req_send, auth_req_recv) = channel::channel::<Zeroizing<String>>();
|
|
||||||
let (auth_res_send, auth_res_recv) = channel::channel::<bool>();
|
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let mut event_loop: EventLoop<()> = EventLoop::try_new().unwrap();
|
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 mut context =
|
||||||
|
match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
|
||||||
|
Ok(ctx) => {
|
||||||
|
debug!("Prepared to authenticate user '{}'", username);
|
||||||
|
ctx
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
error!("Failed to initialize PAM context: {:?}", err);
|
||||||
|
error!(
|
||||||
|
"Ensure that the PAM service '{}' is correctly configured.",
|
||||||
|
service_name
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let running = Arc::new(AtomicBool::new(true));
|
||||||
|
let running_clone = running.clone();
|
||||||
|
|
||||||
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) {
|
||||||
};
|
Ok(()) => {
|
||||||
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
|
let _ = auth_res_send.send((true, seq));
|
||||||
Ok(mut context) => match context.authenticate(Flag::NONE) {
|
}
|
||||||
Ok(()) => {
|
|
||||||
auth_res_send.send(true).unwrap();
|
|
||||||
}
|
|
||||||
Err(err) => {
|
|
||||||
error!("Pam authenticate failed with {:?}", err);
|
|
||||||
auth_res_send.send(false).unwrap();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
error!("Failed to re-initialize PAM context: {:?}", err);
|
error!("Pam authenticate failed with {:?}", err);
|
||||||
auth_res_send.send(false).unwrap();
|
let _ = auth_res_send.send((false, seq));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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))
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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,12 +50,13 @@ 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 {
|
||||||
self.cursor_position -= 1;
|
return InputAction::None;
|
||||||
self.password_buffer.remove(self.cursor_position);
|
}
|
||||||
if self.password_buffer.is_empty() {
|
self.cursor_position -= 1;
|
||||||
return InputAction::PasswordCleared;
|
self.password_buffer.remove(self.cursor_position);
|
||||||
}
|
if self.password_buffer.is_empty() {
|
||||||
|
return InputAction::PasswordCleared;
|
||||||
}
|
}
|
||||||
return InputAction::PasswordChanged;
|
return InputAction::PasswordChanged;
|
||||||
}
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = new_alpha;
|
self.fade_alpha = 1.0;
|
||||||
self.renderer.set_fade_alpha(self.fade_alpha);
|
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.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
|
||||||
self.renderer.caps_lock = self.input_handler.caps_lock();
|
if 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) {
|
||||||
self.renderer.system_status = status;
|
if 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) {
|
||||||
action = Some(a);
|
if let crate::input::InputAction::SubmitPassword(p) = &a {
|
||||||
|
if !p.is_empty() {
|
||||||
|
return Some(a);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
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);
|
||||||
|
|||||||
@@ -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(path)
|
||||||
.open(&log_path)
|
{
|
||||||
{
|
Ok(file) => {
|
||||||
Ok(file) => {
|
*FILE_LOGGER.lock().unwrap() = Some(file);
|
||||||
*FILE_LOGGER.lock().unwrap() = Some(file);
|
eprintln!("Logging to: {}", path.display());
|
||||||
eprintln!("Logging to: {}", log_path);
|
}
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("Failed to open log file {}: {}", path.display(), e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
} else if config.log_file {
|
||||||
eprintln!("Failed to open log file {}: {}", log_path, e);
|
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");
|
||||||
if let Ok(mut lock_manager) = self.lock_manager.lock() {
|
let password: Option<Zeroizing<String>> = self
|
||||||
let mut password = Zeroizing::new(String::new());
|
.lock_manager
|
||||||
let modifiers = self.modifiers;
|
.lock()
|
||||||
for surface in &mut lock_manager.surfaces {
|
.ok()
|
||||||
if let Some(InputAction::SubmitPassword(p)) =
|
.and_then(|mut lm| lm.handle_key_event(event, self.modifiers))
|
||||||
surface.handle_key_event(event.clone(), modifiers)
|
.and_then(|action| {
|
||||||
{
|
if let InputAction::SubmitPassword(p) = action {
|
||||||
password = p;
|
(!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() {
|
||||||
|
for surface in &mut lock_manager.surfaces {
|
||||||
|
surface.show_verifying();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !password.is_empty() {
|
if let Some(tx) = &self.auth_tx {
|
||||||
if let Some(tx) = &self.auth_tx {
|
let _ = tx.send((password, self.auth_seq));
|
||||||
let _ = tx.send(password);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} 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,28 +705,71 @@ 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(),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
return;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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 = {
|
||||||
for y in 0..h {
|
if let Ok(mut surface_data) = surface.data() {
|
||||||
for x in 0..w {
|
for y in 0..h {
|
||||||
let pixel = img.get_pixel(x, y);
|
for x in 0..w {
|
||||||
let idx = ((y * w + x) * 4) as usize;
|
let pixel = img.get_pixel(x, y);
|
||||||
surface_data[idx] = pixel[2]; // B
|
let idx = ((y * w + x) * 4) as usize;
|
||||||
surface_data[idx + 1] = pixel[1]; // G
|
surface_data[idx] = pixel[2];
|
||||||
surface_data[idx + 2] = pixel[0]; // R
|
surface_data[idx + 1] = pixel[1];
|
||||||
surface_data[idx + 3] = pixel[3]; // A
|
surface_data[idx + 2] = pixel[0];
|
||||||
|
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);
|
||||||
|
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 num_outputs = state.output_state.outputs().count();
|
||||||
|
state.captured_backgrounds = vec![Some(surface); num_outputs];
|
||||||
|
state.config.screenshots = false;
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
log::error!("Failed to create Cairo surface for background image");
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut ss = Screenshot::new(surface);
|
|
||||||
let _ = ss.apply_effects(&state.config);
|
|
||||||
let surface = ss.into_inner();
|
|
||||||
|
|
||||||
let num_outputs = state.output_state.outputs().count();
|
|
||||||
state.captured_backgrounds = vec![Some(surface); num_outputs];
|
|
||||||
|
|
||||||
// Disable screenshots if image was successfully loaded
|
|
||||||
state.config.screenshots = false;
|
|
||||||
} 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 {
|
||||||
state.handle_auth_result(success);
|
// Ignore stale auth results from previous requests (e.g. after timeout or retry).
|
||||||
if success {
|
if state.auth_pending_seq == Some(seq) {
|
||||||
state.lock_surfaces.clear();
|
state.handle_auth_result(success);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
@@ -823,15 +956,35 @@ 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 {
|
||||||
let _ = surface.commit(&mut state.pool);
|
if surface.update() {
|
||||||
|
let _ = surface.commit(&mut state.pool);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if state.exit {
|
if state.exit {
|
||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 hit‑testing (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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
data[dst_offset..dst_offset + pixel_width]
|
let copy_end = (src_offset + pixel_width).min(canvas_end);
|
||||||
.copy_from_slice(&canvas[src_offset..src_offset + pixel_width]);
|
if copy_end > src_offset {
|
||||||
|
data[dst_offset..dst_offset + pixel_width]
|
||||||
|
.copy_from_slice(&canvas[src_offset..copy_end]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
data
|
data
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,26 +254,69 @@ impl SystemManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(command) = cmd_rx.recv() => {
|
Some(cmd) = cmd_rx.recv() => {
|
||||||
if let Some(ref c) = conn {
|
match cmd {
|
||||||
let method = match command {
|
BackendCommand::PowerOff
|
||||||
SystemCommand::PowerOff => "PowerOff",
|
| BackendCommand::Reboot
|
||||||
SystemCommand::Reboot => "Reboot",
|
| BackendCommand::Suspend => {
|
||||||
SystemCommand::Suspend => "Suspend",
|
if let Some(ref c) = conn {
|
||||||
};
|
// Safety: only PowerOff/Reboot/Suspend reach this
|
||||||
debug!("Executing system command: {}", method);
|
// branch due to the outer match arm.
|
||||||
let result = tokio::time::timeout(
|
let method = match cmd {
|
||||||
tokio::time::Duration::from_secs(5),
|
BackendCommand::PowerOff => "PowerOff",
|
||||||
c.call_method(
|
BackendCommand::Reboot => "Reboot",
|
||||||
Some("org.freedesktop.login1"),
|
BackendCommand::Suspend => "Suspend",
|
||||||
"/org/freedesktop/login1",
|
BackendCommand::MediaPlayPause
|
||||||
Some("org.freedesktop.login1.Manager"),
|
| BackendCommand::MediaStop
|
||||||
method,
|
| BackendCommand::MediaNext
|
||||||
&(true),
|
| BackendCommand::MediaPrev => {
|
||||||
)
|
unreachable!("media command in power branch: {:?}", cmd)
|
||||||
).await;
|
}
|
||||||
if result.is_err() {
|
};
|
||||||
error!("System command {} timed out", method);
|
debug!("Executing system command: {}", method);
|
||||||
|
let result = tokio::time::timeout(
|
||||||
|
command_timeout,
|
||||||
|
c.call_method(
|
||||||
|
Some("org.freedesktop.login1"),
|
||||||
|
"/org/freedesktop/login1",
|
||||||
|
Some("org.freedesktop.login1.Manager"),
|
||||||
|
method,
|
||||||
|
&(true),
|
||||||
|
)
|
||||||
|
).await;
|
||||||
|
if result.is_err() {
|
||||||
|
error!("System command {} timed out", method);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||