feat: ring shapes, auth reuse, input tests, README docs, CI improvements
- Add --ring-shape (circle/square/diamond/hexagon/pill) with geometry module - Reuse PAM context across auth attempts (no reload per-keystroke) - Proper error propagation in screenshot effects (unwrap → Result + context) - Remove theme presets (modern/pixel/glass) and --theme flag - Add input cooldown after failed attempt (400ms debounce) - Add Ctrl+held peek password, Home/End/Delete cursor keys - Add all input unit tests (23 new tests) - Add --max-dots, feedback duration, timeout, interval config options - Add --log-path, --auth-timeout, verifying_color config fields - Remove unused deps: futures, gio, env_logger, num-traits, thiserror, bytemuck - Fix media bar: remove stop button, fix art+text overlap, uniform hit areas - Fix CI: source-only releases, nightly prereleases, dependabot groups, labeler paths - Update issue templates with structured forms - Add stale workflow for inactive issues/PRs - Update README: full options table (28 flags), remove theme docs, add shapes
This commit is contained in:
@@ -1,23 +1,55 @@
|
|||||||
# Bug Report
|
---
|
||||||
|
name: Bug Report
|
||||||
|
about: Report a crash, visual glitch, or unexpected behavior
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
**Description**
|
## Description
|
||||||
|
|
||||||
|
A clear and concise description of the bug.
|
||||||
|
|
||||||
**Steps to Reproduce**
|
## Steps to Reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
**Expected Behavior**
|
## Expected Behavior
|
||||||
|
|
||||||
|
What did you expect to happen?
|
||||||
|
|
||||||
**Actual Behavior**
|
## Actual Behavior
|
||||||
|
|
||||||
|
What actually happened? Include any error messages, visual glitches, or crashes.
|
||||||
|
|
||||||
**Environment**
|
## Screenshots / Screen Recordings
|
||||||
- OS:
|
|
||||||
- Rust version:
|
|
||||||
- RustLock version:
|
|
||||||
|
|
||||||
**Logs**
|
If applicable, add screenshots or recordings to help explain the problem.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
**Additional Context**
|
- **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
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
# Feature Request
|
---
|
||||||
|
name: Feature Request
|
||||||
|
about: Suggest an idea for rustlock
|
||||||
|
labels: enhancement
|
||||||
|
---
|
||||||
|
|
||||||
**Is your feature request related to a problem? Please describe.**
|
**Is your feature request related to a problem? Please describe.**
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -1,5 +1,3 @@
|
|||||||
# Dependabot configuration for RustLock
|
|
||||||
|
|
||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
updates:
|
updates:
|
||||||
@@ -19,6 +17,13 @@ updates:
|
|||||||
reviewers:
|
reviewers:
|
||||||
- "JorySeverijnse"
|
- "JorySeverijnse"
|
||||||
versioning-strategy: "increase"
|
versioning-strategy: "increase"
|
||||||
|
groups:
|
||||||
|
rust-dependencies:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|
||||||
- package-ecosystem: "github-actions"
|
- package-ecosystem: "github-actions"
|
||||||
directory: "/"
|
directory: "/"
|
||||||
@@ -30,3 +35,10 @@ updates:
|
|||||||
labels:
|
labels:
|
||||||
- "dependencies"
|
- "dependencies"
|
||||||
- "github-actions"
|
- "github-actions"
|
||||||
|
groups:
|
||||||
|
actions-dependencies:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
update-types:
|
||||||
|
- "minor"
|
||||||
|
- "patch"
|
||||||
|
|||||||
+2
-2
@@ -4,7 +4,7 @@ documentation:
|
|||||||
|
|
||||||
features:
|
features:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ['src/input.rs', 'src/lock.rs', 'src/config.rs']
|
- any-glob-to-any-file: ['src/input.rs', 'src/lock.rs', 'src/config.rs', 'src/auth.rs', 'src/screenshot.rs', 'src/system.rs']
|
||||||
|
|
||||||
rendering:
|
rendering:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
@@ -20,4 +20,4 @@ dependencies:
|
|||||||
|
|
||||||
refactoring:
|
refactoring:
|
||||||
- changed-files:
|
- changed-files:
|
||||||
- any-glob-to-any-file: ['src/util.rs', 'src/system.rs', 'src/main.rs']
|
- 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,35 @@
|
|||||||
|
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@v6
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- 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: Create nightly release
|
||||||
|
uses: softprops/action-gh-release@v3
|
||||||
|
with:
|
||||||
|
tag_name: nightly-${{ steps.version.outputs.VERSION }}
|
||||||
|
name: Nightly Build ${{ steps.version.outputs.VERSION }}
|
||||||
|
prerelease: true
|
||||||
|
generate_release_notes: true
|
||||||
+15
-179
@@ -5,181 +5,13 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
|
||||||
CARGO_TERM_COLOR: always
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release-build-x86:
|
|
||||||
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@v6
|
|
||||||
|
|
||||||
- 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@v7
|
|
||||||
with:
|
|
||||||
name: rustlock-${{ matrix.variant }}-${{ matrix.distro }}
|
|
||||||
path: target/release/rustlock
|
|
||||||
|
|
||||||
release-build-arm64:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
include:
|
|
||||||
- variant: "default"
|
|
||||||
args: ""
|
|
||||||
target: "aarch64-unknown-linux-gnu"
|
|
||||||
- variant: "no-networking"
|
|
||||||
args: "--no-default-features"
|
|
||||||
target: "aarch64-unknown-linux-gnu"
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Install cross-compilation toolchain
|
|
||||||
run: |
|
|
||||||
sudo apt-get update
|
|
||||||
sudo 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 \
|
|
||||||
gcc-aarch64-linux-gnu g++-aarch64-linux-gnu
|
|
||||||
|
|
||||||
- name: Add ARM target
|
|
||||||
run: rustup target add aarch64-unknown-linux-gnu
|
|
||||||
|
|
||||||
- name: Configure cross-compilation linker
|
|
||||||
run: |
|
|
||||||
mkdir -p ~/.cargo
|
|
||||||
echo '[target.aarch64-unknown-linux-gnu]
|
|
||||||
linker = "aarch64-linux-gnu-gcc"' >> ~/.cargo/config.toml
|
|
||||||
|
|
||||||
- name: Build ${{ matrix.variant }} (ARM64)
|
|
||||||
run: cargo build --release --target aarch64-unknown-linux-gnu ${{ matrix.args }}
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-artifact@v7
|
|
||||||
with:
|
|
||||||
name: rustlock-${{ matrix.variant }}-arm64
|
|
||||||
path: target/aarch64-unknown-linux-gnu/release/rustlock
|
|
||||||
|
|
||||||
publish-crates:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: [release-build-x86]
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6
|
|
||||||
|
|
||||||
- name: Install Rust
|
|
||||||
uses: dtolnay/rust-toolchain@stable
|
|
||||||
|
|
||||||
- name: Publish to crates.io
|
|
||||||
env:
|
|
||||||
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
|
||||||
run: cargo publish
|
|
||||||
|
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [release-build-x86, release-build-arm64, publish-crates]
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- uses: actions/checkout@v6
|
||||||
uses: actions/checkout@v6
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Download all artifacts
|
|
||||||
uses: actions/download-artifact@v8
|
|
||||||
with:
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Generate checksums
|
|
||||||
working-directory: artifacts
|
|
||||||
run: |
|
|
||||||
find . -type f -name "rustlock" -exec sha256sum {} \; > SHA256SUMS.txt
|
|
||||||
cat SHA256SUMS.txt
|
|
||||||
|
|
||||||
- name: Import GPG key
|
|
||||||
id: import_gpg
|
|
||||||
uses: crazy-max/ghaction-import-gpg@v7
|
|
||||||
with:
|
|
||||||
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
|
|
||||||
passphrase: ${{ secrets.GPG_PASSPHRASE }}
|
|
||||||
git_config_global: true
|
|
||||||
git_user_name: JorySeverijnse
|
|
||||||
git_user_email: jory@severijnse.com
|
|
||||||
|
|
||||||
- name: Sign checksums
|
|
||||||
working-directory: artifacts
|
|
||||||
run: |
|
|
||||||
gpg --armor --detach-sign SHA256SUMS.txt
|
|
||||||
|
|
||||||
- name: Determine tag
|
- name: Determine tag
|
||||||
id: tag
|
id: tag
|
||||||
@@ -192,14 +24,18 @@ jobs:
|
|||||||
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: |
|
|
||||||
artifacts/rustlock-default-ubuntu/rustlock
|
|
||||||
artifacts/rustlock-no-networking-ubuntu/rustlock
|
|
||||||
artifacts/rustlock-default-debian/rustlock
|
|
||||||
artifacts/rustlock-default-fedora/rustlock
|
|
||||||
artifacts/rustlock-default-arch/rustlock
|
|
||||||
artifacts/rustlock-default-arm64/rustlock
|
|
||||||
artifacts/rustlock-no-networking-arm64/rustlock
|
|
||||||
artifacts/SHA256SUMS.txt
|
|
||||||
artifacts/SHA256SUMS.txt.asc
|
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
|
||||||
|
publish-crates:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [release]
|
||||||
|
if: secrets.CARGO_REGISTRY_TOKEN != ''
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
|
||||||
|
- name: Publish to crates.io
|
||||||
|
env:
|
||||||
|
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
|
||||||
|
run: cargo publish
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ jobs:
|
|||||||
- name: Install security tools
|
- name: Install security tools
|
||||||
uses: taiki-e/install-action@v2
|
uses: taiki-e/install-action@v2
|
||||||
with:
|
with:
|
||||||
tool: cargo-audit,cargo-deny,cargo-cyclonedx,cargo-outdated
|
tool: cargo-audit,cargo-deny,cargo-outdated
|
||||||
|
|
||||||
- name: Run cargo audit
|
- name: Run cargo audit
|
||||||
run: cargo audit
|
run: cargo audit
|
||||||
@@ -37,15 +37,6 @@ 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@v7
|
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
Generated
-140
@@ -26,46 +26,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anstyle"
|
name = "anstyle"
|
||||||
version = "1.0.14"
|
version = "1.0.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
dependencies = [
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-wincon"
|
|
||||||
version = "3.0.11"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"once_cell_polyfill",
|
|
||||||
"windows-sys 0.61.2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.102"
|
version = "1.0.102"
|
||||||
@@ -366,12 +332,6 @@ version = "1.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "concurrent-queue"
|
name = "concurrent-queue"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -552,28 +512,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -705,20 +643,6 @@ dependencies = [
|
|||||||
"syn 1.0.109",
|
"syn 1.0.109",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures"
|
|
||||||
version = "0.3.32"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d"
|
|
||||||
dependencies = [
|
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"futures-sink",
|
|
||||||
"futures-task",
|
|
||||||
"futures-util",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures-channel"
|
name = "futures-channel"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@@ -794,7 +718,6 @@ version = "0.3.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-io",
|
"futures-io",
|
||||||
"futures-macro",
|
"futures-macro",
|
||||||
@@ -1290,12 +1213,6 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "is_terminal_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.12.1"
|
version = "0.12.1"
|
||||||
@@ -1311,30 +1228,6 @@ version = "1.0.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote 1.0.45",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.91"
|
version = "0.3.91"
|
||||||
@@ -1539,12 +1432,6 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ordered-stream"
|
name = "ordered-stream"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1705,21 +1592,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "091397be61a01d4be58e7841595bd4bfedb15f1cd54977d79b8271e94ed799a3"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -2085,21 +1957,16 @@ name = "rustlock"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"bytemuck",
|
|
||||||
"cairo-rs",
|
"cairo-rs",
|
||||||
"calloop",
|
"calloop",
|
||||||
"calloop-wayland-source",
|
"calloop-wayland-source",
|
||||||
"chrono",
|
"chrono",
|
||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
|
||||||
"fastblur",
|
"fastblur",
|
||||||
"futures",
|
|
||||||
"gdk-pixbuf",
|
"gdk-pixbuf",
|
||||||
"gio",
|
|
||||||
"image",
|
"image",
|
||||||
"log",
|
"log",
|
||||||
"mpris",
|
"mpris",
|
||||||
"num-traits",
|
|
||||||
"pam-client",
|
"pam-client",
|
||||||
"pangocairo",
|
"pangocairo",
|
||||||
"rand 0.10.1",
|
"rand 0.10.1",
|
||||||
@@ -2107,7 +1974,6 @@ dependencies = [
|
|||||||
"resvg",
|
"resvg",
|
||||||
"serde",
|
"serde",
|
||||||
"smithay-client-toolkit",
|
"smithay-client-toolkit",
|
||||||
"thiserror 1.0.69",
|
|
||||||
"tiny-skia",
|
"tiny-skia",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml 1.1.2+spec-1.1.0",
|
"toml 1.1.2+spec-1.1.0",
|
||||||
@@ -2833,12 +2699,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8parse"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.0"
|
version = "1.23.0"
|
||||||
|
|||||||
@@ -12,30 +12,24 @@ 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"] }
|
||||||
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.10" }
|
rand = { version = "0.10" }
|
||||||
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"
|
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"
|
||||||
|
|||||||
@@ -42,8 +42,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 (modern, pixel, glass)
|
|
||||||
- Configuration via config file or CLI
|
- Configuration via config file or CLI
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -65,6 +65,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 \
|
||||||
@@ -72,6 +73,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
|
||||||
```
|
```
|
||||||
@@ -96,51 +99,77 @@ Use arrow keys to move the cursor while entering your 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 |
|
| `--effect-melting <F>` | — | Melting distortion with factor |
|
||||||
| **Display Options** | |
|
| **Colors** (hex `RRGGBB[AA]`) | | |
|
||||||
| `--show-media` | Show MPRIS media info (default: true) |
|
| `--ring-color <HEX>` | `#785412` | Outer ring color |
|
||||||
| `--show-battery` | Show battery status (default: true) |
|
| `--line-color <HEX>` | `#00000000` | Separator line color |
|
||||||
| `--show-network` | Show WiFi status (default: true) |
|
| `--inside-color <HEX>` | `#00000088` | Inner circle fill color |
|
||||||
| `--show-bluetooth` | Show Bluetooth status (default: true) |
|
| `--separator-color <HEX>` | `#00000000` | Ring segment separator color |
|
||||||
| `--show-keyboard-layout` | Show keyboard layout indicator (default: true) |
|
| `--key-hl-color <HEX>` | `#4EAC41` | Key highlight segment color |
|
||||||
| `--show-album-art` | Show album art (default: true) |
|
| `--caps-lock-key-hl-color <HEX>` | `#4EAC41` | Key highlight color when caps lock is on |
|
||||||
| `--hide-password` | Hide password dots (default: false, dots are shown) |
|
| `--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` | `true` | Show "CAPS" text when caps lock is active |
|
||||||
| **Other** | |
|
| **Display** | | |
|
||||||
| `--grace <SECONDS>` | Grace period in seconds (default: 2) |
|
| `--show-media` | `true` | Show MPRIS media player information |
|
||||||
| `--fade-in <SECONDS>` | Fade-in animation duration (default: 0.2) |
|
| `--show-battery` | `true` | Show battery status |
|
||||||
| `--pam-service <NAME>` | PAM service name (default: "rustlock") |
|
| `--show-network` | `true` | Show WiFi SSID and signal strength |
|
||||||
| `--config <PATH>` | Path to config file |
|
| `--show-bluetooth` | `true` | Show Bluetooth status |
|
||||||
| `--theme <NAME>` | Theme preset: modern, pixel, glass |
|
| `--show-album-art` | `true` | Show album art for media |
|
||||||
| `--debug` | Enable debug logging |
|
| `--show-keyboard-layout` | `true` | 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) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+51
-41
@@ -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,9 +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)>,
|
||||||
pub struct LockConversation {
|
channel::Channel<(bool, u64)>,
|
||||||
|
);pub struct LockConversation {
|
||||||
pub password: Option<Zeroizing<String>>,
|
pub password: Option<Zeroizing<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,59 +40,65 @@ impl pam_client::ConversationHandler for LockConversation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_and_run_auth_loop(
|
pub fn create_and_run_auth_loop(
|
||||||
) -> Option<(channel::Sender<Zeroizing<String>>, channel::Channel<bool>)> {
|
service_name: String,
|
||||||
|
) -> Option<AuthChannels> {
|
||||||
let username = username();
|
let username = username();
|
||||||
|
|
||||||
let conversation = LockConversation { password: None };
|
let (auth_req_send, auth_req_recv) =
|
||||||
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
|
channel::channel::<(Zeroizing<String>, u64)>();
|
||||||
Ok(_) => {
|
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
|
||||||
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))
|
||||||
|
|||||||
+160
-50
@@ -1,7 +1,49 @@
|
|||||||
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(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 +63,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",
|
||||||
@@ -91,6 +137,13 @@ pub struct Config {
|
|||||||
)]
|
)]
|
||||||
pub caps_lock_text_color: (f64, f64, f64, f64),
|
pub caps_lock_text_color: (f64, f64, f64, f64),
|
||||||
|
|
||||||
|
#[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, default_value_t = true)]
|
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
|
||||||
pub show_caps_lock_text: bool,
|
pub show_caps_lock_text: bool,
|
||||||
|
|
||||||
@@ -115,7 +168,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,6 +187,15 @@ pub struct Config {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub log_file: bool,
|
pub log_file: bool,
|
||||||
|
|
||||||
|
/// 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, default_value_t = true)]
|
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
|
||||||
pub show_media: bool,
|
pub show_media: bool,
|
||||||
|
|
||||||
@@ -191,10 +253,46 @@ 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,
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -209,7 +307,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 +340,63 @@ 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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+295
-13
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -116,6 +124,10 @@ 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()
|
||||||
}
|
}
|
||||||
@@ -127,12 +139,19 @@ impl InputHandler {
|
|||||||
/// 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
|
||||||
}
|
}
|
||||||
@@ -146,15 +165,12 @@ 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
|
||||||
@@ -171,3 +187,269 @@ pub enum InputAction {
|
|||||||
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+71
-28
@@ -30,6 +30,7 @@ pub struct LockedSurface {
|
|||||||
dirty: bool,
|
dirty: bool,
|
||||||
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
|
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
|
||||||
last_minute: i64,
|
last_minute: i64,
|
||||||
|
ctrl_held: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl LockedSurface {
|
impl LockedSurface {
|
||||||
@@ -62,6 +63,7 @@ impl LockedSurface {
|
|||||||
configured: false,
|
configured: false,
|
||||||
dirty: true,
|
dirty: true,
|
||||||
last_minute: i64::MIN,
|
last_minute: i64::MIN,
|
||||||
|
ctrl_held: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,8 +88,6 @@ impl LockedSurface {
|
|||||||
/// surface (no input, no animation, same clock minute) returns `false` and
|
/// surface (no input, no animation, same clock minute) returns `false` and
|
||||||
/// does no cairo work, which keeps a locked session near-zero CPU.
|
/// does no cairo work, which keeps a locked session near-zero CPU.
|
||||||
pub fn update(&mut self) -> bool {
|
pub fn update(&mut self) -> bool {
|
||||||
self.input_handler.update();
|
|
||||||
|
|
||||||
if !self.configured {
|
if !self.configured {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -96,30 +96,37 @@ impl LockedSurface {
|
|||||||
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);
|
||||||
// Ease-in-out cubic function
|
|
||||||
let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0);
|
if fade_duration.is_zero() {
|
||||||
let eased_t = if t < 0.5 {
|
self.fade_alpha = 1.0;
|
||||||
4.0 * t * t * t
|
self.renderer.set_fade_alpha(1.0);
|
||||||
|
self.dirty = true;
|
||||||
} else {
|
} else {
|
||||||
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
// Ease-in-out cubic function
|
||||||
};
|
let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0);
|
||||||
let new_alpha = eased_t.min(1.0);
|
let eased_t = if t < 0.5 {
|
||||||
if t >= 1.0 {
|
4.0 * t * t * t
|
||||||
// The eased curve only approaches 1.0 asymptotically, and the
|
} else {
|
||||||
// 0.001 throttle below suppresses the tiny final steps — which
|
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
|
||||||
// would leave fade_alpha stuck just under 1.0 forever. Since
|
};
|
||||||
// `fade_alpha < 1.0` is our "still animating" signal, that would
|
let new_alpha = eased_t.min(1.0);
|
||||||
// force a full render every frame. Snap to exactly 1.0 once the
|
if t >= 1.0 {
|
||||||
// fade duration has elapsed so the animation cleanly completes.
|
// The eased curve only approaches 1.0 asymptotically, and the
|
||||||
if self.fade_alpha != 1.0 {
|
// 0.001 throttle below suppresses the tiny final steps — which
|
||||||
self.fade_alpha = 1.0;
|
// would leave fade_alpha stuck just under 1.0 forever. Since
|
||||||
self.renderer.set_fade_alpha(1.0);
|
// `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;
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +157,7 @@ impl LockedSurface {
|
|||||||
// 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;
|
self.dirty = true;
|
||||||
@@ -174,8 +181,15 @@ impl LockedSurface {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
self.renderer
|
if !self.config.hide_password {
|
||||||
.set_password_display(self.input_handler.password_length());
|
let length = self.input_handler.password_length();
|
||||||
|
if self.ctrl_held {
|
||||||
|
self.renderer
|
||||||
|
.peek_password(self.input_handler.password_buffer().as_str());
|
||||||
|
} else {
|
||||||
|
self.renderer.set_password_display(length);
|
||||||
|
}
|
||||||
|
}
|
||||||
self.renderer
|
self.renderer
|
||||||
.set_cursor_position(self.input_handler.cursor_position());
|
.set_cursor_position(self.input_handler.cursor_position());
|
||||||
self.renderer.render();
|
self.renderer.render();
|
||||||
@@ -217,14 +231,24 @@ impl LockedSurface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@@ -271,6 +295,13 @@ impl LockedSurface {
|
|||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_ctrl_held(&mut self, held: bool) {
|
||||||
|
if self.ctrl_held != held {
|
||||||
|
self.ctrl_held = held;
|
||||||
|
self.dirty = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LockManager {
|
pub struct LockManager {
|
||||||
@@ -321,12 +352,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);
|
||||||
|
|||||||
+154
-68
@@ -48,21 +48,40 @@ 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,8 +156,10 @@ 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");
|
||||||
@@ -148,13 +172,13 @@ impl WaylandLock {
|
|||||||
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();
|
||||||
@@ -193,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;
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -220,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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -470,6 +508,9 @@ impl KeyboardHandler for WaylandLock {
|
|||||||
) {
|
) {
|
||||||
self.modifiers = modifiers;
|
self.modifiers = modifiers;
|
||||||
self.current_layout = layout;
|
self.current_layout = layout;
|
||||||
|
if let Ok(mut lock_manager) = self.lock_manager.lock() {
|
||||||
|
lock_manager.set_ctrl_held(modifiers.ctrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -552,7 +593,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 {
|
||||||
@@ -581,7 +625,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);
|
||||||
@@ -601,7 +649,9 @@ 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());
|
||||||
}
|
}
|
||||||
@@ -683,9 +733,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)]
|
||||||
@@ -699,13 +755,14 @@ 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);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -740,6 +797,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,
|
||||||
@@ -753,31 +813,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 {:?}",
|
||||||
@@ -811,8 +881,11 @@ 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 state.auth_pending_seq == Some(seq) {
|
||||||
|
state.handle_auth_result(success);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
@@ -825,6 +898,19 @@ 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());
|
||||||
|
|
||||||
|
|||||||
+93
-26
@@ -1,7 +1,32 @@
|
|||||||
|
use crate::render::ring_shape;
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
impl Renderer {
|
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) {
|
pub(crate) fn draw_wrong_password_feedback(&self) {
|
||||||
let center_x = self.width as f64 / 2.0;
|
let center_x = self.width as f64 / 2.0;
|
||||||
let center_y = self.height as f64 / 2.0;
|
let center_y = self.height as f64 / 2.0;
|
||||||
@@ -9,7 +34,7 @@ impl Renderer {
|
|||||||
let thickness = self.config.indicator_thickness as f64;
|
let thickness = self.config.indicator_thickness as f64;
|
||||||
let intensity = if let Some(start) = self.wrong_password_start {
|
let intensity = if let Some(start) = self.wrong_password_start {
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let duration = std::time::Duration::from_millis(500);
|
let duration = std::time::Duration::from_millis(self.config.wrong_password_duration);
|
||||||
if elapsed < duration {
|
if elapsed < duration {
|
||||||
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
||||||
} else {
|
} else {
|
||||||
@@ -24,9 +49,15 @@ impl Renderer {
|
|||||||
self.context
|
self.context
|
||||||
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
|
.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_width(thickness + 2.0);
|
||||||
self.context
|
self.context.set_line_join(cairo::LineJoin::Round);
|
||||||
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
|
ring_shape::build_ring_path(
|
||||||
self.context.stroke().unwrap();
|
&self.context,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
radius,
|
||||||
|
self.config.ring_shape,
|
||||||
|
);
|
||||||
|
render_try!(self.context.stroke());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +68,7 @@ impl Renderer {
|
|||||||
let thickness = self.config.indicator_thickness as f64;
|
let thickness = self.config.indicator_thickness as f64;
|
||||||
let intensity = if let Some(start) = self.key_highlight_start {
|
let intensity = if let Some(start) = self.key_highlight_start {
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let duration = std::time::Duration::from_millis(300);
|
let duration = std::time::Duration::from_millis(self.config.key_highlight_duration);
|
||||||
if elapsed < duration {
|
if elapsed < duration {
|
||||||
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
||||||
} else {
|
} else {
|
||||||
@@ -57,17 +88,26 @@ impl Renderer {
|
|||||||
.set_source_rgba(r, g, b, a * intensity * self.fade_alpha);
|
.set_source_rgba(r, g, b, a * intensity * self.fade_alpha);
|
||||||
self.context.set_line_width(thickness + 1.5);
|
self.context.set_line_width(thickness + 1.5);
|
||||||
|
|
||||||
let global_offset = (self.password_display.len() as f64 * 45.0).to_radians();
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
let actual_start = global_offset + self.key_highlight_angle;
|
self.context.set_line_cap(cairo::LineCap::Round);
|
||||||
self.context.arc(
|
// 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_x,
|
||||||
center_y,
|
center_y,
|
||||||
radius,
|
radius,
|
||||||
actual_start,
|
self.config.ring_shape,
|
||||||
actual_start + (40.0_f64).to_radians(),
|
t_start,
|
||||||
|
t_end,
|
||||||
);
|
);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +118,7 @@ impl Renderer {
|
|||||||
let thickness = self.config.indicator_thickness as f64;
|
let thickness = self.config.indicator_thickness as f64;
|
||||||
let intensity = if let Some(start) = self.cleared_feedback_start {
|
let intensity = if let Some(start) = self.cleared_feedback_start {
|
||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
let duration = std::time::Duration::from_millis(500);
|
let duration = std::time::Duration::from_millis(self.config.cleared_feedback_duration);
|
||||||
if elapsed < duration {
|
if elapsed < duration {
|
||||||
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
|
||||||
} else {
|
} else {
|
||||||
@@ -92,32 +132,39 @@ impl Renderer {
|
|||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context
|
self.context
|
||||||
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha * 0.5);
|
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha * 0.5);
|
||||||
self.context.arc(
|
ring_shape::build_fill_path(
|
||||||
|
&self.context,
|
||||||
center_x,
|
center_x,
|
||||||
center_y,
|
center_y,
|
||||||
radius - thickness / 2.0,
|
radius - thickness / 2.0,
|
||||||
0.0,
|
thickness,
|
||||||
2.0 * std::f64::consts::PI,
|
self.config.ring_shape,
|
||||||
);
|
);
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context
|
self.context
|
||||||
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
|
.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_width(thickness + 4.0);
|
||||||
self.context
|
self.context.set_line_join(cairo::LineJoin::Round);
|
||||||
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
|
ring_shape::build_ring_path(
|
||||||
self.context.stroke().unwrap();
|
&self.context,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
radius,
|
||||||
|
self.config.ring_shape,
|
||||||
|
);
|
||||||
|
render_try!(self.context.stroke());
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_font_size(24.0);
|
self.context.set_font_size(24.0);
|
||||||
self.context
|
self.context
|
||||||
.set_source_rgba(1.0, 1.0, 1.0, intensity * self.fade_alpha);
|
.set_source_rgba(1.0, 1.0, 1.0, intensity * self.fade_alpha);
|
||||||
let text = "CLEARED";
|
let text = "CLEARED";
|
||||||
let te = self.context.text_extents(text).unwrap();
|
let te = render_try!(self.context.text_extents(text));
|
||||||
self.context
|
self.context
|
||||||
.move_to(center_x - te.width() / 2.0, center_y - radius - 20.0);
|
.move_to(center_x - te.width() / 2.0, center_y - radius - 20.0);
|
||||||
self.context.show_text(text).unwrap();
|
render_try!(self.context.show_text(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,33 +174,43 @@ impl Renderer {
|
|||||||
self.wrong_password_start.is_some()
|
self.wrong_password_start.is_some()
|
||||||
|| self.key_highlight_start.is_some()
|
|| self.key_highlight_start.is_some()
|
||||||
|| self.cleared_feedback_start.is_some()
|
|| self.cleared_feedback_start.is_some()
|
||||||
|
|| self.verifying_start.is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn update_feedback_timers(&mut self) {
|
pub(crate) fn update_feedback_timers(&mut self) {
|
||||||
self.update_uptime();
|
self.update_uptime();
|
||||||
if let Some(start) = self.wrong_password_start {
|
if let Some(start) = self.wrong_password_start {
|
||||||
if start.elapsed() > std::time::Duration::from_millis(500) {
|
if start.elapsed() > std::time::Duration::from_millis(self.config.wrong_password_duration) {
|
||||||
self.wrong_password_shown = false;
|
self.wrong_password_shown = false;
|
||||||
self.wrong_password_start = None;
|
self.wrong_password_start = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(start) = self.key_highlight_start {
|
if let Some(start) = self.key_highlight_start {
|
||||||
if start.elapsed() > std::time::Duration::from_millis(300) {
|
if start.elapsed() > std::time::Duration::from_millis(self.config.key_highlight_duration) {
|
||||||
self.key_highlight_shown = false;
|
self.key_highlight_shown = false;
|
||||||
self.key_highlight_start = None;
|
self.key_highlight_start = None;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(start) = self.cleared_feedback_start {
|
if let Some(start) = self.cleared_feedback_start {
|
||||||
if start.elapsed() > std::time::Duration::from_millis(500) {
|
if start.elapsed() > std::time::Duration::from_millis(self.config.cleared_feedback_duration) {
|
||||||
self.cleared_feedback_shown = false;
|
self.cleared_feedback_shown = false;
|
||||||
self.cleared_feedback_start = None;
|
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) {
|
pub fn show_wrong_password(&mut self) {
|
||||||
self.wrong_password_shown = true;
|
self.wrong_password_shown = true;
|
||||||
self.wrong_password_start = Some(Instant::now());
|
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) {
|
pub fn show_key_highlight(&mut self) {
|
||||||
@@ -162,8 +219,8 @@ impl Renderer {
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
let seed = SystemTime::now()
|
let seed = SystemTime::now()
|
||||||
.duration_since(SystemTime::UNIX_EPOCH)
|
.duration_since(SystemTime::UNIX_EPOCH)
|
||||||
.unwrap()
|
.map(|d| d.as_nanos() as u64)
|
||||||
.as_nanos() as u64;
|
.unwrap_or(0);
|
||||||
let random_val = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
let random_val = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
|
||||||
self.key_highlight_angle = ((random_val % 360) as f64).to_radians();
|
self.key_highlight_angle = ((random_val % 360) as f64).to_radians();
|
||||||
}
|
}
|
||||||
@@ -172,4 +229,14 @@ impl Renderer {
|
|||||||
self.cleared_feedback_shown = true;
|
self.cleared_feedback_shown = true;
|
||||||
self.cleared_feedback_start = Some(Instant::now());
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-26
@@ -1,3 +1,4 @@
|
|||||||
|
use crate::render::ring_shape;
|
||||||
use crate::render::Renderer;
|
use crate::render::Renderer;
|
||||||
|
|
||||||
impl Renderer {
|
impl Renderer {
|
||||||
@@ -6,35 +7,40 @@ impl Renderer {
|
|||||||
let center_y = self.height as f64 / 2.0;
|
let center_y = self.height as f64 / 2.0;
|
||||||
let radius = self.config.indicator_radius as f64;
|
let radius = self.config.indicator_radius as f64;
|
||||||
let thickness = self.config.indicator_thickness as f64;
|
let thickness = self.config.indicator_thickness as f64;
|
||||||
|
let shape = self.config.ring_shape;
|
||||||
|
|
||||||
|
// Filled center
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
let (r, g, b, a) = self.config.inside_color;
|
let (r, g, b, a) = self.config.inside_color;
|
||||||
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
||||||
self.context.arc(
|
ring_shape::build_fill_path(
|
||||||
|
&self.context,
|
||||||
center_x,
|
center_x,
|
||||||
center_y,
|
center_y,
|
||||||
radius - thickness / 2.0,
|
radius - thickness / 2.0,
|
||||||
0.0,
|
thickness,
|
||||||
2.0 * std::f64::consts::PI,
|
shape,
|
||||||
);
|
);
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
|
|
||||||
|
// Separator line behind the ring
|
||||||
let (lr, lg, lb, la) = self.config.line_color;
|
let (lr, lg, lb, la) = self.config.line_color;
|
||||||
if la > 0.0 {
|
if la > 0.0 {
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context
|
self.context
|
||||||
.set_source_rgba(lr, lg, lb, la * self.fade_alpha);
|
.set_source_rgba(lr, lg, lb, la * self.fade_alpha);
|
||||||
self.context.set_line_width(1.0);
|
self.context.set_line_width(1.0);
|
||||||
self.context.arc(
|
ring_shape::build_ring_path(
|
||||||
|
&self.context,
|
||||||
center_x,
|
center_x,
|
||||||
center_y,
|
center_y,
|
||||||
radius - thickness / 2.0,
|
radius - thickness / 2.0,
|
||||||
0.0,
|
shape,
|
||||||
2.0 * std::f64::consts::PI,
|
|
||||||
);
|
);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Outer ring
|
||||||
let (r, g, b, a) = if self.caps_lock {
|
let (r, g, b, a) = if self.caps_lock {
|
||||||
self.config.caps_lock_color
|
self.config.caps_lock_color
|
||||||
} else {
|
} else {
|
||||||
@@ -43,10 +49,11 @@ impl Renderer {
|
|||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
||||||
self.context.set_line_width(thickness);
|
self.context.set_line_width(thickness);
|
||||||
self.context
|
self.context.set_line_join(cairo::LineJoin::Round);
|
||||||
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
|
ring_shape::build_ring_path(&self.context, center_x, center_y, radius, shape);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
|
|
||||||
|
// Separator line through center
|
||||||
let (r, g, b, a) = self.config.separator_color;
|
let (r, g, b, a) = self.config.separator_color;
|
||||||
if a > 0.0 {
|
if a > 0.0 {
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
@@ -54,7 +61,7 @@ impl Renderer {
|
|||||||
self.context.set_line_width(1.0);
|
self.context.set_line_width(1.0);
|
||||||
self.context.move_to(center_x - radius, center_y);
|
self.context.move_to(center_x - radius, center_y);
|
||||||
self.context.line_to(center_x + radius, center_y);
|
self.context.line_to(center_x + radius, center_y);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +73,7 @@ impl Renderer {
|
|||||||
let center_y = self.height as f64 / 2.0;
|
let center_y = self.height as f64 / 2.0;
|
||||||
let radius = self.config.indicator_radius as f64;
|
let radius = self.config.indicator_radius as f64;
|
||||||
let thickness = self.config.indicator_thickness as f64;
|
let thickness = self.config.indicator_thickness as f64;
|
||||||
|
let shape = self.config.ring_shape;
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
@@ -75,33 +83,41 @@ impl Renderer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let max_dots = self.config.max_dots as f64;
|
||||||
let dot_radius = radius - thickness - 10.0;
|
let dot_radius = radius - thickness - 10.0;
|
||||||
let angle_step = (360.0 / 24.0_f64).to_radians();
|
let t_offset = ring_shape::top_centre_offset(shape);
|
||||||
|
|
||||||
for i in 0..count {
|
for i in 0..count {
|
||||||
let angle = (i as f64 * angle_step) - std::f64::consts::FRAC_PI_2;
|
let t = (i as f64 / max_dots) + t_offset;
|
||||||
let x = center_x + dot_radius * angle.cos();
|
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
|
||||||
let y = center_y + dot_radius * angle.sin();
|
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI);
|
self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI);
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cursor indicator
|
||||||
if self.fade_alpha > 0.0 && self.cursor_position > 0 {
|
if self.fade_alpha > 0.0 && self.cursor_position > 0 {
|
||||||
let cursor_angle =
|
let cursor_t = ((self.cursor_position as f64 - 0.5) / max_dots) + t_offset;
|
||||||
((self.cursor_position as f64 - 0.5) * angle_step) - std::f64::consts::FRAC_PI_2;
|
let (cx, cy) =
|
||||||
let x1 = center_x + (dot_radius - 8.0) * cursor_angle.cos();
|
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
|
||||||
let y1 = center_y + (dot_radius - 8.0) * cursor_angle.sin();
|
let dx = cx - center_x;
|
||||||
let x2 = center_x + (dot_radius + 8.0) * cursor_angle.cos();
|
let dy = cy - center_y;
|
||||||
let y2 = center_y + (dot_radius + 8.0) * cursor_angle.sin();
|
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.new_path();
|
||||||
self.context.set_source_rgba(0.0, 0.8, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(0.0, 0.8, 1.0, self.fade_alpha);
|
||||||
self.context.set_line_width(2.0);
|
self.context.set_line_width(2.0);
|
||||||
self.context.move_to(x1, y1);
|
self.context.move_to(x1, y1);
|
||||||
self.context.line_to(x2, y2);
|
self.context.line_to(x2, y2);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,9 +130,9 @@ impl Renderer {
|
|||||||
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
|
||||||
self.context.set_font_size(24.0);
|
self.context.set_font_size(24.0);
|
||||||
let text = "Caps Lock";
|
let text = "Caps Lock";
|
||||||
let te = self.context.text_extents(text).unwrap();
|
let te = render_try!(self.context.text_extents(text));
|
||||||
self.context
|
self.context
|
||||||
.move_to(center_x - te.width() / 2.0, center_y - radius - 10.0);
|
.move_to(center_x - te.width() / 2.0, center_y - radius - 10.0);
|
||||||
self.context.show_text(text).unwrap();
|
render_try!(self.context.show_text(text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-80
@@ -7,7 +7,6 @@ impl Renderer {
|
|||||||
let center_x = self.width as f64 / 2.0;
|
let center_x = self.width as f64 / 2.0;
|
||||||
let start_y = self.height as f64 - 120.0;
|
let start_y = self.height as f64 - 120.0;
|
||||||
let art_size = 56.0;
|
let art_size = 56.0;
|
||||||
let spacing = 80.0;
|
|
||||||
|
|
||||||
if self.config.show_album_art && self.system_status.media_art_url != self.last_art_url {
|
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.last_art_url = self.system_status.media_art_url.clone();
|
||||||
@@ -16,45 +15,30 @@ impl Renderer {
|
|||||||
if let Ok(img) = image::load_from_memory(data) {
|
if let Ok(img) = image::load_from_memory(data) {
|
||||||
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) = ImageSurface::create(Format::ARgb32, w as i32, h as i32) {
|
||||||
ImageSurface::create(Format::ARgb32, w as i32, h as i32).unwrap();
|
if let Ok(mut surface_data) = surface.data() {
|
||||||
{
|
for y in 0..h {
|
||||||
let mut surface_data = surface.data().unwrap();
|
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];
|
||||||
let idx = ((y * w + x) * 4) as usize;
|
surface_data[idx + 1] = pixel[1];
|
||||||
surface_data[idx] = pixel[2];
|
surface_data[idx + 2] = pixel[0];
|
||||||
surface_data[idx + 1] = pixel[1];
|
surface_data[idx + 3] = pixel[3];
|
||||||
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");
|
||||||
}
|
}
|
||||||
self.media_art_surface = Some(surface);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let has_art = self.config.show_album_art && self.media_art_surface.is_some();
|
let has_art = self.config.show_album_art && self.media_art_surface.is_some();
|
||||||
let text_x = if has_art {
|
|
||||||
center_x - spacing / 2.0
|
|
||||||
} else {
|
|
||||||
center_x
|
|
||||||
};
|
|
||||||
let art_x = center_x - spacing - art_size / 2.0;
|
|
||||||
|
|
||||||
if has_art {
|
|
||||||
if let Some(ref art) = self.media_art_surface {
|
|
||||||
self.context.save().unwrap();
|
|
||||||
let scale = art_size / art.width() as f64;
|
|
||||||
self.context.translate(art_x, start_y);
|
|
||||||
self.context.scale(scale, scale);
|
|
||||||
self.context.set_source_surface(art, 0.0, 0.0).unwrap();
|
|
||||||
self.context.paint_with_alpha(self.fade_alpha).unwrap();
|
|
||||||
self.context.restore().unwrap();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context
|
self.context
|
||||||
@@ -67,65 +51,66 @@ impl Renderer {
|
|||||||
title.clone()
|
title.clone()
|
||||||
};
|
};
|
||||||
|
|
||||||
let te = self.context.text_extents(&display_text).unwrap();
|
let te = render_try!(self.context.text_extents(&display_text));
|
||||||
self.context
|
|
||||||
.move_to(text_x - te.width() / 2.0, start_y + 20.0);
|
|
||||||
self.context.show_text(&display_text).unwrap();
|
|
||||||
|
|
||||||
if self.system_status.media_playing {
|
// Center art + text as a group with 16px gap between them
|
||||||
if let Some(ref icon) = self.media_pause_icon_surface {
|
let art_text_gap = 16.0;
|
||||||
let pause_y = start_y + 40.0;
|
let group_width = te.width() + art_size + art_text_gap;
|
||||||
let rx = center_x - icon.width() as f64 / 2.0;
|
let group_start_x = center_x - group_width / 2.0;
|
||||||
let ry = pause_y - icon.height() as f64 / 2.0;
|
let art_x = group_start_x;
|
||||||
self.draw_icon_at(rx, ry, icon);
|
let text_center_x = art_x + art_size + art_text_gap + te.width() / 2.0;
|
||||||
self.media_rects.push((
|
|
||||||
"play_pause",
|
if has_art {
|
||||||
rx,
|
if let Some(ref art) = self.media_art_surface {
|
||||||
ry,
|
render_try!(self.context.save());
|
||||||
icon.width() as f64,
|
let scale = art_size / art.width() as f64;
|
||||||
icon.height() 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());
|
||||||
}
|
}
|
||||||
} else if let Some(ref icon) = self.media_play_icon_surface {
|
|
||||||
let play_y = start_y + 40.0;
|
|
||||||
let rx = center_x - icon.width() as f64 / 2.0;
|
|
||||||
let ry = play_y - icon.height() as f64 / 2.0;
|
|
||||||
self.draw_icon_at(rx, ry, icon);
|
|
||||||
self.media_rects.push((
|
|
||||||
"play_pause",
|
|
||||||
rx,
|
|
||||||
ry,
|
|
||||||
icon.width() as f64,
|
|
||||||
icon.height() as f64,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let controls_y = start_y + 65.0;
|
self.context.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
|
||||||
let icon_size = 20.0;
|
render_try!(self.context.show_text(&display_text));
|
||||||
let icon_spacing = 40.0;
|
|
||||||
|
// 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 {
|
if let Some(ref icon) = self.media_prev_icon_surface {
|
||||||
let rx = center_x - icon_spacing - icon_size / 2.0;
|
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
|
||||||
let ry = controls_y - icon_size / 2.0;
|
self.media_rects.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
|
||||||
self.draw_icon_at(rx, ry, icon);
|
btn_x += btn_gap;
|
||||||
self.media_rects
|
|
||||||
.push(("prev", rx, ry, icon_size, icon_size));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(ref icon) = self.media_stop_icon_surface {
|
// Play/pause — always present (at least one of play/pause icon should load)
|
||||||
let rx = center_x - icon_size / 2.0;
|
if self.system_status.media_playing {
|
||||||
let ry = controls_y - icon_size / 2.0;
|
if let Some(ref icon) = self.media_pause_icon_surface {
|
||||||
self.draw_icon_at(rx, ry, icon);
|
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
|
||||||
self.media_rects
|
self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
|
||||||
.push(("stop", rx, ry, icon_size, icon_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 {
|
if let Some(ref icon) = self.media_next_icon_surface {
|
||||||
let rx = center_x + icon_spacing - icon_size / 2.0;
|
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
|
||||||
let ry = controls_y - icon_size / 2.0;
|
self.media_rects.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
|
||||||
self.draw_icon_at(rx, ry, icon);
|
|
||||||
self.media_rects
|
|
||||||
.push(("next", rx, ry, icon_size, icon_size));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,24 @@ use std::time::Instant;
|
|||||||
use crate::config::Config;
|
use crate::config::Config;
|
||||||
use crate::system::SystemStatus;
|
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 feedback;
|
||||||
mod indicator;
|
mod indicator;
|
||||||
mod media_bar;
|
mod media_bar;
|
||||||
|
mod ring_shape;
|
||||||
mod status_bar;
|
mod status_bar;
|
||||||
|
|
||||||
pub struct Renderer {
|
pub struct Renderer {
|
||||||
@@ -19,9 +34,11 @@ pub struct Renderer {
|
|||||||
pub(crate) wrong_password_shown: bool,
|
pub(crate) wrong_password_shown: bool,
|
||||||
pub(crate) key_highlight_shown: bool,
|
pub(crate) key_highlight_shown: bool,
|
||||||
pub(crate) cleared_feedback_shown: bool,
|
pub(crate) cleared_feedback_shown: bool,
|
||||||
|
pub(crate) verifying_shown: bool,
|
||||||
pub(crate) wrong_password_start: Option<Instant>,
|
pub(crate) wrong_password_start: Option<Instant>,
|
||||||
pub(crate) key_highlight_start: Option<Instant>,
|
pub(crate) key_highlight_start: Option<Instant>,
|
||||||
pub(crate) cleared_feedback_start: Option<Instant>,
|
pub(crate) cleared_feedback_start: Option<Instant>,
|
||||||
|
pub(crate) verifying_start: Option<Instant>,
|
||||||
pub(crate) key_highlight_angle: f64,
|
pub(crate) key_highlight_angle: f64,
|
||||||
pub(crate) background: Option<ImageSurface>,
|
pub(crate) background: Option<ImageSurface>,
|
||||||
pub(crate) password_display: String,
|
pub(crate) password_display: String,
|
||||||
@@ -60,9 +77,11 @@ impl Renderer {
|
|||||||
wrong_password_shown: false,
|
wrong_password_shown: false,
|
||||||
key_highlight_shown: false,
|
key_highlight_shown: false,
|
||||||
cleared_feedback_shown: false,
|
cleared_feedback_shown: false,
|
||||||
|
verifying_shown: false,
|
||||||
wrong_password_start: None,
|
wrong_password_start: None,
|
||||||
key_highlight_start: None,
|
key_highlight_start: None,
|
||||||
cleared_feedback_start: None,
|
cleared_feedback_start: None,
|
||||||
|
verifying_start: None,
|
||||||
key_highlight_angle: 0.0,
|
key_highlight_angle: 0.0,
|
||||||
background: None,
|
background: None,
|
||||||
password_display: String::new(),
|
password_display: String::new(),
|
||||||
@@ -110,6 +129,10 @@ impl Renderer {
|
|||||||
self.password_display = ".".repeat(length);
|
self.password_display = ".".repeat(length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn peek_password(&mut self, password: &str) {
|
||||||
|
self.password_display = password.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_cursor_position(&mut self, position: usize) {
|
pub fn set_cursor_position(&mut self, position: usize) {
|
||||||
self.cursor_position = position;
|
self.cursor_position = position;
|
||||||
}
|
}
|
||||||
@@ -193,6 +216,10 @@ impl Renderer {
|
|||||||
self.draw_caps_lock_indicator();
|
self.draw_caps_lock_indicator();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.verifying_shown {
|
||||||
|
self.draw_verifying_feedback();
|
||||||
|
}
|
||||||
|
|
||||||
if self.wrong_password_shown {
|
if self.wrong_password_shown {
|
||||||
self.draw_wrong_password_feedback();
|
self.draw_wrong_password_feedback();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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),
|
||||||
|
}
|
||||||
|
}
|
||||||
+32
-26
@@ -3,7 +3,7 @@ use cairo::{Format, ImageSurface};
|
|||||||
|
|
||||||
impl Renderer {
|
impl Renderer {
|
||||||
pub(crate) fn load_icons(&mut self) {
|
pub(crate) fn load_icons(&mut self) {
|
||||||
log::info!("Attempting to load status icons...");
|
log::debug!("Attempting to load status icons...");
|
||||||
let wifi_names = [
|
let wifi_names = [
|
||||||
"network-wireless-signal-excellent-symbolic",
|
"network-wireless-signal-excellent-symbolic",
|
||||||
"network-wireless-signal-excellent",
|
"network-wireless-signal-excellent",
|
||||||
@@ -25,7 +25,7 @@ impl Renderer {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if !wifi_path.is_empty() {
|
if !wifi_path.is_empty() {
|
||||||
log::info!("Resolved WiFi icon path: {}", wifi_path);
|
log::debug!("Resolved WiFi icon path: {}", wifi_path);
|
||||||
self.wifi_icon_surface = self.load_icon(&wifi_path);
|
self.wifi_icon_surface = self.load_icon(&wifi_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ impl Renderer {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if !bt_path.is_empty() {
|
if !bt_path.is_empty() {
|
||||||
log::info!("Resolved Bluetooth icon path: {}", bt_path);
|
log::debug!("Resolved Bluetooth icon path: {}", bt_path);
|
||||||
self.bluetooth_icon_surface = self.load_icon(&bt_path);
|
self.bluetooth_icon_surface = self.load_icon(&bt_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,7 +77,7 @@ impl Renderer {
|
|||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
if !batt_path.is_empty() {
|
if !batt_path.is_empty() {
|
||||||
log::info!("Resolved Battery icon path: {}", batt_path);
|
log::debug!("Resolved Battery icon path: {}", batt_path);
|
||||||
self.battery_icon_surface = self.load_icon(&batt_path);
|
self.battery_icon_surface = self.load_icon(&batt_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,27 +353,27 @@ impl Renderer {
|
|||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
self.context.set_font_size(48.0);
|
self.context.set_font_size(48.0);
|
||||||
let te = self.context.text_extents(&time_str).unwrap();
|
let te = render_try!(self.context.text_extents(&time_str));
|
||||||
self.context
|
self.context
|
||||||
.move_to(center_x - te.width() / 2.0, center_y + te.height() / 4.0);
|
.move_to(center_x - te.width() / 2.0, center_y + te.height() / 4.0);
|
||||||
self.context.show_text(&time_str).unwrap();
|
render_try!(self.context.show_text(&time_str));
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_font_size(14.0);
|
self.context.set_font_size(14.0);
|
||||||
let de = self.context.text_extents(&date_str).unwrap();
|
let de = render_try!(self.context.text_extents(&date_str));
|
||||||
self.context.move_to(
|
self.context.move_to(
|
||||||
center_x - de.width() / 2.0,
|
center_x - de.width() / 2.0,
|
||||||
center_y + te.height() / 4.0 + 25.0,
|
center_y + te.height() / 4.0 + 25.0,
|
||||||
);
|
);
|
||||||
self.context.show_text(&date_str).unwrap();
|
render_try!(self.context.show_text(&date_str));
|
||||||
|
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
let ue = self.context.text_extents(&self.uptime_cache).unwrap();
|
let ue = render_try!(self.context.text_extents(&self.uptime_cache));
|
||||||
self.context.move_to(
|
self.context.move_to(
|
||||||
center_x - ue.width() / 2.0,
|
center_x - ue.width() / 2.0,
|
||||||
center_y + te.height() / 4.0 + 43.0,
|
center_y + te.height() / 4.0 + 43.0,
|
||||||
);
|
);
|
||||||
self.context.show_text(&self.uptime_cache).unwrap();
|
render_try!(self.context.show_text(&self.uptime_cache));
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn draw_network(&self) {
|
pub(crate) fn draw_network(&self) {
|
||||||
@@ -392,13 +392,13 @@ impl Renderer {
|
|||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
self.context.set_font_size(16.0);
|
self.context.set_font_size(16.0);
|
||||||
self.context.move_to(text_x, y);
|
self.context.move_to(text_x, y);
|
||||||
self.context.show_text(ssid).unwrap();
|
render_try!(self.context.show_text(ssid));
|
||||||
} else {
|
} else {
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
self.context.set_font_size(16.0);
|
self.context.set_font_size(16.0);
|
||||||
self.context.move_to(x, y);
|
self.context.move_to(x, y);
|
||||||
self.context.show_text(ssid).unwrap();
|
render_try!(self.context.show_text(ssid));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,7 +418,7 @@ impl Renderer {
|
|||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
self.context.set_font_size(16.0);
|
self.context.set_font_size(16.0);
|
||||||
self.context.move_to(text_x, y);
|
self.context.move_to(text_x, y);
|
||||||
self.context.show_text(&battery_text).unwrap();
|
render_try!(self.context.show_text(&battery_text));
|
||||||
} else {
|
} else {
|
||||||
self.draw_battery_icon_at(
|
self.draw_battery_icon_at(
|
||||||
x,
|
x,
|
||||||
@@ -433,7 +433,7 @@ impl Renderer {
|
|||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
|
||||||
self.context.set_font_size(16.0);
|
self.context.set_font_size(16.0);
|
||||||
self.context.move_to(x + icon_width + 10.0, y);
|
self.context.move_to(x + icon_width + 10.0, y);
|
||||||
self.context.show_text(&battery_text).unwrap();
|
render_try!(self.context.show_text(&battery_text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -462,7 +462,7 @@ impl Renderer {
|
|||||||
.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult);
|
.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult);
|
||||||
self.context.set_font_size(14.0);
|
self.context.set_font_size(14.0);
|
||||||
self.context.move_to(text_x, y);
|
self.context.move_to(text_x, y);
|
||||||
self.context.show_text(&status_text).unwrap();
|
render_try!(self.context.show_text(&status_text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -478,7 +478,7 @@ impl Renderer {
|
|||||||
self.context.set_font_size(16.0);
|
self.context.set_font_size(16.0);
|
||||||
let text = format!("Layout: {}", layout);
|
let text = format!("Layout: {}", layout);
|
||||||
self.context.move_to(x, y);
|
self.context.move_to(x, y);
|
||||||
self.context.show_text(&text).unwrap();
|
render_try!(self.context.show_text(&text));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -490,8 +490,12 @@ impl Renderer {
|
|||||||
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
|
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
|
||||||
self.context.translate(x, y);
|
self.context.translate(x, y);
|
||||||
self.context.scale(scale, scale);
|
self.context.scale(scale, scale);
|
||||||
self.context.set_source_surface(surface, 0.0, 0.0).unwrap();
|
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
|
||||||
self.context.paint_with_alpha(self.fade_alpha).unwrap();
|
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();
|
self.context.restore().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -508,10 +512,12 @@ impl Renderer {
|
|||||||
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
|
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
|
||||||
self.context.translate(x, y);
|
self.context.translate(x, y);
|
||||||
self.context.scale(scale, scale);
|
self.context.scale(scale, scale);
|
||||||
self.context.set_source_surface(surface, 0.0, 0.0).unwrap();
|
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
|
||||||
self.context
|
log::error!("cairo error: {:?}", e);
|
||||||
.paint_with_alpha(self.fade_alpha * alpha)
|
}
|
||||||
.unwrap();
|
if let Err(e) = self.context.paint_with_alpha(self.fade_alpha * alpha) {
|
||||||
|
log::error!("cairo error: {:?}", e);
|
||||||
|
}
|
||||||
self.context.restore().unwrap();
|
self.context.restore().unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -529,11 +535,11 @@ impl Renderer {
|
|||||||
self.context.set_source_rgba(1.0, 1.0, 1.0, alpha * 0.5);
|
self.context.set_source_rgba(1.0, 1.0, 1.0, alpha * 0.5);
|
||||||
self.context.set_line_width(2.0);
|
self.context.set_line_width(2.0);
|
||||||
self.context.rectangle(x, y, width, height);
|
self.context.rectangle(x, y, width, height);
|
||||||
self.context.stroke().unwrap();
|
render_try!(self.context.stroke());
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context
|
self.context
|
||||||
.rectangle(x + width, y + height / 4.0, 3.0, height / 2.0);
|
.rectangle(x + width, y + height / 4.0, 3.0, height / 2.0);
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
let fill_width = (width - 4.0) * (percent / 100.0);
|
let fill_width = (width - 4.0) * (percent / 100.0);
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
if percent < 20.0 {
|
if percent < 20.0 {
|
||||||
@@ -543,7 +549,7 @@ impl Renderer {
|
|||||||
}
|
}
|
||||||
self.context
|
self.context
|
||||||
.rectangle(x + 2.0, y + 2.0, fill_width, height - 4.0);
|
.rectangle(x + 2.0, y + 2.0, fill_width, height - 4.0);
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
if charging {
|
if charging {
|
||||||
self.context.new_path();
|
self.context.new_path();
|
||||||
self.context.set_source_rgba(1.0, 1.0, 0.0, alpha);
|
self.context.set_source_rgba(1.0, 1.0, 0.0, alpha);
|
||||||
@@ -556,7 +562,7 @@ impl Renderer {
|
|||||||
self.context.line_to(bx - 1.0, by - 3.0);
|
self.context.line_to(bx - 1.0, by - 3.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();
|
self.context.close_path();
|
||||||
self.context.fill().unwrap();
|
render_try!(self.context.fill());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-34
@@ -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;
|
||||||
@@ -39,22 +39,22 @@ impl Screenshot {
|
|||||||
self.apply_blur(radius, times)?;
|
self.apply_blur(radius, times)?;
|
||||||
}
|
}
|
||||||
if let Some((base, factor)) = config.effect_vignette {
|
if let Some((base, factor)) = config.effect_vignette {
|
||||||
self.apply_vignette(base, factor);
|
self.apply_vignette(base, factor)?;
|
||||||
}
|
}
|
||||||
if let Some(pixel_size) = config.effect_pixelate {
|
if let Some(pixel_size) = config.effect_pixelate {
|
||||||
self.apply_pixelate(pixel_size);
|
self.apply_pixelate(pixel_size)?;
|
||||||
}
|
}
|
||||||
if let Some(angle) = config.effect_swirl {
|
if let Some(angle) = config.effect_swirl {
|
||||||
self.apply_swirl(angle);
|
self.apply_swirl(angle)?;
|
||||||
}
|
}
|
||||||
if let Some(factor) = config.effect_melting {
|
if let Some(factor) = config.effect_melting {
|
||||||
self.apply_melting(factor);
|
self.apply_melting(factor)?;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a swirl effect.
|
/// Apply a swirl effect.
|
||||||
pub fn apply_swirl(&mut self, angle: f32) {
|
pub fn apply_swirl(&mut self, angle: f32) -> Result<()> {
|
||||||
let width = self.surface.width();
|
let width = self.surface.width();
|
||||||
let height = self.surface.height();
|
let height = self.surface.height();
|
||||||
let center_x = width as f32 / 2.0;
|
let center_x = width as f32 / 2.0;
|
||||||
@@ -65,7 +65,7 @@ impl Screenshot {
|
|||||||
let mut data = vec![0u8; stride * height as usize];
|
let mut data = vec![0u8; stride * height as usize];
|
||||||
self.surface
|
self.surface
|
||||||
.with_data(|src| data.copy_from_slice(src))
|
.with_data(|src| data.copy_from_slice(src))
|
||||||
.unwrap();
|
.context("swirl: failed to read surface data")?;
|
||||||
let original = data.clone();
|
let original = data.clone();
|
||||||
|
|
||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
@@ -92,12 +92,13 @@ impl Screenshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut surface_data = self.surface.data().unwrap();
|
let mut surface_data = self.surface.data().context("swirl: failed to write surface data")?;
|
||||||
surface_data.copy_from_slice(&data);
|
surface_data.copy_from_slice(&data);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a melting effect (vertical smear).
|
/// Apply a melting effect (vertical smear).
|
||||||
pub fn apply_melting(&mut self, factor: f32) {
|
pub fn apply_melting(&mut self, factor: f32) -> Result<()> {
|
||||||
let width = self.surface.width();
|
let width = self.surface.width();
|
||||||
let height = self.surface.height();
|
let height = self.surface.height();
|
||||||
|
|
||||||
@@ -105,7 +106,7 @@ impl Screenshot {
|
|||||||
let mut data = vec![0u8; stride * height as usize];
|
let mut data = vec![0u8; stride * height as usize];
|
||||||
self.surface
|
self.surface
|
||||||
.with_data(|src| data.copy_from_slice(src))
|
.with_data(|src| data.copy_from_slice(src))
|
||||||
.unwrap();
|
.context("melting: failed to read surface data")?;
|
||||||
|
|
||||||
use rand::RngExt;
|
use rand::RngExt;
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
@@ -130,14 +131,15 @@ impl Screenshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut surface_data = self.surface.data().unwrap();
|
let mut surface_data = self.surface.data().context("melting: failed to write surface data")?;
|
||||||
surface_data.copy_from_slice(&data);
|
surface_data.copy_from_slice(&data);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pixelate the surface.
|
/// Pixelate the surface.
|
||||||
pub fn apply_pixelate(&mut self, pixel_size: u32) {
|
pub fn apply_pixelate(&mut self, pixel_size: u32) -> Result<()> {
|
||||||
if pixel_size <= 1 {
|
if pixel_size <= 1 {
|
||||||
return;
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let width = self.surface.width();
|
let width = self.surface.width();
|
||||||
@@ -146,7 +148,7 @@ impl Screenshot {
|
|||||||
let mut data = vec![0u8; stride * height as usize];
|
let mut data = vec![0u8; stride * height as usize];
|
||||||
self.surface
|
self.surface
|
||||||
.with_data(|src| data.copy_from_slice(src))
|
.with_data(|src| data.copy_from_slice(src))
|
||||||
.unwrap();
|
.context("pixelate: failed to read surface data")?;
|
||||||
|
|
||||||
for y in (0..height).step_by(pixel_size as usize) {
|
for y in (0..height).step_by(pixel_size as usize) {
|
||||||
for x in (0..width).step_by(pixel_size as usize) {
|
for x in (0..width).step_by(pixel_size as usize) {
|
||||||
@@ -192,8 +194,9 @@ impl Screenshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut surface_data = self.surface.data().unwrap();
|
let mut surface_data = self.surface.data().context("pixelate: failed to write surface data")?;
|
||||||
surface_data.copy_from_slice(&data);
|
surface_data.copy_from_slice(&data);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a Gaussian blur effect.
|
/// Apply a Gaussian blur effect.
|
||||||
@@ -202,31 +205,42 @@ impl Screenshot {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let width = self.surface.width();
|
let width = self.surface.width() as usize;
|
||||||
let height = self.surface.height();
|
let height = self.surface.height() as usize;
|
||||||
let stride = self.surface.stride() as usize;
|
let stride = self.surface.stride() as usize;
|
||||||
let mut data = vec![0u8; stride * height as usize];
|
let mut data = vec![0u8; stride * height];
|
||||||
|
|
||||||
self.surface
|
self.surface
|
||||||
.with_data(|src| data.copy_from_slice(src))
|
.with_data(|src| data.copy_from_slice(src))
|
||||||
.context("Failed to get surface data")?;
|
.context("blur: failed to read surface data")?;
|
||||||
|
|
||||||
|
// Convert from stride-padded surface data to tight RgbaImage.
|
||||||
|
// Cairo stride may be larger than width*4 for alignment, so copy
|
||||||
|
// row by row to strip the padding.
|
||||||
|
let tight_stride = width * 4;
|
||||||
|
let mut tight = vec![0u8; tight_stride * height];
|
||||||
|
for y in 0..height {
|
||||||
|
let src_off = y * stride;
|
||||||
|
let dst_off = y * tight_stride;
|
||||||
|
tight[dst_off..dst_off + tight_stride]
|
||||||
|
.copy_from_slice(&data[src_off..src_off + tight_stride]);
|
||||||
|
}
|
||||||
|
|
||||||
// Convert to image::RgbaImage for processing
|
|
||||||
let mut img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
|
let mut img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
|
||||||
image::ImageBuffer::from_raw(width as u32, height as u32, data)
|
image::ImageBuffer::from_raw(width as u32, height as u32, tight)
|
||||||
.context("Failed to create image buffer")?;
|
.context("blur: failed to create image buffer")?;
|
||||||
|
|
||||||
for _ in 0..times {
|
for _ in 0..times {
|
||||||
let mut rgb_data: Vec<[u8; 3]> =
|
let mut rgb_data: Vec<[u8; 3]> =
|
||||||
Vec::with_capacity((width as usize) * (height as usize));
|
Vec::with_capacity(width * height);
|
||||||
for pixel in img.pixels() {
|
for pixel in img.pixels() {
|
||||||
rgb_data.push([pixel[0], pixel[1], pixel[2]]);
|
rgb_data.push([pixel[0], pixel[1], pixel[2]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
fastblur::gaussian_blur(
|
fastblur::gaussian_blur(
|
||||||
&mut rgb_data,
|
&mut rgb_data,
|
||||||
width as usize,
|
width,
|
||||||
height as usize,
|
height,
|
||||||
radius as f32,
|
radius as f32,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -237,15 +251,20 @@ impl Screenshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy back to surface
|
// Copy back from tight buffer into stride-padded surface data
|
||||||
let new_data = img.into_raw();
|
let new_data = img.into_raw();
|
||||||
let mut surface_data = self.surface.data()?;
|
let mut surface_data = self.surface.data()?;
|
||||||
surface_data.copy_from_slice(&new_data);
|
for y in 0..height {
|
||||||
|
let src_off = y * tight_stride;
|
||||||
|
let dst_off = y * stride;
|
||||||
|
surface_data[dst_off..dst_off + tight_stride]
|
||||||
|
.copy_from_slice(&new_data[src_off..src_off + tight_stride]);
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a vignette effect (darken edges).
|
/// Apply a vignette effect (darken edges).
|
||||||
pub fn apply_vignette(&mut self, base: f32, factor: f32) {
|
pub fn apply_vignette(&mut self, base: f32, factor: f32) -> Result<()> {
|
||||||
let width = self.surface.width();
|
let width = self.surface.width();
|
||||||
let height = self.surface.height();
|
let height = self.surface.height();
|
||||||
let center_x = width as f32 / 2.0;
|
let center_x = width as f32 / 2.0;
|
||||||
@@ -256,7 +275,7 @@ impl Screenshot {
|
|||||||
let mut data = vec![0u8; stride * height as usize];
|
let mut data = vec![0u8; stride * height as usize];
|
||||||
self.surface
|
self.surface
|
||||||
.with_data(|src| data.copy_from_slice(src))
|
.with_data(|src| data.copy_from_slice(src))
|
||||||
.unwrap();
|
.context("vignette: failed to read surface data")?;
|
||||||
|
|
||||||
for y in 0..height {
|
for y in 0..height {
|
||||||
for x in 0..width {
|
for x in 0..width {
|
||||||
@@ -265,7 +284,7 @@ impl Screenshot {
|
|||||||
let distance = (dx * dx + dy * dy).sqrt();
|
let distance = (dx * dx + dy * dy).sqrt();
|
||||||
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
|
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
|
||||||
|
|
||||||
let index = ((y * width + x) * 4) as usize;
|
let index = (y as usize * stride) + (x as usize * 4);
|
||||||
for i in 0..3 {
|
for i in 0..3 {
|
||||||
let value = data[index + i] as f32 * vignette_factor;
|
let value = data[index + i] as f32 * vignette_factor;
|
||||||
data[index + i] = value.clamp(0.0, 255.0) as u8;
|
data[index + i] = value.clamp(0.0, 255.0) as u8;
|
||||||
@@ -273,8 +292,9 @@ impl Screenshot {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut surface_data = self.surface.data().unwrap();
|
let mut surface_data = self.surface.data().context("vignette: failed to write surface data")?;
|
||||||
surface_data.copy_from_slice(&data);
|
surface_data.copy_from_slice(&data);
|
||||||
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,7 +332,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 +381,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
|
||||||
};
|
};
|
||||||
|
|||||||
+79
-48
@@ -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,60 @@ 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 || {
|
||||||
|
if let Ok(finder) = PlayerFinder::new() {
|
||||||
|
if let Ok(player) = finder.find_active() {
|
||||||
|
match action {
|
||||||
|
BackendCommand::MediaPlayPause => { let _ = player.play_pause(); }
|
||||||
|
BackendCommand::MediaStop => { let _ = player.stop(); }
|
||||||
|
BackendCommand::MediaNext => { let _ = player.next(); }
|
||||||
|
BackendCommand::MediaPrev => { let _ = player.previous(); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,42 +320,29 @@ 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user