Compare commits

...

2 Commits

Author SHA1 Message Date
jory 7603975062 refactor: changed effects, add shell completions
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Extract all apply_* effect methods from screenshot.rs into dedicated
  src/effects.rs (blur, vignette, pixelate, swirl) — screenshot.rs
- Rewrite apply_blur: replace broken fastblur::gaussian_blur (horizontal-
  only box blur, backbuf dropped) with correct inline two-pass sliding-
  window box blur operating directly on Cairo surface bytes — ~10×
  faster, no ImageBuffer round-trip, no fastblur dependency
- Remove melting effect entirely (seems to be hard to implement
  properely, tried multiple itterations)
- Drop unused dependencies: fastblur, rand
- Add clap_complete dependency + --completions {bash,fish,zsh} flag,
  with CompletionShell enum in config.rs
- Fix show_* flags defaulting to true: remove default_value_t=true from
  show_media, show_battery, show_network, show_bluetooth, show_album_art,
  show_caps_lock_text — all now opt-in (matching user expectation)
- Convert peek toggle to hold: toggle_peek() → set_peek_held(held: bool),
  Press calls set_peek_held(true), new Release handler iterates all
  surfaces and calls set_peek_held(false), matching Ctrl-hold behavior
- Fix cursor rendering: increase font size 11→14 for peeked chars,
  handle cursor_position == 0 (prevent negative t wrapping to far ring
  end)
- Fix peek hit-test: use shape-aware point_in_shape() instead of simple
  circle pill/square/diamond/hexagon now detect clicks correctly within
  their area
- Remove per-frame DEBUG logs: PEEK/DOT mode in lock.rs,
  set_password_display/peek_password in render/mod.rs
- Update README: embed rustlock-effects.webp demo, remove melting
  references, correct show_* defaults and options table
