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
This commit is contained in:
2026-06-28 18:17:53 +02:00
parent 833706f5a5
commit 7603975062
24 changed files with 862 additions and 1038 deletions
+17 -2
View File
@@ -14,10 +14,16 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
- name: Set version
id: version
run: |
@@ -26,10 +32,19 @@ jobs:
VERSION=$(grep -m1 '^version =' Cargo.toml | cut -d'"' -f2)
echo "VERSION=${VERSION}-nightly.$DATE.$SHA" >> $GITHUB_OUTPUT
- name: Build release binary
run: cargo build --release
- name: Upload binary
uses: actions/upload-artifact@v4
with:
name: rustlock-${{ steps.version.outputs.VERSION }}
path: target/release/rustlock
- name: Create nightly release
uses: softprops/action-gh-release@v3
with:
tag_name: nightly-${{ steps.version.outputs.VERSION }}
tag_name: ${{ steps.version.outputs.VERSION }}
name: Nightly Build ${{ steps.version.outputs.VERSION }}
prerelease: true
generate_release_notes: true
+1 -4
View File
@@ -14,7 +14,7 @@ jobs:
quality-checks:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/deps
@@ -31,9 +31,6 @@ jobs:
- name: Run clippy
run: cargo clippy --all-targets -- -D warnings
- name: Run unit tests (default features)
run: cargo test --quiet
- name: Run unit tests (all features)
run: cargo test --all-features --quiet
+12 -4
View File
@@ -11,7 +11,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Determine tag
id: tag
@@ -29,13 +29,21 @@ jobs:
publish-crates:
runs-on: ubuntu-latest
needs: [release]
if: secrets.CARGO_REGISTRY_TOKEN != ''
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: ./.github/actions/deps
- uses: Swatinem/rust-cache@v2
- uses: dtolnay/rust-toolchain@stable
- name: Publish to crates.io
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: cargo publish
run: |
if [ -z "$CARGO_REGISTRY_TOKEN" ]; then
echo "CARGO_REGISTRY_TOKEN not set — skipping publish"
exit 0
fi
cargo publish
+9 -1
View File
@@ -11,6 +11,14 @@ on:
- 'Cargo.lock'
- 'deny.toml'
- '.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:
CARGO_TERM_COLOR: always
@@ -19,7 +27,7 @@ jobs:
security-audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
+1
View File
@@ -8,6 +8,7 @@ repos:
- id: check-yaml
- id: check-toml
- id: check-added-large-files
exclude: ^assets/
- id: check-merge-conflict
- repo: local
Generated
+403 -713
View File
File diff suppressed because it is too large Load Diff
+1 -2
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"] }
pangocairo = { version = "0.20" }
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"] }
serde = { version = "1.0", default-features = false, features = ["derive", "std"] }
zeroize = "1.8"
@@ -25,10 +26,8 @@ whoami = "1.6"
zbus = { version = "5.14", default-features = false, features = ["tokio"] }
mpris = "2.0"
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 }
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] }
fastblur = "0.1"
pam-client = "0.5"
calloop = "0.13"
xkbcommon = "0.7"
+13 -10
View File
@@ -1,10 +1,14 @@
# 🔒 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)
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
@@ -13,7 +17,7 @@ A high-performance Wayland screen locker written in Rust, inspired by `swaylock-
- 🎨 **Visual Effects**:
- Gaussian blur (configurable radius and passes)
- Vignette effect (configurable base and factor)
- Pixelate, Swirl, and Melting effects
- Pixelate and Swirl effects
- Smooth fade-in animation
- 🔐 **Password Indicator**:
- 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-pixelate <S>` | — | Pixelate effect with block size in pixels |
| `--effect-swirl <A>` | — | Swirl distortion with angle |
| `--effect-melting <F>` | — | Melting distortion with factor |
| **Colors** (hex `RRGGBB[AA]`) | | |
| `--ring-color <HEX>` | `#785412` | Outer ring 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-text-color <HEX>` | `#E5A445` | Caps lock text 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** | | |
| `--show-media` | `true` | Show MPRIS media player information |
| `--show-battery` | `true` | Show battery status |
| `--show-network` | `true` | Show WiFi SSID and signal strength |
| `--show-bluetooth` | `true` | Show Bluetooth status |
| `--show-album-art` | `true` | Show album art for media |
| `--show-keyboard-layout` | `true` | Show keyboard layout indicator |
| `--show-media` | `false` | Show MPRIS media player information |
| `--show-battery` | `false` | Show battery status |
| `--show-network` | `false` | Show WiFi SSID and signal strength |
| `--show-bluetooth` | `false` | Show Bluetooth status |
| `--show-album-art` | `false` | Show album art for media |
| `--show-keyboard-layout` | `false` | Show keyboard layout indicator |
| **Feedback & Timing** | | |
| `--fade-in <SECONDS>` | `0.2` | Fade-in animation duration |
| `--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

+38 -10
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)]
#[command(author, version, about, long_about = None)]
pub struct Config {
@@ -91,10 +98,6 @@ pub struct Config {
#[serde(default)]
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)]
#[serde(
deserialize_with = "util::deserialize_hex_color",
@@ -144,7 +147,7 @@ pub struct Config {
)]
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,
#[arg(long, default_value = "00000000", value_parser = util::parse_hex_color)]
@@ -196,19 +199,19 @@ pub struct Config {
#[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)]
pub show_media: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
#[arg(long, action = clap::ArgAction::SetTrue)]
pub show_battery: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
#[arg(long, action = clap::ArgAction::SetTrue)]
pub show_network: bool,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
#[arg(long, action = clap::ArgAction::SetTrue)]
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,
#[arg(long, action = clap::ArgAction::SetTrue)]
@@ -292,6 +295,11 @@ pub struct Config {
/// Timeout (seconds) for system commands (poweroff, reboot, suspend)
#[arg(long, default_value = "5")]
pub command_timeout: u64,
/// Generate shell completions for the given shell
#[arg(long, value_enum)]
#[serde(skip)]
pub completions: Option<CompletionShell>,
}
impl Config {
@@ -299,6 +307,26 @@ impl Config {
use clap::CommandFactory;
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 matches = cmd.get_matches();
+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(())
}
+8 -16
View File
@@ -31,7 +31,8 @@ pub struct LockedSurface {
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
last_minute: i64,
ctrl_held: bool,
/// Set by clicking on the indicator ring. Persists until clicked again.
/// 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,
}
@@ -188,18 +189,8 @@ impl LockedSurface {
let buf = self.input_handler.password_buffer();
let length = self.input_handler.password_length();
if self.ctrl_held || self.peek_toggled {
log::debug!(
"LockedSurface::update: PEEK mode (ctrl={}, toggled={})",
self.ctrl_held,
self.peek_toggled
);
self.renderer.peek_password(buf.as_str());
} else {
log::debug!(
"LockedSurface::update: DOT mode (ctrl={}, toggled={})",
self.ctrl_held,
self.peek_toggled
);
self.renderer.set_password_display(length);
}
}
@@ -316,11 +307,12 @@ impl LockedSurface {
}
}
/// Toggle peek mode on/off. Called when the user clicks on the indicator
/// ring. Persists until toggled again (unlike Ctrl-hold which is transient).
pub fn toggle_peek(&mut self) {
self.peek_toggled = !self.peek_toggled;
log::debug!("toggle_peek: peek_toggled = {}", self.peek_toggled);
/// 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;
}
}
+24 -8
View File
@@ -1,5 +1,6 @@
mod auth;
mod config;
mod effects;
mod input;
mod lock;
mod render;
@@ -704,8 +705,8 @@ impl PointerHandler for WaylandLock {
events: &[PointerEvent],
) {
for event in events {
if let PointerEventKind::Press { button, .. } = event.kind {
if button == 0x110 {
match event.kind {
PointerEventKind::Press { button: 0x110, .. } => {
let (x, y) = event.position;
if let Ok(lm) = self.lock_manager.lock() {
for surface in &lm.surfaces {
@@ -728,14 +729,13 @@ impl PointerHandler for WaylandLock {
if handled {
return;
}
// Check indicator ring hit — toggle password peek
// 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 dx = x - cx;
let dy = y - cy;
if dx * dx + dy * dy <= r * r {
// Need to reborrow mutably for toggle
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
@@ -743,7 +743,7 @@ impl PointerHandler for WaylandLock {
.iter_mut()
.find(|s| s.matches_surface(&event.surface))
{
s.toggle_peek();
s.set_peek_held(true);
s.update();
let _ = s.commit(&mut self.pool);
}
@@ -754,6 +754,22 @@ impl PointerHandler for WaylandLock {
}
}
}
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);
}
}
}
_ => {}
}
}
}
+10 -4
View File
@@ -85,9 +85,8 @@ impl Renderer {
}
if self.peeking {
log::debug!("draw_password_display: PEEKING text ({} chars)", count);
// Draw each character at the same ring-perimeter positions as dots
self.context.set_font_size(11.0);
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;
@@ -117,9 +116,16 @@ impl Renderer {
}
}
// Cursor indicator (shared between peek and dot modes)
// Cursor indicator (shared between peek and dot modes).
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) =
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
let dx = cx - center_x;
+1 -6
View File
@@ -21,7 +21,7 @@ macro_rules! render_try {
mod feedback;
mod indicator;
mod media_bar;
mod ring_shape;
pub(crate) mod ring_shape;
mod status_bar;
pub struct Renderer {
@@ -128,16 +128,11 @@ impl Renderer {
}
pub fn set_password_display(&mut self, length: usize) {
log::debug!("Renderer::set_password_display(length={})", length);
self.password_display = ".".repeat(length);
self.peeking = false;
}
pub fn peek_password(&mut self, password: &str) {
log::debug!(
"Renderer::peek_password(len={}, peeking=true)",
password.len()
);
self.password_display = password.to_string();
self.peeking = true;
}
+34
View File
@@ -207,6 +207,40 @@ pub(crate) fn build_fill_path(
build_ring_path(ctx, cx, cy, inner_r, shape);
}
/// Check whether a point `(px, py)` lies inside the shape's fill area.
/// This is used for hittesting (e.g. click & hold to peek) so the clickable
/// region matches what the user sees on screen.
pub(crate) fn point_in_shape(cx: f64, cy: f64, r: f64, shape: RingShape, px: f64, py: f64) -> bool {
let dx = (px - cx).abs();
let dy = (py - cy).abs();
match shape {
RingShape::Circle => dx * dx + dy * dy <= r * r,
RingShape::Square => dx <= r && dy <= r,
RingShape::Diamond => dx + dy <= r,
RingShape::Hexagon => {
// Regular hexagon, vertex at (r, 0), edge slopes at ±60°.
// For |dx| ≥ r/2 the sloping edge bounds: |dy| ≤ √3 (r |dx|).
// For |dx| ≤ r/2 the flat top bounds: |dy| ≤ √3 r / 2.
let sqrt3 = 3.0_f64.sqrt();
if dx >= r / 2.0 {
dy <= sqrt3 * (r - dx)
} else {
dy <= r * sqrt3 / 2.0
}
}
RingShape::Pill => {
// Pill = centre rectangle (2r × 2r) + semicircular caps of
// radius r at each end. Total width 4r, height 2r.
if dx <= r {
dy <= r
} else {
let ex = dx - r;
ex * ex + dy * dy <= r * r
}
}
}
}
/// Return the normalized `t` offset that places the first password dot at the
/// visual top-centre of the shape. May be negative; callers should NOT wrap.
pub(crate) fn top_centre_offset(shape: RingShape) -> f64 {
+4 -257
View File
@@ -36,270 +36,17 @@ impl Screenshot {
/// Apply configured visual effects to the screenshot.
pub fn apply_effects(&mut self, config: &Config) -> Result<()> {
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 {
self.apply_vignette(base, factor)?;
crate::effects::apply_vignette(&mut self.surface, base, factor)?;
}
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 {
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(())
}
}