Compare commits
2 Commits
c2c093595a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
7603975062
|
|||
|
833706f5a5
|
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,10 +26,8 @@ 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"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
# 🔒 RustLock
|
# 🔒 RustLock
|
||||||
|
|
||||||
[](https://github.com/yourusername/rustlock/blob/main/LICENSE)
|
[](https://github.com/yourusername/rustlock/blob/main/LICENSE)
|
||||||
[](https://github.com/yourusername/rustlock/releases)
|
[](https://github.com/yourusername/rustlock/releases)
|
||||||
|
|
||||||
A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`.
|
A high-performance Wayland screen locker written in Rust, inspired by `swaylock-effects`.
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="assets/rustlock-effects.webp" alt="RustLock effects demo" width="800">
|
||||||
|
</p>
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
@@ -13,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 |
|
||||||
|
|||||||
|
After Width: | Height: | Size: 149 KiB |
|
After Width: | Height: | Size: 103 KiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 784 KiB |
|
After Width: | Height: | Size: 443 KiB |
@@ -28,8 +28,27 @@
|
|||||||
nativeBuildInputs = [
|
nativeBuildInputs = [
|
||||||
pkgs.pkg-config
|
pkgs.pkg-config
|
||||||
pkgs.rustPlatform.bindgenHook
|
pkgs.rustPlatform.bindgenHook
|
||||||
pkgs.rustfmt
|
];
|
||||||
pkgs.clippy
|
};
|
||||||
|
|
||||||
|
devShells.${system}.default = pkgs.mkShell {
|
||||||
|
packages = with pkgs; [
|
||||||
|
cargo
|
||||||
|
rustc
|
||||||
|
rustfmt
|
||||||
|
clippy
|
||||||
|
cargo-audit
|
||||||
|
cargo-deny
|
||||||
|
prek
|
||||||
|
pkg-config
|
||||||
|
rustPlatform.bindgenHook
|
||||||
|
cairo
|
||||||
|
pam
|
||||||
|
gdk-pixbuf
|
||||||
|
librsvg
|
||||||
|
pango
|
||||||
|
libxkbcommon
|
||||||
|
dbus
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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(())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 hit‑testing (e.g. click & hold to peek) so the clickable
|
||||||
|
/// region matches what the user sees on screen.
|
||||||
|
pub(crate) fn point_in_shape(cx: f64, cy: f64, r: f64, shape: RingShape, px: f64, py: f64) -> bool {
|
||||||
|
let dx = (px - cx).abs();
|
||||||
|
let dy = (py - cy).abs();
|
||||||
|
match shape {
|
||||||
|
RingShape::Circle => dx * dx + dy * dy <= r * r,
|
||||||
|
RingShape::Square => dx <= r && dy <= r,
|
||||||
|
RingShape::Diamond => dx + dy <= r,
|
||||||
|
RingShape::Hexagon => {
|
||||||
|
// Regular hexagon, vertex at (r, 0), edge slopes at ±60°.
|
||||||
|
// For |dx| ≥ r/2 the sloping edge bounds: |dy| ≤ √3 (r − |dx|).
|
||||||
|
// For |dx| ≤ r/2 the flat top bounds: |dy| ≤ √3 r / 2.
|
||||||
|
let sqrt3 = 3.0_f64.sqrt();
|
||||||
|
if dx >= r / 2.0 {
|
||||||
|
dy <= sqrt3 * (r - dx)
|
||||||
|
} else {
|
||||||
|
dy <= r * sqrt3 / 2.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RingShape::Pill => {
|
||||||
|
// Pill = centre rectangle (2r × 2r) + semicircular caps of
|
||||||
|
// radius r at each end. Total width 4r, height 2r.
|
||||||
|
if dx <= r {
|
||||||
|
dy <= r
|
||||||
|
} else {
|
||||||
|
let ex = dx - r;
|
||||||
|
ex * ex + dy * dy <= r * r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Return the normalized `t` offset that places the first password dot at the
|
/// 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 {
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||