2026-06-28 18:17:53 +02:00
jory 833706f5a5 feat: peek-to-reveal on click/ctrl, pre-commit hooks, devShell, fmt fixes
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled
- Click indicator ring to toggle password peek (persistent click-to-toggle)
- Ctrl-hold transient peek refactored to share peek path
- Cursor always visible (previously hidden at position 0, now shows at top of ring)
- .pre-commit-config.yaml: standard hooks + cargo fmt/clippy
- flake.nix: devShell with cargo, rustc, rustfmt, clippy, cargo-audit, prek
- cargo fmt pass across all source files (trailing whitespace, line wrapping)
- MPRIS error logging (silent failures now logged)
- Dependabot versioning-strategy: "increase" → "auto"
2026-06-21 20:44:59 +02:00
33 changed files with 1110 additions and 1118 deletions
+1 -1
View File
@@ -16,7 +16,7 @@ updates:
prefix: "deps" prefix: "deps"
reviewers: reviewers:
- "JorySeverijnse" - "JorySeverijnse"
versioning-strategy: "increase" versioning-strategy: "auto"
groups: groups:
rust-dependencies: rust-dependencies:
patterns: patterns:
+17 -2
View File
@@ -14,10 +14,16 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
with: with:
fetch-depth: 0 fetch-depth: 0
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
- name: Set version - name: Set version
id: version id: version
run: | run: |
@@ -26,10 +32,19 @@ jobs:
VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d'"' -f2) VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d'"' -f2)
echo "VERSION=${VERSION}-nightly.$DATE.$SHA" >> $GITHUB_OUTPUT echo "VERSION=${VERSION}-nightly.$DATE.$SHA" >> $GITHUB_OUTPUT
- name: Build release binary
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: rustlock-${{ steps.version.outputs.VERSION }}
path: target/release/rustlock
- name: Create nightly release - name: Create nightly release
uses: softprops/action-gh-release@v3 uses: softprops/action-gh-release@v3
with: with:
tag_name: nightly-${{ steps.version.outputs.VERSION }} tag_name: ${{ steps.version.outputs.VERSION }}
name: Nightly Build ${{ steps.version.outputs.VERSION }} name: Nightly Build ${{ steps.version.outputs.VERSION }}
prerelease: true prerelease: true
generate_release_notes: true generate_release_notes: true
+1 -4
View File
@@ -14,7 +14,7 @@ jobs:
quality-checks: quality-checks:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: ./.github/actions/deps - uses: ./.github/actions/deps
@@ -31,9 +31,6 @@ jobs:
- name: Run clippy - name: Run clippy
run: cargo clippy --all-targets -- -D warnings run: cargo clippy --all-targets -- -D warnings
- name: Run unit tests (default features)
run: cargo test --quiet
- name: Run unit tests (all features) - name: Run unit tests (all features)
run: cargo test --all-features --quiet run: cargo test --all-features --quiet
+12 -4
View File
@@ -11,7 +11,7 @@ jobs:
permissions: permissions:
contents: write contents: write
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Determine tag - name: Determine tag
id: tag id: tag
@@ -29,13 +29,21 @@ jobs:
publish-crates: publish-crates:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [release] needs: [release]
if: secrets.CARGO_REGISTRY_TOKEN != ''
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable - uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io - name: Publish to crates.io
env: env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish run: |
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "CARGO_REGISTRY_TOKEN not set — skipping publish"
exit 0
fi
cargo publish
+9 -2
View File
@@ -11,6 +11,14 @@ on:
- 'Cargo.lock' - 'Cargo.lock'
- 'deny.toml' - 'deny.toml'
- '.github/workflows/security.yml' - '.github/workflows/security.yml'
pull_request:
branches: [main, master]
paths:
- 'Cargo.toml'
- 'Cargo.lock'
- 'deny.toml'
- '.github/workflows/security.yml'
types: [opened, synchronize, reopened]
env: env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
@@ -19,7 +27,7 @@ jobs:
security-audit: security-audit:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v6 - uses: actions/checkout@v7
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
@@ -39,4 +47,3 @@ jobs:
- 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"
+28
View File
@@ -0,0 +1,28 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
exclude: \.md$
- id: end-of-file-fixer
- id: check-yaml
- id: check-toml
- id: check-added-large-files
exclude: ^assets/
- id: check-merge-conflict
- repo: local
hooks:
- id: fmt
name: cargo fmt
entry: cargo fmt
args: ["--", "--check"]
language: system
types: [rust]
pass_filenames: false
- id: clippy
name: cargo clippy
entry: cargo clippy
args: ["--", "-D", "warnings"]
language: system
pass_filenames: false
Generated
+403 -713
View File
File diff suppressed because it is too large Load Diff
+2 -3
View File
@@ -16,6 +16,7 @@ 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"] }
pangocairo = { version = "0.20" } pangocairo = { version = "0.20" }
clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] } clap = { version = "4.6", default-features = false, features = ["derive", "std", "help", "usage", "error-context"] }
clap_complete = { version = "4.6", default-features = false }
toml = { version = "1.1", default-features = false, features = ["parse", "display", "serde"] } toml = { version = "1.1", default-features = false, features = ["parse", "display", "serde"] }
serde = { version = "1.0", default-features = false, features = ["derive", "std"] } serde = { version = "1.0", default-features = false, features = ["derive", "std"] }
zeroize = "1.8" zeroize = "1.8"
@@ -25,11 +26,9 @@ 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"] }
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" pam-client = "0.5"
pam-client = "0.5"
calloop = "0.13" calloop = "0.13"
xkbcommon = "0.7" xkbcommon = "0.7"
calloop-wayland-source = "0.3" calloop-wayland-source = "0.3"
+13 -10
View File
@@ -1,10 +1,14 @@
# 🔒 RustLock # 🔒 RustLock
[![License](https://img.shields.io/badge/license-AGPL--3.0%2B-blue.svg)](https://github.com/yourusername/rustlock/blob/main/LICENSE) [![License](https://img.shields.io/badge/license-GPL-3.0-blue.svg)](https://github.com/yourusername/rustlock/blob/main/LICENSE)
[![Version](https://img.shields.io/badge/version-0.1.0-green.svg)](https://github.com/yourusername/rustlock/releases) [![Version](https://img.shields.io/badge/version-0.1.0-green.svg)](https://github.com/yourusername/rustlock/releases)
A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`. A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`.
<p align="center">
<img src="assets/rustlock-effects.webp" alt="RustLock effects demo" width="800">
</p>
--- ---
## ✨ Features ## ✨ Features
@@ -13,7 +17,7 @@ A high-performance Wayland screen locker written in Rust, inspired by `swaylock-
- 🎨 **Visual Effects**: - 🎨 **Visual Effects**:
- Gaussian blur (configurable radius and passes) - Gaussian blur (configurable radius and passes)
- Vignette effect (configurable base and factor) - Vignette effect (configurable base and factor)
- Pixelate, Swirl, and Melting effects - Pixelate and Swirl effects
- Smooth fade-in animation - Smooth fade-in animation
- 🔐 **Password Indicator**: - 🔐 **Password Indicator**:
- Circular ring with configurable radius and thickness - Circular ring with configurable radius and thickness
@@ -123,7 +127,6 @@ Options can be provided via command line or a configuration file at `~/.config/r
| `--effect-vignette <B>:<F>` | — | Vignette: base : factor (e.g., `0.5:0.5`) | | `--effect-vignette <B>:<F>` | — | Vignette: base : factor (e.g., `0.5:0.5`) |
| `--effect-pixelate <S>` | — | Pixelate effect with block size in pixels | | `--effect-pixelate <S>` | — | Pixelate effect with block size in pixels |
| `--effect-swirl <A>` | — | Swirl distortion with angle | | `--effect-swirl <A>` | — | Swirl distortion with angle |
| `--effect-melting <F>` | — | Melting distortion with factor |
| **Colors** (hex `RRGGBB[AA]`) | | | | **Colors** (hex `RRGGBB[AA]`) | | |
| `--ring-color <HEX>` | `#785412` | Outer ring color | | `--ring-color <HEX>` | `#785412` | Outer ring color |
| `--line-color <HEX>` | `#00000000` | Separator line color | | `--line-color <HEX>` | `#00000000` | Separator line color |
@@ -135,14 +138,14 @@ Options can be provided via command line or a configuration file at `~/.config/r
| `--caps-lock-color <HEX>` | `#E5A445` | Caps lock indicator ring color | | `--caps-lock-color <HEX>` | `#E5A445` | Caps lock indicator ring color |
| `--caps-lock-text-color <HEX>` | `#E5A445` | Caps lock text color | | `--caps-lock-text-color <HEX>` | `#E5A445` | Caps lock text color |
| `--verifying-color <HEX>` | `#0072FF` | Verifying feedback ring color | | `--verifying-color <HEX>` | `#0072FF` | Verifying feedback ring color |
| `--show-caps-lock-text` | `true` | Show "CAPS" text when caps lock is active | | `--show-caps-lock-text` | `false` | Show "CAPS" text when caps lock is active |
| **Display** | | | | **Display** | | |
| `--show-media` | `true` | Show MPRIS media player information | | `--show-media` | `false` | Show MPRIS media player information |
| `--show-battery` | `true` | Show battery status | | `--show-battery` | `false` | Show battery status |
| `--show-network` | `true` | Show WiFi SSID and signal strength | | `--show-network` | `false` | Show WiFi SSID and signal strength |
| `--show-bluetooth` | `true` | Show Bluetooth status | | `--show-bluetooth` | `false` | Show Bluetooth status |
| `--show-album-art` | `true` | Show album art for media | | `--show-album-art` | `false` | Show album art for media |
| `--show-keyboard-layout` | `true` | Show keyboard layout indicator | | `--show-keyboard-layout` | `false` | Show keyboard layout indicator |
| **Feedback & Timing** | | | | **Feedback & Timing** | | |
| `--fade-in <SECONDS>` | `0.2` | Fade-in animation duration | | `--fade-in <SECONDS>` | `0.2` | Fade-in animation duration |
| `--grace <SECONDS>` | `0` | Grace period — any key press unlocks within N seconds | | `--grace <SECONDS>` | `0` | Grace period — any key press unlocks within N seconds |
Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 760 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 443 KiB

+21 -2
View File
@@ -28,8 +28,27 @@
nativeBuildInputs = [ nativeBuildInputs = [
pkgs.pkg-config pkgs.pkg-config
pkgs.rustPlatform.bindgenHook pkgs.rustPlatform.bindgenHook
pkgs.rustfmt ];
pkgs.clippy };
devShells.${system}.default = pkgs.mkShell {
packages = with pkgs; [
cargo
rustc
rustfmt
clippy
cargo-audit
cargo-deny
prek
pkg-config
rustPlatform.bindgenHook
cairo
pam
gdk-pixbuf
librsvg
pango
libxkbcommon
dbus
]; ];
}; };
}; };
+6 -7
View File
@@ -13,7 +13,8 @@ use zeroize::Zeroizing;
type AuthChannels = ( type AuthChannels = (
channel::Sender<(Zeroizing<String>, u64)>, channel::Sender<(Zeroizing<String>, u64)>,
channel::Channel<(bool, u64)>, channel::Channel<(bool, u64)>,
);pub struct LockConversation { );
pub struct LockConversation {
pub password: Option<Zeroizing<String>>, pub password: Option<Zeroizing<String>>,
} }
@@ -39,13 +40,10 @@ impl pam_client::ConversationHandler for LockConversation {
} }
} }
pub fn create_and_run_auth_loop( pub fn create_and_run_auth_loop(service_name: String) -> Option<AuthChannels> {
service_name: String,
) -> Option<AuthChannels> {
let username = username(); let username = username();
let (auth_req_send, auth_req_recv) = let (auth_req_send, auth_req_recv) = channel::channel::<(Zeroizing<String>, u64)>();
channel::channel::<(Zeroizing<String>, u64)>();
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>(); let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
thread::spawn(move || { thread::spawn(move || {
@@ -55,7 +53,8 @@ pub fn create_and_run_auth_loop(
// Creating a new context each time is expensive because it // Creating a new context each time is expensive because it
// re-parses configs and re-loads shared libraries for every attempt. // re-parses configs and re-loads shared libraries for every attempt.
let conversation = LockConversation { password: None }; let conversation = LockConversation { password: None };
let mut context = match Context::new(service_name.as_str(), Some(username.as_str()), conversation) { let mut context =
match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
Ok(ctx) => { Ok(ctx) => {
debug!("Prepared to authenticate user '{}'", username); debug!("Prepared to authenticate user '{}'", username);
ctx ctx
+37 -11
View File
@@ -45,6 +45,13 @@ impl fmt::Display for RingShape {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, clap::ValueEnum)]
pub enum CompletionShell {
Bash,
Fish,
Zsh,
}
#[derive(Parser, Debug, Clone, Serialize, Deserialize)] #[derive(Parser, Debug, Clone, Serialize, Deserialize)]
#[command(author, version, about, long_about = None)] #[command(author, version, about, long_about = None)]
pub struct Config { pub struct Config {
@@ -91,10 +98,6 @@ pub struct Config {
#[serde(default)] #[serde(default)]
pub effect_swirl: Option<f32>, pub effect_swirl: Option<f32>,
#[arg(long)]
#[serde(default)]
pub effect_melting: Option<f32>,
#[arg(long, default_value = "785412", value_parser = util::parse_hex_color)] #[arg(long, default_value = "785412", value_parser = util::parse_hex_color)]
#[serde( #[serde(
deserialize_with = "util::deserialize_hex_color", deserialize_with = "util::deserialize_hex_color",
@@ -144,7 +147,7 @@ pub struct Config {
)] )]
pub verifying_color: (f64, f64, f64, f64), pub verifying_color: (f64, f64, f64, f64),
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_caps_lock_text: bool, pub show_caps_lock_text: bool,
#[arg(long, default_value = "00000000", value_parser = util::parse_hex_color)] #[arg(long, default_value = "00000000", value_parser = util::parse_hex_color)]
@@ -196,19 +199,19 @@ pub struct Config {
#[arg(long, default_value = "10000")] #[arg(long, default_value = "10000")]
pub auth_timeout: u64, pub auth_timeout: u64,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_media: bool, pub show_media: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_battery: bool, pub show_battery: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_network: bool, pub show_network: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_bluetooth: bool, pub show_bluetooth: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)] #[arg(long, action = clap::ArgAction::SetTrue)]
pub show_album_art: bool, pub show_album_art: bool,
#[arg(long, action = clap::ArgAction::SetTrue)] #[arg(long, action = clap::ArgAction::SetTrue)]
@@ -293,6 +296,10 @@ pub struct Config {
#[arg(long, default_value = "5")] #[arg(long, default_value = "5")]
pub command_timeout: u64, pub command_timeout: u64,
/// Generate shell completions for the given shell
#[arg(long, value_enum)]
#[serde(skip)]
pub completions: Option<CompletionShell>,
} }
impl Config { impl Config {
@@ -300,6 +307,26 @@ impl Config {
use clap::CommandFactory; use clap::CommandFactory;
let mut config = Config::parse(); let mut config = Config::parse();
// Handle --completions early (before config file merge so file can't inject it)
if let Some(shell) = config.completions {
use clap_complete::Shell;
let mut cmd = Config::command();
let name = "rustlock";
match shell {
CompletionShell::Bash => {
clap_complete::generate(Shell::Bash, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Fish => {
clap_complete::generate(Shell::Fish, &mut cmd, name, &mut std::io::stdout())
}
CompletionShell::Zsh => {
clap_complete::generate(Shell::Zsh, &mut cmd, name, &mut std::io::stdout())
}
}
std::process::exit(0);
}
let cmd = Config::command(); let cmd = Config::command();
let matches = cmd.get_matches(); let matches = cmd.get_matches();
@@ -347,7 +374,6 @@ impl Config {
config config
} }
} }
#[cfg(test)] #[cfg(test)]
+285
View File
@@ -0,0 +1,285 @@
//! Visual effects for lock screen backgrounds.
//!
//! Each effect takes a `&mut ImageSurface` and processes it in-place.
//! Effects are applied in order: blur → vignette → pixelate → swirl.
use anyhow::{Context, Result};
use cairo::ImageSurface;
/// Apply a swirl effect (radial rotation around the image centre).
pub fn apply_swirl(surface: &mut ImageSurface, angle: f32) -> Result<()> {
let width = surface.width();
let height = surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let radius = center_x.min(center_y);
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("swirl: failed to read surface data")?;
let original = data.clone();
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let d = (dx * dx + dy * dy).sqrt();
if d < radius {
let percent = (radius - d) / radius;
let theta = percent * percent * angle;
let s = theta.sin();
let c = theta.cos();
let nx = (c * dx - s * dy + center_x) as i32;
let ny = (s * dx + c * dy + center_y) as i32;
if nx >= 0 && nx < width && ny >= 0 && ny < height {
let src_idx = (ny as usize * stride) + (nx as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
data[dst_idx..dst_idx + 4].copy_from_slice(&original[src_idx..src_idx + 4]);
}
}
}
}
let mut surface_data = surface
.data()
.context("swirl: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Pixelate the surface.
pub fn apply_pixelate(surface: &mut ImageSurface, pixel_size: u32) -> Result<()> {
if pixel_size <= 1 {
return Ok(());
}
let width = surface.width();
let height = surface.height();
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("pixelate: failed to read surface data")?;
for y in (0..height).step_by(pixel_size as usize) {
for x in (0..width).step_by(pixel_size as usize) {
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
let mut count = 0u32;
// Average pixels in the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
r += data[index] as u32;
g += data[index + 1] as u32;
b += data[index + 2] as u32;
count += 1;
}
}
}
if count > 0 {
let r = r.checked_div(count).unwrap_or(0) as u8;
let g = g.checked_div(count).unwrap_or(0) as u8;
let b = b.checked_div(count).unwrap_or(0) as u8;
// Fill the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
}
}
}
}
}
}
let mut surface_data = surface
.data()
.context("pixelate: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a box blur effect (fast two-pass sliding-window implementation).
/// Uses multiply-shift to avoid slow integer division in the hot loop.
pub fn apply_blur(surface: &mut ImageSurface, radius: u32, times: u32) -> Result<()> {
if radius == 0 || times == 0 {
return Ok(());
}
let width = surface.width() as usize;
let height = surface.height() as usize;
let stride = surface.stride() as usize;
let r = radius as usize;
let mut data = vec![0u8; stride * height];
surface
.with_data(|src| data.copy_from_slice(src))
.context("blur: failed to read surface data")?;
let mut scratch = vec![0u8; stride * height];
// Precompute ceil(2^32 / c) for every possible window size c.
let max_count = (2 * r + 1).min(width.max(height));
let factor: Vec<u32> = (0..=max_count)
.map(|c| {
if c == 0 {
0
} else {
(1u64 << 32).div_ceil(c as u64) as u32
}
})
.collect();
#[inline(always)]
fn div_mul(n: u32, factor: u32) -> u8 {
((n as u64 * factor as u64) >> 32) as u8
}
for _ in 0..times {
// Horizontal box blur: data -> scratch
for y in 0..height {
let row = y * stride;
let init_end = r.min(width - 1);
let mut b_acc = 0u32;
let mut g_acc = 0u32;
let mut r_acc = 0u32;
for x in 0..=init_end {
let px = row + x * 4;
b_acc += data[px] as u32;
g_acc += data[px + 1] as u32;
r_acc += data[px + 2] as u32;
}
let mut count = (init_end + 1) as u32;
for x in 0..width {
let dst = row + x * 4;
let f = factor[count as usize];
scratch[dst] = div_mul(b_acc, f);
scratch[dst + 1] = div_mul(g_acc, f);
scratch[dst + 2] = div_mul(r_acc, f);
scratch[dst + 3] = data[dst + 3];
if x >= r {
let old = row + (x - r) * 4;
b_acc -= data[old] as u32;
g_acc -= data[old + 1] as u32;
r_acc -= data[old + 2] as u32;
count -= 1;
}
if x + r + 1 < width {
let new = row + (x + r + 1) * 4;
b_acc += data[new] as u32;
g_acc += data[new + 1] as u32;
r_acc += data[new + 2] as u32;
count += 1;
}
}
}
// Vertical box blur: scratch -> data
let init_end = r.min(height - 1);
let mut b_acc = vec![0u32; width];
let mut g_acc = vec![0u32; width];
let mut r_acc = vec![0u32; width];
for y in 0..=init_end {
let row = y * stride;
for x in 0..width {
let px = row + x * 4;
b_acc[x] += scratch[px] as u32;
g_acc[x] += scratch[px + 1] as u32;
r_acc[x] += scratch[px + 2] as u32;
}
}
let mut count = (init_end + 1) as u32;
for y in 0..height {
let f = factor[count as usize];
let dst_row = y * stride;
for x in 0..width {
let dst = dst_row + x * 4;
data[dst] = div_mul(b_acc[x], f);
data[dst + 1] = div_mul(g_acc[x], f);
data[dst + 2] = div_mul(r_acc[x], f);
}
if y >= r {
let old_row = (y - r) * stride;
for x in 0..width {
let px = old_row + x * 4;
b_acc[x] -= scratch[px] as u32;
g_acc[x] -= scratch[px + 1] as u32;
r_acc[x] -= scratch[px + 2] as u32;
}
count -= 1;
}
if y + r + 1 < height {
let new_row = (y + r + 1) * stride;
for x in 0..width {
let px = new_row + x * 4;
b_acc[x] += scratch[px] as u32;
g_acc[x] += scratch[px + 1] as u32;
r_acc[x] += scratch[px + 2] as u32;
}
count += 1;
}
}
}
let mut surface_data = surface.data()?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a vignette effect (darken edges).
pub fn apply_vignette(surface: &mut ImageSurface, base: f32, factor: f32) -> Result<()> {
let width = surface.width();
let height = surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let max_distance = (center_x * center_x + center_y * center_y).sqrt();
let stride = surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
surface
.with_data(|src| data.copy_from_slice(src))
.context("vignette: failed to read surface data")?;
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let distance = (dx * dx + dy * dy).sqrt();
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
let index = (y as usize * stride) + (x as usize * 4);
for i in 0..3 {
let value = data[index + i] as f32 * vignette_factor;
data[index + i] = value.clamp(0.0, 255.0) as u8;
}
}
}
let mut surface_data = surface
.data()
.context("vignette: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
+2 -1
View File
@@ -165,7 +165,8 @@ 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(self.config.key_highlight_window_duration) timer.elapsed()
< std::time::Duration::from_millis(self.config.key_highlight_window_duration)
} else { } else {
false false
} }
+16 -3
View File
@@ -31,6 +31,9 @@ pub struct LockedSurface {
/// 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, ctrl_held: bool,
/// True while the pointer button is held on the indicator ring (hold-to-peek).
/// Cleared on button release, matching Ctrl-hold transient behavior.
peek_toggled: bool,
} }
impl LockedSurface { impl LockedSurface {
@@ -64,6 +67,7 @@ impl LockedSurface {
dirty: true, dirty: true,
last_minute: i64::MIN, last_minute: i64::MIN,
ctrl_held: false, ctrl_held: false,
peek_toggled: false,
}) })
} }
@@ -182,10 +186,10 @@ impl LockedSurface {
} }
if !self.config.hide_password { if !self.config.hide_password {
let buf = self.input_handler.password_buffer();
let length = self.input_handler.password_length(); let length = self.input_handler.password_length();
if self.ctrl_held { if self.ctrl_held || self.peek_toggled {
self.renderer self.renderer.peek_password(buf.as_str());
.peek_password(self.input_handler.password_buffer().as_str());
} else { } else {
self.renderer.set_password_display(length); self.renderer.set_password_display(length);
} }
@@ -302,6 +306,15 @@ impl LockedSurface {
self.dirty = true; self.dirty = true;
} }
} }
/// Set peek mode on/off. Called when the user presses (held=true) or
/// releases (held=false) the mouse button on the indicator ring. Works
/// like Ctrl-hold — peek only lasts while the button is held.
pub fn set_peek_held(&mut self, held: bool) {
self.peek_toggled = held;
log::debug!("set_peek_held: peek_toggled = {}", self.peek_toggled);
self.dirty = true;
}
} }
pub struct LockManager { pub struct LockManager {
+74 -11
View File
@@ -1,5 +1,6 @@
mod auth; mod auth;
mod config; mod config;
mod effects;
mod input; mod input;
mod lock; mod lock;
mod render; mod render;
@@ -65,9 +66,8 @@ fn setup_file_logging(config: &Config) {
} }
} }
} else if config.log_file { } else if config.log_file {
let default_path = std::path::PathBuf::from( let default_path =
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()), std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()))
)
.join(".rustlock.log"); .join(".rustlock.log");
match OpenOptions::new() match OpenOptions::new()
.create(true) .create(true)
@@ -506,8 +506,16 @@ impl KeyboardHandler for WaylandLock {
modifiers: Modifiers, modifiers: Modifiers,
layout: u32, layout: u32,
) { ) {
let ctrl_changed = self.modifiers.ctrl != modifiers.ctrl;
self.modifiers = modifiers; self.modifiers = modifiers;
self.current_layout = layout; self.current_layout = layout;
if ctrl_changed {
log::debug!(
"update_modifiers: ctrl {} -> {}",
!modifiers.ctrl,
modifiers.ctrl
);
}
if let Ok(mut lock_manager) = self.lock_manager.lock() { if let Ok(mut lock_manager) = self.lock_manager.lock() {
lock_manager.set_ctrl_held(modifiers.ctrl); lock_manager.set_ctrl_held(modifiers.ctrl);
} }
@@ -650,7 +658,10 @@ 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);
if let Err(e) = 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); 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());
@@ -694,12 +705,14 @@ impl PointerHandler for WaylandLock {
events: &[PointerEvent], events: &[PointerEvent],
) { ) {
for event in events { for event in events {
if let PointerEventKind::Press { button, .. } = event.kind { match event.kind {
if button == 0x110 { PointerEventKind::Press { button: 0x110, .. } => {
let (x, y) = event.position; let (x, y) = event.position;
if let Ok(lm) = self.lock_manager.lock() { if let Ok(lm) = self.lock_manager.lock() {
for surface in &lm.surfaces { for surface in &lm.surfaces {
if surface.matches_surface(&event.surface) { if surface.matches_surface(&event.surface) {
// Check media controls first
let mut handled = false;
for (action, rx, ry, rw, rh) in &surface.renderer.media_rects { for (action, rx, ry, rw, rh) in &surface.renderer.media_rects {
if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh { if x >= *rx && x <= rx + rw && y >= *ry && y <= ry + rh {
match *action { match *action {
@@ -709,15 +722,56 @@ impl PointerHandler for WaylandLock {
"prev" => self.system_manager.media_prev(), "prev" => self.system_manager.media_prev(),
_ => {} _ => {}
} }
handled = true;
break;
}
}
if handled {
return;
}
// Check indicator ring hit — hold to peek (like Ctrl)
let cx = surface.renderer.width as f64 / 2.0;
let cy = surface.renderer.height as f64 / 2.0;
let r = surface.renderer.config.indicator_radius as f64;
let shape = surface.renderer.config.ring_shape;
if crate::render::ring_shape::point_in_shape(cx, cy, r, shape, x, y)
{
drop(lm);
if let Ok(mut lm) = self.lock_manager.lock() {
if let Some(s) = lm
.surfaces
.iter_mut()
.find(|s| s.matches_surface(&event.surface))
{
s.set_peek_held(true);
s.update();
let _ = s.commit(&mut self.pool);
}
}
return; return;
} }
} }
} }
} }
} }
PointerEventKind::Release { button: 0x110, .. } => {
if let Ok(mut lm) = self.lock_manager.lock() {
// Clear peek on ALL surfaces. Wayland button release events
// can reference a different wl_surface proxy than the press
// event (compositor-dependent pointer grab semantics), so
// matching by surface might miss the LockedSurface that has
// peek_toggled=true. Iterating every surface guarantees peek
// always ends on button release — matching Ctrl-hold behavior.
for surface in &mut lm.surfaces {
surface.set_peek_held(false);
surface.update();
let _ = surface.commit(&mut self.pool);
} }
} }
} }
_ => {}
}
}
} }
} }
@@ -757,12 +811,16 @@ fn main() -> Result<(), Box<dyn Error>> {
let system_manager = Arc::new(SystemManager::new(&config)); let system_manager = Arc::new(SystemManager::new(&config));
let (auth_tx_actual, auth_feedback_rx_actual) = let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop(
match auth::create_and_run_auth_loop(config.pam_service.clone()) { 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/{}", config.pam_service); 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);
} }
}; };
@@ -902,8 +960,13 @@ fn main() -> Result<(), Box<dyn Error>> {
// treat as auth failure so the user gets feedback instead of hanging forever. // treat as auth failure so the user gets feedback instead of hanging forever.
if state.auth_pending_seq.is_some() { if state.auth_pending_seq.is_some() {
if let Some(at) = state.auth_pending_at { if let Some(at) = state.auth_pending_at {
if Instant::now().duration_since(at) >= Duration::from_millis(state.config.auth_timeout) { if Instant::now().duration_since(at)
log::warn!("Authentication timed out after {} ms", state.config.auth_timeout); >= 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 // Clear pending seq so the eventual PAM result is ignored as stale
state.auth_pending_seq = None; state.auth_pending_seq = None;
state.handle_auth_result(false); state.handle_auth_result(false);
+10 -5
View File
@@ -12,8 +12,7 @@ impl Renderer {
if a > 0.0 { if a > 0.0 {
self.context.new_path(); self.context.new_path();
self.context self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness + 2.0); self.context.set_line_width(thickness + 2.0);
self.context.set_line_join(cairo::LineJoin::Round); self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path( ring_shape::build_ring_path(
@@ -180,19 +179,25 @@ impl Renderer {
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(self.config.wrong_password_duration) { 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(self.config.key_highlight_duration) { 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(self.config.cleared_feedback_duration) { 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;
} }
+35 -8
View File
@@ -75,17 +75,36 @@ impl Renderer {
let thickness = self.config.indicator_thickness as f64; let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape; let shape = self.config.ring_shape;
self.context.new_path(); let max_dots = self.config.max_dots as f64;
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha); let dot_radius = radius - thickness - 10.0;
let t_offset = ring_shape::top_centre_offset(shape);
let count = self.password_display.len(); let count = self.password_display.len();
if count == 0 { if count == 0 {
return; return;
} }
let max_dots = self.config.max_dots as f64; if self.peeking {
let dot_radius = radius - thickness - 10.0; // Draw each character at the same ring-perimeter positions as dots
let t_offset = ring_shape::top_centre_offset(shape); self.context.set_font_size(14.0);
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for (i, ch) in self.password_display.chars().enumerate() {
let t = (i as f64 / max_dots) + t_offset;
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
let mut ch_str = String::new();
ch_str.push(ch);
let te = render_try!(self.context.text_extents(&ch_str));
self.context.new_path();
self.context.move_to(
x - te.x_bearing() - te.width() / 2.0,
y - te.y_bearing() - te.height() / 2.0,
);
render_try!(self.context.show_text(&ch_str));
}
} else {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
for i in 0..count { for i in 0..count {
let t = (i as f64 / max_dots) + t_offset; let t = (i as f64 / max_dots) + t_offset;
@@ -95,10 +114,18 @@ impl Renderer {
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);
render_try!(self.context.fill()); render_try!(self.context.fill());
} }
}
// Cursor indicator // Cursor indicator (shared between peek and dot modes).
if self.fade_alpha > 0.0 && self.cursor_position > 0 { if self.fade_alpha > 0.0 {
let cursor_t = ((self.cursor_position as f64 - 0.5) / max_dots) + t_offset; // At position 0, place the cursor squarely at the first-dot position
// (t_offset) instead of subtracting 0.5, which would push t negative
// and cause perimeter_point to wrap it to the far end of the ring.
let cursor_t = if self.cursor_position == 0 {
t_offset
} else {
((self.cursor_position as f64 - 0.5) / max_dots) + t_offset
};
let (cx, cy) = let (cx, cy) =
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t); ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
let dx = cx - center_x; let dx = cx - center_x;
+26 -9
View File
@@ -15,7 +15,9 @@ 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();
if let Ok(mut surface) = ImageSurface::create(Format::ARgb32, w as i32, h as i32) { if let Ok(mut surface) =
ImageSurface::create(Format::ARgb32, w as i32, h as i32)
{
if let Ok(mut surface_data) = surface.data() { if let Ok(mut surface_data) = surface.data() {
for y in 0..h { for y in 0..h {
for x in 0..w { for x in 0..w {
@@ -72,7 +74,8 @@ impl Renderer {
} }
} }
self.context.move_to(text_center_x - te.width() / 2.0, start_y + 20.0); self.context
.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
render_try!(self.context.show_text(&display_text)); render_try!(self.context.show_text(&display_text));
// All media buttons on one row, evenly spaced. // All media buttons on one row, evenly spaced.
@@ -82,17 +85,18 @@ impl Renderer {
let btn_y = start_y + 50.0; let btn_y = start_y + 50.0;
// Layout: prev | play_pause | next (centered as a group) // Layout: prev | play_pause | next (centered as a group)
let total_buttons: f64 = let total_buttons: f64 = (self.media_prev_icon_surface.is_some() as u32
(self.media_prev_icon_surface.is_some() as u32
+ 1 + 1
+ self.media_next_icon_surface.is_some() as u32) as f64; + self.media_next_icon_surface.is_some() as u32)
as f64;
let group_width = (total_buttons - 1.0) * btn_gap + btn_size; let group_width = (total_buttons - 1.0) * btn_gap + btn_size;
let group_start_x = center_x - group_width / 2.0; let group_start_x = center_x - group_width / 2.0;
let mut btn_x = group_start_x; 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 {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); self.media_rects
.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
btn_x += btn_gap; btn_x += btn_gap;
} }
@@ -100,17 +104,30 @@ impl Renderer {
if self.system_status.media_playing { if self.system_status.media_playing {
if let Some(ref icon) = self.media_pause_icon_surface { if let Some(ref icon) = self.media_pause_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); 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)); self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
} }
} else if let Some(ref icon) = self.media_play_icon_surface { } 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.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)); self.media_rects.push((
"play_pause",
btn_x,
btn_y - btn_size / 2.0,
btn_size,
btn_size,
));
} }
btn_x += btn_gap; btn_x += btn_gap;
if let Some(ref icon) = self.media_next_icon_surface { if let Some(ref icon) = self.media_next_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon); self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size)); self.media_rects
.push(("next", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
} }
} }
} }
+5 -1
View File
@@ -21,7 +21,7 @@ macro_rules! render_try {
mod feedback; mod feedback;
mod indicator; mod indicator;
mod media_bar; mod media_bar;
mod ring_shape; pub(crate) mod ring_shape;
mod status_bar; mod status_bar;
pub struct Renderer { pub struct Renderer {
@@ -42,6 +42,7 @@ pub struct Renderer {
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,
pub(crate) peeking: bool,
pub(crate) cursor_position: usize, pub(crate) cursor_position: usize,
pub(crate) uptime_cache: String, pub(crate) uptime_cache: String,
pub(crate) last_uptime_update: Option<Instant>, pub(crate) last_uptime_update: Option<Instant>,
@@ -85,6 +86,7 @@ impl Renderer {
key_highlight_angle: 0.0, key_highlight_angle: 0.0,
background: None, background: None,
password_display: String::new(), password_display: String::new(),
peeking: false,
cursor_position: 0, cursor_position: 0,
uptime_cache: String::new(), uptime_cache: String::new(),
last_uptime_update: None, last_uptime_update: None,
@@ -127,10 +129,12 @@ impl Renderer {
pub fn set_password_display(&mut self, length: usize) { pub fn set_password_display(&mut self, length: usize) {
self.password_display = ".".repeat(length); self.password_display = ".".repeat(length);
self.peeking = false;
} }
pub fn peek_password(&mut self, password: &str) { pub fn peek_password(&mut self, password: &str) {
self.password_display = password.to_string(); self.password_display = password.to_string();
self.peeking = true;
} }
pub fn set_cursor_position(&mut self, position: usize) { pub fn set_cursor_position(&mut self, position: usize) {
+37 -10
View File
@@ -9,13 +9,7 @@ const SEGMENTS: usize = 120;
/// Return the (x, y) point on the shape's perimeter at normalized position `t` ∈ [0, 1]. /// 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. /// `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). /// `r` is the shape's characteristic radius (distance from center to side/vertex).
pub(crate) fn perimeter_point( pub(crate) fn perimeter_point(cx: f64, cy: f64, r: f64, shape: RingShape, t: f64) -> (f64, f64) {
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 // Normalize t to [0, 1). Rust's % preserves sign, and the shape-specific
// functions use floor/truncation that break on negative values. // functions use floor/truncation that break on negative values.
let t = t - t.floor(); let t = t - t.floor();
@@ -95,9 +89,8 @@ fn hexagon_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let local = t * 6.0 - side as f64; let local = t * 6.0 - side as f64;
// Shared helper for edges between two vertices // Shared helper for edges between two vertices
let vert = |angle_rad: f64| -> (f64, f64) { let vert =
(cx + r * angle_rad.cos(), cy + r * angle_rad.sin()) |angle_rad: f64| -> (f64, f64) { (cx + r * angle_rad.cos(), cy + r * angle_rad.sin()) };
};
// Vertices clockwise from right (angle = 0) // Vertices clockwise from right (angle = 0)
let v = [ let v = [
@@ -214,6 +207,40 @@ pub(crate) fn build_fill_path(
build_ring_path(ctx, cx, cy, inner_r, shape); build_ring_path(ctx, cx, cy, inner_r, shape);
} }
/// Check whether a point `(px, py)` lies inside the shape's fill area.
/// This is used for hittesting (e.g. click & hold to peek) so the clickable
/// region matches what the user sees on screen.
pub(crate) fn point_in_shape(cx: f64, cy: f64, r: f64, shape: RingShape, px: f64, py: f64) -> bool {
let dx = (px - cx).abs();
let dy = (py - cy).abs();
match shape {
RingShape::Circle => dx * dx + dy * dy <= r * r,
RingShape::Square => dx <= r && dy <= r,
RingShape::Diamond => dx + dy <= r,
RingShape::Hexagon => {
// Regular hexagon, vertex at (r, 0), edge slopes at ±60°.
// For |dx| ≥ r/2 the sloping edge bounds: |dy| ≤ √3 (r |dx|).
// For |dx| ≤ r/2 the flat top bounds: |dy| ≤ √3 r / 2.
let sqrt3 = 3.0_f64.sqrt();
if dx >= r / 2.0 {
dy <= sqrt3 * (r - dx)
} else {
dy <= r * sqrt3 / 2.0
}
}
RingShape::Pill => {
// Pill = centre rectangle (2r × 2r) + semicircular caps of
// radius r at each end. Total width 4r, height 2r.
if dx <= r {
dy <= r
} else {
let ex = dx - r;
ex * ex + dy * dy <= r * r
}
}
}
}
/// Return the normalized `t` offset that places the first password dot at the /// 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. /// visual top-centre of the shape. May be negative; callers should NOT wrap.
pub(crate) fn top_centre_offset(shape: RingShape) -> f64 { pub(crate) fn top_centre_offset(shape: RingShape) -> f64 {
+4 -251
View File
@@ -36,264 +36,17 @@ impl Screenshot {
/// Apply configured visual effects to the screenshot. /// Apply configured visual effects to the screenshot.
pub fn apply_effects(&mut self, config: &Config) -> Result<()> { pub fn apply_effects(&mut self, config: &Config) -> Result<()> {
if let Some((radius, times)) = config.effect_blur { if let Some((radius, times)) = config.effect_blur {
self.apply_blur(radius, times)?; crate::effects::apply_blur(&mut self.surface, radius, times)?;
} }
if let Some((base, factor)) = config.effect_vignette { if let Some((base, factor)) = config.effect_vignette {
self.apply_vignette(base, factor)?; crate::effects::apply_vignette(&mut self.surface, base, factor)?;
} }
if let Some(pixel_size) = config.effect_pixelate { if let Some(pixel_size) = config.effect_pixelate {
self.apply_pixelate(pixel_size)?; crate::effects::apply_pixelate(&mut self.surface, pixel_size)?;
} }
if let Some(angle) = config.effect_swirl { if let Some(angle) = config.effect_swirl {
self.apply_swirl(angle)?; crate::effects::apply_swirl(&mut self.surface, angle)?;
} }
if let Some(factor) = config.effect_melting {
self.apply_melting(factor)?;
}
Ok(())
}
/// Apply a swirl effect.
pub fn apply_swirl(&mut self, angle: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let radius = center_x.min(center_y);
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("swirl: failed to read surface data")?;
let original = data.clone();
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let d = (dx * dx + dy * dy).sqrt();
if d < radius {
let percent = (radius - d) / radius;
let theta = percent * percent * angle;
let s = theta.sin();
let c = theta.cos();
let nx = (c * dx - s * dy + center_x) as i32;
let ny = (s * dx + c * dy + center_y) as i32;
if nx >= 0 && nx < width && ny >= 0 && ny < height {
let src_idx = (ny as usize * stride) + (nx as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
data[dst_idx..dst_idx + 4].copy_from_slice(&original[src_idx..src_idx + 4]);
}
}
}
}
let mut surface_data = self.surface.data().context("swirl: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a melting effect (vertical smear).
pub fn apply_melting(&mut self, factor: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("melting: failed to read surface data")?;
use rand::RngExt;
let mut rng = rand::rng();
for x in 0..width {
let mut melt_amount = 0.0;
for y in 0..height {
melt_amount += rng.random_range(0.0..factor);
let src_y = (y as f32 - melt_amount).max(0.0) as i32;
let src_idx = (src_y as usize * stride) + (x as usize * 4);
let dst_idx = (y as usize * stride) + (x as usize * 4);
// Copy the pixel from above to create a smear
let pixel = [
data[src_idx],
data[src_idx + 1],
data[src_idx + 2],
data[src_idx + 3],
];
data[dst_idx..dst_idx + 4].copy_from_slice(&pixel);
}
}
let mut surface_data = self.surface.data().context("melting: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Pixelate the surface.
pub fn apply_pixelate(&mut self, pixel_size: u32) -> Result<()> {
if pixel_size <= 1 {
return Ok(());
}
let width = self.surface.width();
let height = self.surface.height();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("pixelate: failed to read surface data")?;
for y in (0..height).step_by(pixel_size as usize) {
for x in (0..width).step_by(pixel_size as usize) {
let mut r = 0u32;
let mut g = 0u32;
let mut b = 0u32;
let mut count = 0u32;
// Average pixels in the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
r += data[index] as u32;
g += data[index + 1] as u32;
b += data[index + 2] as u32;
count += 1;
}
}
}
if count > 0 {
let r = r.checked_div(count).unwrap_or(0) as u8;
let g = g.checked_div(count).unwrap_or(0) as u8;
let b = b.checked_div(count).unwrap_or(0) as u8;
// Fill the block
for py in 0..pixel_size {
for px in 0..pixel_size {
let cur_x = x + px as i32;
let cur_y = y + py as i32;
if cur_x < width && cur_y < height {
let index = (cur_y as usize * stride) + (cur_x as usize * 4);
data[index] = r;
data[index + 1] = g;
data[index + 2] = b;
}
}
}
}
}
}
let mut surface_data = self.surface.data().context("pixelate: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a Gaussian blur effect.
pub fn apply_blur(&mut self, radius: u32, times: u32) -> Result<()> {
if radius == 0 || times == 0 {
return Ok(());
}
let width = self.surface.width() as usize;
let height = self.surface.height() as usize;
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height];
self.surface
.with_data(|src| data.copy_from_slice(src))
.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]);
}
let mut img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
image::ImageBuffer::from_raw(width as u32, height as u32, tight)
.context("blur: failed to create image buffer")?;
for _ in 0..times {
let mut rgb_data: Vec<[u8; 3]> =
Vec::with_capacity(width * height);
for pixel in img.pixels() {
rgb_data.push([pixel[0], pixel[1], pixel[2]]);
}
fastblur::gaussian_blur(
&mut rgb_data,
width,
height,
radius as f32,
);
for (i, pixel) in img.pixels_mut().enumerate() {
pixel[0] = rgb_data[i][0];
pixel[1] = rgb_data[i][1];
pixel[2] = rgb_data[i][2];
}
}
// Copy back from tight buffer into stride-padded surface data
let new_data = img.into_raw();
let mut surface_data = self.surface.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(())
}
/// Apply a vignette effect (darken edges).
pub fn apply_vignette(&mut self, base: f32, factor: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
let center_y = height as f32 / 2.0;
let max_distance = (center_x * center_x + center_y * center_y).sqrt();
let stride = self.surface.stride() as usize;
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("vignette: failed to read surface data")?;
for y in 0..height {
for x in 0..width {
let dx = x as f32 - center_x;
let dy = y as f32 - center_y;
let distance = (dx * dx + dy * dy).sqrt();
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
let index = (y as usize * stride) + (x as usize * 4);
for i in 0..3 {
let value = data[index + i] as f32 * vignette_factor;
data[index + i] = value.clamp(0.0, 255.0) as u8;
}
}
}
let mut surface_data = self.surface.data().context("vignette: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(()) Ok(())
} }
} }
+18 -12
View File
@@ -296,16 +296,25 @@ impl SystemManager {
let action = cmd; let action = cmd;
// Fire-and-forget: don't block the polling loop on MPRIS. // Fire-and-forget: don't block the polling loop on MPRIS.
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
if let Ok(finder) = PlayerFinder::new() { match PlayerFinder::new() {
if let Ok(player) = finder.find_active() { Ok(finder) => {
match action { match finder.find_active() {
BackendCommand::MediaPlayPause => { let _ = player.play_pause(); } Ok(player) => {
BackendCommand::MediaStop => { let _ = player.stop(); } let result = match action {
BackendCommand::MediaNext => { let _ = player.next(); } BackendCommand::MediaPlayPause => player.play_pause(),
BackendCommand::MediaPrev => { let _ = player.previous(); } BackendCommand::MediaStop => player.stop(),
_ => {} BackendCommand::MediaNext => player.next(),
BackendCommand::MediaPrev => player.previous(),
_ => Ok(()),
};
if let Err(e) = result {
error!("MPRIS command {action:?} failed: {e}");
} }
} }
Err(e) => error!("No active MPRIS player found: {e}"),
}
}
Err(e) => error!("Failed to create MPRIS PlayerFinder: {e}"),
} }
}); });
} }
@@ -320,10 +329,7 @@ impl SystemManager {
} }
pub fn get_status(&self) -> SystemStatus { pub fn get_status(&self) -> SystemStatus {
self.status self.status.lock().map(|s| s.clone()).unwrap_or_default()
.lock()
.map(|s| s.clone())
.unwrap_or_default()
} }
pub fn send_command(&self, cmd: BackendCommand) { pub fn send_command(&self, cmd: BackendCommand) {