feat: ring shapes, auth reuse, input tests, README docs, CI improvements
Nightly Release / nightly-build (push) Has been cancelled
Code Quality / quality-checks (push) Has been cancelled
Security Scan / security-audit (push) Has been cancelled

- Add --ring-shape (circle/square/diamond/hexagon/pill) with geometry module
- Reuse PAM context across auth attempts (no reload per-keystroke)
- Proper error propagation in screenshot effects (unwrap → Result + context)
- Remove theme presets (modern/pixel/glass) and --theme flag
- Add input cooldown after failed attempt (400ms debounce)
- Add Ctrl+held peek password, Home/End/Delete cursor keys
- Add all input unit tests (23 new tests)
- Add --max-dots, feedback duration, timeout, interval config options
- Add --log-path, --auth-timeout, verifying_color config fields
- Remove unused deps: futures, gio, env_logger, num-traits, thiserror, bytemuck
- Fix media bar: remove stop button, fix art+text overlap, uniform hit areas
- Fix CI: source-only releases, nightly prereleases, dependabot groups, labeler paths
- Update issue templates with structured forms
- Add stale workflow for inactive issues/PRs
- Update README: full options table (28 flags), remove theme docs, add shapes
This commit is contained in:
2026-06-21 17:28:58 +02:00
parent db3e797e36
commit c2c093595a
25 changed files with 1610 additions and 836 deletions
+51 -41
View File
@@ -1,5 +1,8 @@
use std::ffi::{CStr, CString};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::thread;
use std::time::Duration;
use log::{debug, error};
use pam_client::{Context, ErrorCode, Flag};
@@ -7,9 +10,10 @@ use smithay_client_toolkit::reexports::{calloop::channel, calloop::EventLoop};
use whoami::username;
use zeroize::Zeroizing;
const SERVICE_NAME: &str = "rustlock";
pub struct LockConversation {
type AuthChannels = (
channel::Sender<(Zeroizing<String>, u64)>,
channel::Channel<(bool, u64)>,
);pub struct LockConversation {
pub password: Option<Zeroizing<String>>,
}
@@ -36,59 +40,65 @@ impl pam_client::ConversationHandler for LockConversation {
}
pub fn create_and_run_auth_loop(
) -> Option<(channel::Sender<Zeroizing<String>>, channel::Channel<bool>)> {
service_name: String,
) -> Option<AuthChannels> {
let username = username();
let conversation = LockConversation { password: None };
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
Ok(_) => {
debug!("Prepared to authenticate user '{}'", username);
}
Err(err) => {
error!("Failed to initialize PAM context: {:?}", err);
error!(
"Ensure that the PAM service '{}' is correctly configured.",
SERVICE_NAME
);
return None;
}
}
let (auth_req_send, auth_req_recv) = channel::channel::<Zeroizing<String>>();
let (auth_res_send, auth_res_recv) = channel::channel::<bool>();
let (auth_req_send, auth_req_recv) =
channel::channel::<(Zeroizing<String>, u64)>();
let (auth_res_send, auth_res_recv) = channel::channel::<(bool, u64)>();
thread::spawn(move || {
let mut event_loop: EventLoop<()> = EventLoop::try_new().unwrap();
// Create PAM context once and reuse it for all auth attempts.
// Creating a new context each time is expensive because it
// re-parses configs and re-loads shared libraries for every attempt.
let conversation = LockConversation { password: None };
let mut context = match Context::new(service_name.as_str(), Some(username.as_str()), conversation) {
Ok(ctx) => {
debug!("Prepared to authenticate user '{}'", username);
ctx
}
Err(err) => {
error!("Failed to initialize PAM context: {:?}", err);
error!(
"Ensure that the PAM service '{}' is correctly configured.",
service_name
);
return;
}
};
let running = Arc::new(AtomicBool::new(true));
let running_clone = running.clone();
event_loop
.handle()
.insert_source(auth_req_recv, |evt, _metadata, _state| match evt {
channel::Event::Msg(password) => {
let conversation = LockConversation {
password: Some(password),
};
match Context::new(SERVICE_NAME, Some(username.as_str()), conversation) {
Ok(mut context) => match context.authenticate(Flag::NONE) {
Ok(()) => {
auth_res_send.send(true).unwrap();
}
Err(err) => {
error!("Pam authenticate failed with {:?}", err);
auth_res_send.send(false).unwrap();
}
},
.insert_source(auth_req_recv, move |evt, _metadata, _state| match evt {
channel::Event::Msg((password, seq)) => {
context.conversation_mut().password = Some(password);
match context.authenticate(Flag::NONE) {
Ok(()) => {
let _ = auth_res_send.send((true, seq));
}
Err(err) => {
error!("Failed to re-initialize PAM context: {:?}", err);
auth_res_send.send(false).unwrap();
error!("Pam authenticate failed with {:?}", err);
let _ = auth_res_send.send((false, seq));
}
}
}
channel::Event::Closed => {}
channel::Event::Closed => {
running_clone.store(false, Ordering::SeqCst);
}
})
.unwrap();
loop {
event_loop.dispatch(None, &mut ()).expect("Failed to run");
while running.load(Ordering::SeqCst) {
let _ = event_loop.dispatch(Some(Duration::from_millis(100)), &mut ());
}
debug!("PAM auth thread exiting cleanly");
});
Some((auth_req_send, auth_res_recv))
+160 -50
View File
@@ -1,7 +1,49 @@
use crate::util;
use clap::Parser;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub enum RingShape {
#[default]
Circle,
Square,
Diamond,
Hexagon,
Pill,
}
impl FromStr for RingShape {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"circle" => Ok(RingShape::Circle),
"square" => Ok(RingShape::Square),
"diamond" => Ok(RingShape::Diamond),
"hexagon" => Ok(RingShape::Hexagon),
"pill" => Ok(RingShape::Pill),
_ => Err(format!(
"Unknown ring shape '{}'. Options: circle, square, diamond, hexagon, pill",
s
)),
}
}
}
impl fmt::Display for RingShape {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RingShape::Circle => write!(f, "circle"),
RingShape::Square => write!(f, "square"),
RingShape::Diamond => write!(f, "diamond"),
RingShape::Hexagon => write!(f, "hexagon"),
RingShape::Pill => write!(f, "pill"),
}
}
}
#[derive(Parser, Debug, Clone, Serialize, Deserialize)]
#[command(author, version, about, long_about = None)]
@@ -21,6 +63,10 @@ pub struct Config {
#[arg(long, default_value = "7")]
pub indicator_thickness: u32,
#[arg(long, default_value = "circle", value_parser = clap::value_parser!(RingShape))]
#[serde(default)]
pub ring_shape: RingShape,
#[arg(long, value_parser = util::parse_blur_effect)]
#[serde(
deserialize_with = "util::deserialize_blur_effect",
@@ -91,6 +137,13 @@ pub struct Config {
)]
pub caps_lock_text_color: (f64, f64, f64, f64),
#[arg(long, default_value = "0072FF", value_parser = util::parse_hex_color)]
#[serde(
deserialize_with = "util::deserialize_hex_color",
serialize_with = "util::serialize_hex_color"
)]
pub verifying_color: (f64, f64, f64, f64),
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
pub show_caps_lock_text: bool,
@@ -115,7 +168,7 @@ pub struct Config {
)]
pub separator_color: (f64, f64, f64, f64),
#[arg(long, default_value = "2")]
#[arg(long, default_value = "0")]
pub grace: f32,
#[arg(long, default_value = "0.2")]
@@ -134,6 +187,15 @@ pub struct Config {
#[arg(long)]
pub log_file: bool,
/// Path for log file (enables file logging, overrides --log-file default path)
#[arg(long)]
#[serde(default)]
pub log_path: Option<PathBuf>,
/// Timeout (ms) for PAM authentication before showing failure
#[arg(long, default_value = "10000")]
pub auth_timeout: u64,
#[arg(long, action = clap::ArgAction::SetTrue, default_value_t = true)]
pub show_media: bool,
@@ -191,10 +253,46 @@ pub struct Config {
#[serde(default)]
pub media_next_icon: Option<String>,
/// Apply a pre-defined theme preset
#[arg(long)]
#[serde(default)]
pub theme: Option<String>,
/// Maximum number of password dots in the indicator ring
#[arg(long, default_value = "24")]
pub max_dots: u32,
/// Duration (ms) for wrong password feedback animation
#[arg(long, default_value = "500")]
pub wrong_password_duration: u64,
/// Duration (ms) for key highlight feedback animation
#[arg(long, default_value = "300")]
pub key_highlight_duration: u64,
/// Duration (ms) for cleared password feedback animation
#[arg(long, default_value = "500")]
pub cleared_feedback_duration: u64,
/// Duration (ms) for verifying feedback fallback timeout
#[arg(long, default_value = "5000")]
pub verifying_timeout: u64,
/// Duration (ms) that wrong password feedback is shown input-side
#[arg(long, default_value = "1000")]
pub feedback_window_duration: u64,
/// Duration (ms) for key highlight feedback input-side window
#[arg(long, default_value = "200")]
pub key_highlight_window_duration: u64,
/// Polling interval (seconds) for system status updates
#[arg(long, default_value = "2")]
pub system_poll_interval: u64,
/// Delay (seconds) before reconnecting DBus on failure
#[arg(long, default_value = "5")]
pub dbus_reconnect_delay: u64,
/// Timeout (seconds) for system commands (poweroff, reboot, suspend)
#[arg(long, default_value = "5")]
pub command_timeout: u64,
}
impl Config {
@@ -209,7 +307,7 @@ impl Config {
let is_cli =
|key: &str| matches.value_source(key) == Some(clap::parser::ValueSource::CommandLine);
// 1. Config file layer (overrides defaults and themes)
// Config file layer (overrides defaults, CLI args take precedence)
let config_path = config.config.clone().unwrap_or_else(|| {
let mut path = std::path::PathBuf::from(std::env::var("HOME").unwrap_or_default());
path.push(".config/rustlock/config.toml");
@@ -242,51 +340,63 @@ impl Config {
}
}
// 2. Theme presets (applied to fields NOT set on CLI or in File)
if let Some(theme) = &config.theme {
match theme.as_str() {
"modern" => {
if config.effect_blur.is_none() && !is_cli("effect_blur") {
config.effect_blur = Some((10, 3));
}
if config.effect_vignette.is_none() && !is_cli("effect_vignette") {
config.effect_vignette = Some((0.5, 0.5));
}
if !is_cli("indicator_radius") {
config.indicator_radius = 120;
}
if !is_cli("ring_color") {
config.ring_color = (0.2, 0.6, 0.8, 1.0);
}
}
"pixel" => {
if config.effect_pixelate.is_none() && !is_cli("effect_pixelate") {
config.effect_pixelate = Some(10);
}
if !is_cli("indicator_radius") {
config.indicator_radius = 80;
}
if !is_cli("ring_color") {
config.ring_color = (0.8, 0.2, 0.2, 1.0);
}
}
"glass" => {
if config.effect_blur.is_none() && !is_cli("effect_blur") {
config.effect_blur = Some((20, 5));
}
if !is_cli("inside_color") {
config.inside_color = (1.0, 1.0, 1.0, 0.1);
}
if !is_cli("ring_color") {
config.ring_color = (1.0, 1.0, 1.0, 0.5);
}
}
_ => {
log::warn!("Unknown theme: {}", theme);
}
}
}
config.auth_timeout = config.auth_timeout.max(100);
config.max_dots = config.max_dots.max(1);
config.fade_in = config.fade_in.max(0.0);
config.grace = config.grace.max(0.0);
config
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_max_dots_default() {
let config = Config::parse_from(["test"]);
assert_eq!(config.max_dots, 24);
}
#[test]
fn test_auth_timeout_default() {
let config = Config::parse_from(["test"]);
assert_eq!(config.auth_timeout, 10000);
}
#[test]
fn test_auth_timeout_min_clamp() {
let mut config = Config::parse_from(["test", "--auth-timeout", "0"]);
config.auth_timeout = config.auth_timeout.max(100);
assert_eq!(config.auth_timeout, 100);
}
#[test]
fn test_max_dots_min_clamp() {
let mut config = Config::parse_from(["test", "--max-dots", "0"]);
config.max_dots = config.max_dots.max(1);
assert_eq!(config.max_dots, 1);
}
#[test]
fn test_fade_in_negative_clamp() {
let mut config = Config::parse_from(["test", "--fade-in=-1"]);
config.fade_in = config.fade_in.max(0.0);
assert_eq!(config.fade_in, 0.0);
}
#[test]
fn test_grace_negative_clamp() {
let mut config = Config::parse_from(["test", "--grace=-1"]);
config.grace = config.grace.max(0.0);
assert_eq!(config.grace, 0.0);
}
#[test]
fn test_log_path_default_none() {
let config = Config::parse_from(["test"]);
assert!(config.log_path.is_none());
}
}
+295 -13
View File
@@ -7,16 +7,20 @@ pub struct InputHandler {
wrong_password_timer: Option<std::time::Instant>,
key_highlight_timer: Option<std::time::Instant>,
caps_lock: bool,
config: crate::config::Config,
last_failed_attempt: Option<std::time::Instant>,
}
impl InputHandler {
pub fn new(_config: crate::config::Config) -> Self {
pub fn new(config: crate::config::Config) -> Self {
Self {
password_buffer: Zeroizing::new(String::new()),
cursor_position: 0,
wrong_password_timer: None,
key_highlight_timer: None,
caps_lock: false,
config,
last_failed_attempt: None,
}
}
@@ -27,7 +31,10 @@ impl InputHandler {
utf8: Option<String>,
modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
) -> InputAction {
// Update Caps Lock state
if self.is_cooldown() {
return InputAction::None;
}
self.caps_lock = modifiers.caps_lock;
if modifiers.ctrl && keysym == Keysym::u {
@@ -43,12 +50,13 @@ impl InputHandler {
use smithay_client_toolkit::seat::keyboard::Keysym;
match keysym {
Keysym::BackSpace => {
if !self.password_buffer.is_empty() && self.cursor_position > 0 {
self.cursor_position -= 1;
self.password_buffer.remove(self.cursor_position);
if self.password_buffer.is_empty() {
return InputAction::PasswordCleared;
}
if self.password_buffer.is_empty() || self.cursor_position == 0 {
return InputAction::None;
}
self.cursor_position -= 1;
self.password_buffer.remove(self.cursor_position);
if self.password_buffer.is_empty() {
return InputAction::PasswordCleared;
}
return InputAction::PasswordChanged;
}
@@ -116,6 +124,10 @@ impl InputHandler {
InputAction::None
}
pub fn password_buffer(&self) -> &Zeroizing<String> {
&self.password_buffer
}
pub fn password_length(&self) -> usize {
self.password_buffer.len()
}
@@ -127,12 +139,19 @@ impl InputHandler {
/// Set wrong password feedback timer
pub fn set_wrong_password_feedback(&mut self) {
self.wrong_password_timer = Some(std::time::Instant::now());
self.last_failed_attempt = Some(std::time::Instant::now());
}
pub fn is_cooldown(&self) -> bool {
self.last_failed_attempt
.map(|t| t.elapsed() < std::time::Duration::from_millis(400))
.unwrap_or(false)
}
/// Check if wrong password feedback should be shown
pub fn should_show_wrong_password(&self) -> bool {
if let Some(timer) = self.wrong_password_timer {
timer.elapsed() < std::time::Duration::from_millis(1000)
timer.elapsed() < std::time::Duration::from_millis(self.config.feedback_window_duration)
} else {
false
}
@@ -146,15 +165,12 @@ impl InputHandler {
/// Check if key highlight should be shown
pub fn should_show_key_highlight(&self) -> bool {
if let Some(timer) = self.key_highlight_timer {
timer.elapsed() < std::time::Duration::from_millis(200)
timer.elapsed() < std::time::Duration::from_millis(self.config.key_highlight_window_duration)
} else {
false
}
}
/// Update timers (should be called periodically)
pub fn update(&mut self) {}
/// Get the current Caps Lock state
pub fn caps_lock(&self) -> bool {
self.caps_lock
@@ -171,3 +187,269 @@ pub enum InputAction {
SubmitPassword(Zeroizing<String>),
Cancel,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use clap::Parser;
use smithay_client_toolkit::seat::keyboard::{Keysym, Modifiers};
fn test_config() -> Config {
Config::parse_from(["test"])
}
#[test]
fn test_new_handler_defaults() {
let handler = InputHandler::new(test_config());
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
assert!(!handler.caps_lock());
assert!(!handler.should_show_wrong_password());
assert!(!handler.should_show_key_highlight());
}
#[test]
fn test_character_input_appends() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 1);
assert_eq!(handler.cursor_position(), 1);
let action = handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 2);
assert_eq!(handler.cursor_position(), 2);
assert_eq!(&*handler.password_buffer, "ab");
}
#[test]
fn test_backspace_removes_last_char() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert_eq!(handler.password_length(), 2);
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 1);
assert_eq!(handler.cursor_position(), 1);
assert_eq!(&*handler.password_buffer, "a");
}
#[test]
fn test_backspace_on_empty_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.password_length(), 0);
}
#[test]
fn test_backspace_last_char_clears() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::BackSpace, None, mods);
assert!(matches!(action, InputAction::PasswordCleared));
assert_eq!(handler.password_length(), 0);
}
#[test]
fn test_ctrl_u_clears_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers {
ctrl: true,
..Modifiers::default()
};
handler.handle_key_event(Keysym::a, Some("a".to_string()), Modifiers::default());
handler.handle_key_event(Keysym::b, Some("b".to_string()), Modifiers::default());
assert_eq!(handler.password_length(), 2);
let action = handler.handle_key_event(Keysym::u, None, mods);
assert!(matches!(action, InputAction::PasswordCleared));
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_submit_returns_and_clears() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
let action = handler.handle_key_event(Keysym::Return, None, mods);
match action {
InputAction::SubmitPassword(p) => {
assert_eq!(&*p, "ab");
}
_ => panic!("Expected SubmitPassword, got {:?}", action),
}
// Buffer should be cleared after submission
assert_eq!(handler.password_length(), 0);
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_submit_enter_kp() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::KP_Enter, None, Modifiers::default());
assert!(matches!(action, InputAction::SubmitPassword(_)));
}
#[test]
fn test_escape_cancels() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::Escape, None, mods);
assert!(matches!(action, InputAction::Cancel));
}
#[test]
fn test_cursor_left_right() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
assert_eq!(handler.cursor_position(), 3);
// Move left
let action = handler.handle_key_event(Keysym::Left, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 2);
// Left again
handler.handle_key_event(Keysym::Left, None, mods);
assert_eq!(handler.cursor_position(), 1);
// Right
let action = handler.handle_key_event(Keysym::Right, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 2);
}
#[test]
fn test_cursor_left_at_start() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
let action = handler.handle_key_event(Keysym::Left, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.cursor_position(), 0);
}
#[test]
fn test_cursor_right_at_end() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
let action = handler.handle_key_event(Keysym::Right, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.cursor_position(), 1);
}
#[test]
fn test_home_and_end() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
handler.handle_key_event(Keysym::Left, None, mods);
handler.handle_key_event(Keysym::Left, None, mods);
assert_eq!(handler.cursor_position(), 1);
// Home
let action = handler.handle_key_event(Keysym::Home, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 0);
// End
let action = handler.handle_key_event(Keysym::End, None, mods);
assert!(matches!(action, InputAction::CursorMoved));
assert_eq!(handler.cursor_position(), 3);
}
#[test]
fn test_delete_removes_at_cursor() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
// cursor at 3, delete should be a no-op
let action = handler.handle_key_event(Keysym::Delete, None, mods);
assert!(matches!(action, InputAction::None));
assert_eq!(handler.password_length(), 3);
// move left, delete at cursor position 2 (removes 'c')
handler.handle_key_event(Keysym::Left, None, mods);
let action = handler.handle_key_event(Keysym::Delete, None, mods);
assert!(matches!(action, InputAction::PasswordChanged));
assert_eq!(handler.password_length(), 2);
assert_eq!(&*handler.password_buffer, "ab");
}
#[test]
fn test_insert_mid_buffer() {
let mut handler = InputHandler::new(test_config());
let mods = Modifiers::default();
handler.handle_key_event(Keysym::a, Some("a".to_string()), mods);
handler.handle_key_event(Keysym::c, Some("c".to_string()), mods);
// Move left, insert 'b' between a and c
handler.handle_key_event(Keysym::Left, None, mods);
handler.handle_key_event(Keysym::b, Some("b".to_string()), mods);
assert_eq!(&*handler.password_buffer, "abc");
assert_eq!(handler.cursor_position(), 2);
}
#[test]
fn test_caps_lock_tracking() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.caps_lock());
let caps_mods = Modifiers {
caps_lock: true,
..Modifiers::default()
};
handler.handle_key_event(Keysym::a, Some("A".to_string()), caps_mods);
assert!(handler.caps_lock());
}
#[test]
fn test_wrong_password_timer() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.should_show_wrong_password());
handler.set_wrong_password_feedback();
assert!(handler.should_show_wrong_password());
}
#[test]
fn test_key_highlight_timer() {
let mut handler = InputHandler::new(test_config());
assert!(!handler.should_show_key_highlight());
handler.set_key_highlight();
assert!(handler.should_show_key_highlight());
}
}
+71 -28
View File
@@ -30,6 +30,7 @@ pub struct LockedSurface {
dirty: bool,
/// Last clock minute (unix-minute) we rendered, to detect %H:%M rollover.
last_minute: i64,
ctrl_held: bool,
}
impl LockedSurface {
@@ -62,6 +63,7 @@ impl LockedSurface {
configured: false,
dirty: true,
last_minute: i64::MIN,
ctrl_held: false,
})
}
@@ -86,8 +88,6 @@ impl LockedSurface {
/// surface (no input, no animation, same clock minute) returns `false` and
/// does no cairo work, which keeps a locked session near-zero CPU.
pub fn update(&mut self) -> bool {
self.input_handler.update();
if !self.configured {
return false;
}
@@ -96,30 +96,37 @@ impl LockedSurface {
if self.fade_alpha < 1.0 {
let elapsed = self.start_time.elapsed();
let fade_duration = std::time::Duration::from_secs_f32(self.config.fade_in);
// Ease-in-out cubic function
let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0);
let eased_t = if t < 0.5 {
4.0 * t * t * t
if fade_duration.is_zero() {
self.fade_alpha = 1.0;
self.renderer.set_fade_alpha(1.0);
self.dirty = true;
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
};
let new_alpha = eased_t.min(1.0);
if t >= 1.0 {
// The eased curve only approaches 1.0 asymptotically, and the
// 0.001 throttle below suppresses the tiny final steps — which
// would leave fade_alpha stuck just under 1.0 forever. Since
// `fade_alpha < 1.0` is our "still animating" signal, that would
// force a full render every frame. Snap to exactly 1.0 once the
// fade duration has elapsed so the animation cleanly completes.
if self.fade_alpha != 1.0 {
self.fade_alpha = 1.0;
self.renderer.set_fade_alpha(1.0);
// Ease-in-out cubic function
let t = (elapsed.as_secs_f64() / fade_duration.as_secs_f64()).clamp(0.0, 1.0);
let eased_t = if t < 0.5 {
4.0 * t * t * t
} else {
1.0 - (-2.0 * t + 2.0).powi(3) / 2.0
};
let new_alpha = eased_t.min(1.0);
if t >= 1.0 {
// The eased curve only approaches 1.0 asymptotically, and the
// 0.001 throttle below suppresses the tiny final steps — which
// would leave fade_alpha stuck just under 1.0 forever. Since
// `fade_alpha < 1.0` is our "still animating" signal, that would
// force a full render every frame. Snap to exactly 1.0 once the
// fade duration has elapsed so the animation cleanly completes.
if self.fade_alpha != 1.0 {
self.fade_alpha = 1.0;
self.renderer.set_fade_alpha(1.0);
self.dirty = true;
}
} else if (new_alpha - self.fade_alpha).abs() > 0.001 {
self.fade_alpha = new_alpha;
self.renderer.set_fade_alpha(self.fade_alpha);
self.dirty = true;
}
} else if (new_alpha - self.fade_alpha).abs() > 0.001 {
self.fade_alpha = new_alpha;
self.renderer.set_fade_alpha(self.fade_alpha);
self.dirty = true;
}
}
@@ -150,7 +157,7 @@ impl LockedSurface {
// Set background if available and not already applied
if !self.background_applied {
if let Some(ref background) = self.background {
log::info!("Applying background image to renderer");
log::debug!("Applying background image to renderer");
self.renderer.set_background(background.clone());
self.background_applied = true;
self.dirty = true;
@@ -174,8 +181,15 @@ impl LockedSurface {
return false;
}
self.renderer
.set_password_display(self.input_handler.password_length());
if !self.config.hide_password {
let length = self.input_handler.password_length();
if self.ctrl_held {
self.renderer
.peek_password(self.input_handler.password_buffer().as_str());
} else {
self.renderer.set_password_display(length);
}
}
self.renderer
.set_cursor_position(self.input_handler.cursor_position());
self.renderer.render();
@@ -217,14 +231,24 @@ impl LockedSurface {
}
pub fn show_wrong_password(&mut self) {
self.renderer.clear_verifying();
self.input_handler.set_wrong_password_feedback();
self.wrong_password_shown = false;
self.dirty = true;
}
pub fn show_verifying(&mut self) {
self.renderer.show_verifying();
self.dirty = true;
}
pub fn handle_key_event(
&mut self,
event: smithay_client_toolkit::seat::keyboard::KeyEvent,
event: KeyEvent,
modifiers: smithay_client_toolkit::seat::keyboard::Modifiers,
) -> Option<InputAction> {
self.ctrl_held = modifiers.ctrl;
let action = self
.input_handler
.handle_key_event(event.keysym, event.utf8, modifiers);
@@ -271,6 +295,13 @@ impl LockedSurface {
self.dirty = true;
}
}
pub fn set_ctrl_held(&mut self, held: bool) {
if self.ctrl_held != held {
self.ctrl_held = held;
self.dirty = true;
}
}
}
pub struct LockManager {
@@ -321,12 +352,24 @@ impl LockManager {
let mut action = None;
for surface in &mut self.surfaces {
if let Some(a) = surface.handle_key_event(event.clone(), modifiers) {
action = Some(a);
if let crate::input::InputAction::SubmitPassword(p) = &a {
if !p.is_empty() {
return Some(a);
}
} else {
action = Some(a);
}
}
}
action
}
pub fn set_ctrl_held(&mut self, held: bool) {
for surface in &mut self.surfaces {
surface.set_ctrl_held(held);
}
}
pub fn remove_surface_by_output(&mut self, output: &wl_output::WlOutput) -> Option<usize> {
use wayland_client::Proxy;
let output_id = Proxy::id(output);
+154 -68
View File
@@ -48,21 +48,40 @@ use smithay_client_toolkit::{
static FILE_LOGGER: std::sync::LazyLock<std::sync::Mutex<Option<std::fs::File>>> =
std::sync::LazyLock::new(|| std::sync::Mutex::new(None));
fn setup_file_logging(_config: &Config) {
let log_path = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()) + "/.rustlock.log";
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&log_path)
{
Ok(file) => {
*FILE_LOGGER.lock().unwrap() = Some(file);
eprintln!("Logging to: {}", log_path);
fn setup_file_logging(config: &Config) {
if let Some(ref path) = config.log_path {
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(path)
{
Ok(file) => {
*FILE_LOGGER.lock().unwrap() = Some(file);
eprintln!("Logging to: {}", path.display());
}
Err(e) => {
eprintln!("Failed to open log file {}: {}", path.display(), e);
}
}
Err(e) => {
eprintln!("Failed to open log file {}: {}", log_path, e);
} else if config.log_file {
let default_path = std::path::PathBuf::from(
std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()),
)
.join(".rustlock.log");
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&default_path)
{
Ok(file) => {
*FILE_LOGGER.lock().unwrap() = Some(file);
eprintln!("Logging to: {}", default_path.display());
}
Err(e) => {
eprintln!("Failed to open log file {}: {}", default_path.display(), e);
}
}
}
}
@@ -110,7 +129,9 @@ struct WaylandLock {
lock_manager: Arc<Mutex<LockManager>>,
config: Config,
ctrlc_exit: Arc<std::sync::atomic::AtomicBool>,
auth_tx: Option<calloop::channel::Sender<Zeroizing<String>>>,
auth_tx: Option<calloop::channel::Sender<(Zeroizing<String>, u64)>>,
auth_seq: u64,
auth_pending_seq: Option<u64>,
compositor_state: CompositorState,
output_state: OutputState,
registry_state: RegistryState,
@@ -127,6 +148,7 @@ struct WaylandLock {
exit: bool,
screenshot_manager: Option<ScreenshotManager>,
grace_until: Option<Instant>,
auth_pending_at: Option<Instant>,
system_manager: Arc<SystemManager>,
modifiers: Modifiers,
current_layout: u32,
@@ -134,8 +156,10 @@ struct WaylandLock {
impl WaylandLock {
fn handle_auth_result(&mut self, success: bool) {
// Clear grace period on any auth result
// Clear grace period and auth pending on any auth result
self.grace_until = None;
self.auth_pending_at = None;
self.auth_pending_seq = None;
if success {
log::info!("✅ Authentication successful - unlocking session");
@@ -148,13 +172,13 @@ impl WaylandLock {
session_lock.unlock();
let _ = self.conn.flush();
self.exit = true;
log::debug!("Unlock requested - exiting");
log::info!("Unlock requested - exiting");
} else {
log::error!("No session_lock available to unlock!");
self.exit = true;
}
} else {
log::warn!("❌ Authentication failed - wrong password");
log::error!("❌ Authentication failed - wrong password");
if let Ok(mut lock_manager) = self.lock_manager.lock() {
for surface in &mut lock_manager.surfaces {
surface.show_wrong_password();
@@ -193,17 +217,17 @@ impl WaylandLock {
}
Keysym::F1 => {
self.system_manager
.send_command(system::SystemCommand::Suspend);
.send_command(system::BackendCommand::Suspend);
return;
}
Keysym::F2 => {
self.system_manager
.send_command(system::SystemCommand::Reboot);
.send_command(system::BackendCommand::Reboot);
return;
}
Keysym::F3 => {
self.system_manager
.send_command(system::SystemCommand::PowerOff);
.send_command(system::BackendCommand::PowerOff);
return;
}
_ => {}
@@ -220,30 +244,44 @@ impl WaylandLock {
}
if event.keysym == Keysym::Return {
// Debounce: skip if auth is already pending (user pressed Enter twice)
if self.auth_pending_at.is_some() {
return;
}
log::info!("Enter pressed - submitting password");
if let Ok(mut lock_manager) = self.lock_manager.lock() {
let mut password = Zeroizing::new(String::new());
let modifiers = self.modifiers;
for surface in &mut lock_manager.surfaces {
if let Some(InputAction::SubmitPassword(p)) =
surface.handle_key_event(event.clone(), modifiers)
{
password = p;
let password: Option<Zeroizing<String>> = self
.lock_manager
.lock()
.ok()
.and_then(|mut lm| lm.handle_key_event(event, self.modifiers))
.and_then(|action| {
if let InputAction::SubmitPassword(p) = action {
(!p.is_empty()).then_some(p)
} else {
None
}
});
if let Some(password) = password {
self.auth_seq += 1;
self.auth_pending_at = Some(Instant::now());
self.auth_pending_seq = Some(self.auth_seq);
// Show verifying feedback on ALL surfaces BEFORE sending to PAM.
if let Ok(mut lock_manager) = self.lock_manager.lock() {
for surface in &mut lock_manager.surfaces {
surface.show_verifying();
}
}
if !password.is_empty() {
if let Some(tx) = &self.auth_tx {
let _ = tx.send(password);
}
if let Some(tx) = &self.auth_tx {
let _ = tx.send((password, self.auth_seq));
}
}
} else {
let modifiers = self.modifiers;
let _action = self
.lock_manager
self.lock_manager
.lock()
.map(|mut lm| lm.handle_key_event(event, modifiers))
.unwrap_or(None);
.ok()
.and_then(|mut lm| lm.handle_key_event(event, self.modifiers));
}
}
}
@@ -470,6 +508,9 @@ impl KeyboardHandler for WaylandLock {
) {
self.modifiers = modifiers;
self.current_layout = layout;
if let Ok(mut lock_manager) = self.lock_manager.lock() {
lock_manager.set_ctrl_held(modifiers.ctrl);
}
}
}
@@ -552,7 +593,10 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
height,
stride,
} => {
let format = format.into_result().unwrap();
let Ok(format) = format.into_result() else {
log::error!("Screencopy: invalid buffer format, skipping capture");
return;
};
let mut info = data.info.lock().unwrap();
*info = Some(screenshot::BufferInfo {
@@ -581,7 +625,11 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
}
}
Event::Flags { flags } => {
*data.flags.lock().unwrap() = Some(flags.into_result().unwrap());
if let Ok(f) = flags.into_result() {
*data.flags.lock().unwrap() = Some(f);
} else {
log::error!("Screencopy: invalid flags, skipping");
}
}
Event::Ready { .. } => {
log::info!("Screencopy: Ready for output {}", data.output_idx);
@@ -601,7 +649,9 @@ impl Dispatch<ZwlrScreencopyFrameV1, CaptureData> for WaylandLock {
};
if let Ok(surface) = mgr.buffer_to_surface(handle, &mut pool) {
let mut ss = Screenshot::new(surface);
let _ = ss.apply_effects(&state.config);
if let Err(e) = ss.apply_effects(&state.config) {
log::error!("Failed to apply effects to screenshot {}: {e}", data.output_idx);
}
if data.output_idx < state.captured_backgrounds.len() {
state.captured_backgrounds[data.output_idx] = Some(ss.into_inner());
}
@@ -683,9 +733,15 @@ wayland_client::delegate_noop!(WaylandLock: ignore wayland_client::protocol::wl_
fn main() -> Result<(), Box<dyn Error>> {
let config = Config::load();
setup_file_logging(&config);
static LOGGER: DualLogger = DualLogger;
log::set_logger(&LOGGER).map(|()| log::set_max_level(log::LevelFilter::Debug))?;
let max_level = if config.debug {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
};
log::set_logger(&LOGGER).map(|()| log::set_max_level(max_level))?;
log::info!("Starting rustlock v{}", env!("CARGO_PKG_VERSION"));
#[allow(clippy::arc_with_non_send_sync)]
@@ -699,13 +755,14 @@ fn main() -> Result<(), Box<dyn Error>> {
let shm_state = Shm::bind(&globals, &qh).map_err(|_| "wl_shm not supported")?;
let system_manager = Arc::new(SystemManager::new());
let system_manager = Arc::new(SystemManager::new(&config));
let (auth_tx_actual, auth_feedback_rx_actual) = match auth::create_and_run_auth_loop() {
let (auth_tx_actual, auth_feedback_rx_actual) =
match auth::create_and_run_auth_loop(config.pam_service.clone()) {
Some(channels) => channels,
None => {
log::error!("Failed to initialize authentication. This usually means PAM is not configured correctly.");
log::error!("Please ensure you have a PAM service file at /etc/pam.d/rustlock");
log::error!("Please ensure you have a PAM service file at /etc/pam.d/{}", config.pam_service);
std::process::exit(1);
}
};
@@ -740,6 +797,9 @@ fn main() -> Result<(), Box<dyn Error>> {
exit: false,
screenshot_manager: ScreenshotManager::new(&globals, &qh).ok(),
grace_until: None,
auth_pending_at: None,
auth_seq: 0,
auth_pending_seq: None,
system_manager: system_manager.clone(),
modifiers: Modifiers::default(),
current_layout: 0,
@@ -753,31 +813,41 @@ fn main() -> Result<(), Box<dyn Error>> {
if let Ok(img) = image::open(image_path) {
let img = img.to_rgba8();
let (w, h) = img.dimensions();
let mut surface =
cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32).unwrap();
if let Ok(mut surface) =
cairo::ImageSurface::create(cairo::Format::ARgb32, w as i32, h as i32)
{
let mut surface_data = surface.data().unwrap();
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2]; // B
surface_data[idx + 1] = pixel[1]; // G
surface_data[idx + 2] = pixel[0]; // R
surface_data[idx + 3] = pixel[3]; // A
let image_ok = {
if let Ok(mut surface_data) = surface.data() {
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2];
surface_data[idx + 1] = pixel[1];
surface_data[idx + 2] = pixel[0];
surface_data[idx + 3] = pixel[3];
}
}
true
} else {
log::error!("Failed to get background image surface data");
false
}
};
if image_ok {
let mut ss = Screenshot::new(surface);
if let Err(e) = ss.apply_effects(&state.config) {
log::error!("Failed to apply effects to custom background image: {e}");
}
let surface = ss.into_inner();
let num_outputs = state.output_state.outputs().count();
state.captured_backgrounds = vec![Some(surface); num_outputs];
state.config.screenshots = false;
}
} else {
log::error!("Failed to create Cairo surface for background image");
}
let mut ss = Screenshot::new(surface);
let _ = ss.apply_effects(&state.config);
let surface = ss.into_inner();
let num_outputs = state.output_state.outputs().count();
state.captured_backgrounds = vec![Some(surface); num_outputs];
// Disable screenshots if image was successfully loaded
state.config.screenshots = false;
} else {
log::error!(
"Failed to load custom background image from {:?}",
@@ -811,8 +881,11 @@ fn main() -> Result<(), Box<dyn Error>> {
event_loop
.handle()
.insert_source(auth_feedback_rx_actual, |event, _, state| {
if let calloop::channel::Event::Msg(success) = event {
state.handle_auth_result(success);
if let calloop::channel::Event::Msg((success, seq)) = event {
// Ignore stale auth results from previous requests (e.g. after timeout or retry).
if state.auth_pending_seq == Some(seq) {
state.handle_auth_result(success);
}
}
})?;
@@ -825,6 +898,19 @@ fn main() -> Result<(), Box<dyn Error>> {
}
}
// Auth timeout: if PAM thread doesn't respond within config.auth_timeout ms,
// treat as auth failure so the user gets feedback instead of hanging forever.
if state.auth_pending_seq.is_some() {
if let Some(at) = state.auth_pending_at {
if Instant::now().duration_since(at) >= Duration::from_millis(state.config.auth_timeout) {
log::warn!("Authentication timed out after {} ms", state.config.auth_timeout);
// Clear pending seq so the eventual PAM result is ignored as stale
state.auth_pending_seq = None;
state.handle_auth_result(false);
}
}
}
let mut status = state.system_manager.get_status();
status.keyboard_layout = Some(state.current_layout.to_string());
+93 -26
View File
@@ -1,7 +1,32 @@
use crate::render::ring_shape;
use crate::render::Renderer;
use std::time::Instant;
impl Renderer {
pub(crate) fn draw_verifying_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let (r, g, b, a) = self.config.verifying_color;
if a > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness + 2.0);
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
}
}
pub(crate) fn draw_wrong_password_feedback(&self) {
let center_x = self.width as f64 / 2.0;
let center_y = self.height as f64 / 2.0;
@@ -9,7 +34,7 @@ impl Renderer {
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.wrong_password_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(500);
let duration = std::time::Duration::from_millis(self.config.wrong_password_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
@@ -24,9 +49,15 @@ impl Renderer {
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
self.context.set_line_width(thickness + 2.0);
self.context
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
self.context.stroke().unwrap();
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
}
}
@@ -37,7 +68,7 @@ impl Renderer {
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.key_highlight_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(300);
let duration = std::time::Duration::from_millis(self.config.key_highlight_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
@@ -57,17 +88,26 @@ impl Renderer {
.set_source_rgba(r, g, b, a * intensity * self.fade_alpha);
self.context.set_line_width(thickness + 1.5);
let global_offset = (self.password_display.len() as f64 * 45.0).to_radians();
self.context.new_path();
let actual_start = global_offset + self.key_highlight_angle;
self.context.arc(
self.context.set_line_cap(cairo::LineCap::Round);
// Convert angle range to normalized perimeter t (for circle: t = angle / 2π)
let max_dots = self.config.max_dots as f64;
let t_offset = ring_shape::top_centre_offset(self.config.ring_shape);
let global_t = ((self.password_display.len() as f64) / max_dots) + t_offset;
let random_t = self.key_highlight_angle / (2.0 * std::f64::consts::PI);
let t_start = global_t + random_t;
let sector_t = 40.0 / 360.0;
let t_end = t_start + sector_t;
ring_shape::build_sector_path(
&self.context,
center_x,
center_y,
radius,
actual_start,
actual_start + (40.0_f64).to_radians(),
self.config.ring_shape,
t_start,
t_end,
);
self.context.stroke().unwrap();
render_try!(self.context.stroke());
}
}
@@ -78,7 +118,7 @@ impl Renderer {
let thickness = self.config.indicator_thickness as f64;
let intensity = if let Some(start) = self.cleared_feedback_start {
let elapsed = start.elapsed();
let duration = std::time::Duration::from_millis(500);
let duration = std::time::Duration::from_millis(self.config.cleared_feedback_duration);
if elapsed < duration {
1.0 - (elapsed.as_secs_f64() / duration.as_secs_f64())
} else {
@@ -92,32 +132,39 @@ impl Renderer {
self.context.new_path();
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha * 0.5);
self.context.arc(
ring_shape::build_fill_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
0.0,
2.0 * std::f64::consts::PI,
thickness,
self.config.ring_shape,
);
self.context.fill().unwrap();
render_try!(self.context.fill());
self.context.new_path();
self.context
.set_source_rgba(1.0, 0.0, 0.0, intensity * self.fade_alpha);
self.context.set_line_width(thickness + 4.0);
self.context
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
self.context.stroke().unwrap();
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius,
self.config.ring_shape,
);
render_try!(self.context.stroke());
self.context.new_path();
self.context.set_font_size(24.0);
self.context
.set_source_rgba(1.0, 1.0, 1.0, intensity * self.fade_alpha);
let text = "CLEARED";
let te = self.context.text_extents(text).unwrap();
let te = render_try!(self.context.text_extents(text));
self.context
.move_to(center_x - te.width() / 2.0, center_y - radius - 20.0);
self.context.show_text(text).unwrap();
render_try!(self.context.show_text(text));
}
}
@@ -127,33 +174,43 @@ impl Renderer {
self.wrong_password_start.is_some()
|| self.key_highlight_start.is_some()
|| self.cleared_feedback_start.is_some()
|| self.verifying_start.is_some()
}
pub(crate) fn update_feedback_timers(&mut self) {
self.update_uptime();
if let Some(start) = self.wrong_password_start {
if start.elapsed() > std::time::Duration::from_millis(500) {
if start.elapsed() > std::time::Duration::from_millis(self.config.wrong_password_duration) {
self.wrong_password_shown = false;
self.wrong_password_start = None;
}
}
if let Some(start) = self.key_highlight_start {
if start.elapsed() > std::time::Duration::from_millis(300) {
if start.elapsed() > std::time::Duration::from_millis(self.config.key_highlight_duration) {
self.key_highlight_shown = false;
self.key_highlight_start = None;
}
}
if let Some(start) = self.cleared_feedback_start {
if start.elapsed() > std::time::Duration::from_millis(500) {
if start.elapsed() > std::time::Duration::from_millis(self.config.cleared_feedback_duration) {
self.cleared_feedback_shown = false;
self.cleared_feedback_start = None;
}
}
if let Some(start) = self.verifying_start {
if start.elapsed() > std::time::Duration::from_millis(self.config.auth_timeout) {
self.verifying_shown = false;
self.verifying_start = None;
}
}
}
pub fn show_wrong_password(&mut self) {
self.wrong_password_shown = true;
self.wrong_password_start = Some(Instant::now());
// Clear verifying state — wrong password replaces it
self.verifying_shown = false;
self.verifying_start = None;
}
pub fn show_key_highlight(&mut self) {
@@ -162,8 +219,8 @@ impl Renderer {
use std::time::SystemTime;
let seed = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap()
.as_nanos() as u64;
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
let random_val = seed.wrapping_mul(6364136223846793005).wrapping_add(1);
self.key_highlight_angle = ((random_val % 360) as f64).to_radians();
}
@@ -172,4 +229,14 @@ impl Renderer {
self.cleared_feedback_shown = true;
self.cleared_feedback_start = Some(Instant::now());
}
pub fn show_verifying(&mut self) {
self.verifying_shown = true;
self.verifying_start = Some(Instant::now());
}
pub fn clear_verifying(&mut self) {
self.verifying_shown = false;
self.verifying_start = None;
}
}
+42 -26
View File
@@ -1,3 +1,4 @@
use crate::render::ring_shape;
use crate::render::Renderer;
impl Renderer {
@@ -6,35 +7,40 @@ impl Renderer {
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape;
// Filled center
self.context.new_path();
let (r, g, b, a) = self.config.inside_color;
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.arc(
ring_shape::build_fill_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
0.0,
2.0 * std::f64::consts::PI,
thickness,
shape,
);
self.context.fill().unwrap();
render_try!(self.context.fill());
// Separator line behind the ring
let (lr, lg, lb, la) = self.config.line_color;
if la > 0.0 {
self.context.new_path();
self.context
.set_source_rgba(lr, lg, lb, la * self.fade_alpha);
self.context.set_line_width(1.0);
self.context.arc(
ring_shape::build_ring_path(
&self.context,
center_x,
center_y,
radius - thickness / 2.0,
0.0,
2.0 * std::f64::consts::PI,
shape,
);
self.context.stroke().unwrap();
render_try!(self.context.stroke());
}
// Outer ring
let (r, g, b, a) = if self.caps_lock {
self.config.caps_lock_color
} else {
@@ -43,10 +49,11 @@ impl Renderer {
self.context.new_path();
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_line_width(thickness);
self.context
.arc(center_x, center_y, radius, 0.0, 2.0 * std::f64::consts::PI);
self.context.stroke().unwrap();
self.context.set_line_join(cairo::LineJoin::Round);
ring_shape::build_ring_path(&self.context, center_x, center_y, radius, shape);
render_try!(self.context.stroke());
// Separator line through center
let (r, g, b, a) = self.config.separator_color;
if a > 0.0 {
self.context.new_path();
@@ -54,7 +61,7 @@ impl Renderer {
self.context.set_line_width(1.0);
self.context.move_to(center_x - radius, center_y);
self.context.line_to(center_x + radius, center_y);
self.context.stroke().unwrap();
render_try!(self.context.stroke());
}
}
@@ -66,6 +73,7 @@ impl Renderer {
let center_y = self.height as f64 / 2.0;
let radius = self.config.indicator_radius as f64;
let thickness = self.config.indicator_thickness as f64;
let shape = self.config.ring_shape;
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
@@ -75,33 +83,41 @@ impl Renderer {
return;
}
let max_dots = self.config.max_dots as f64;
let dot_radius = radius - thickness - 10.0;
let angle_step = (360.0 / 24.0_f64).to_radians();
let t_offset = ring_shape::top_centre_offset(shape);
for i in 0..count {
let angle = (i as f64 * angle_step) - std::f64::consts::FRAC_PI_2;
let x = center_x + dot_radius * angle.cos();
let y = center_y + dot_radius * angle.sin();
let t = (i as f64 / max_dots) + t_offset;
let (x, y) = ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, t);
self.context.new_path();
self.context.arc(x, y, 4.0, 0.0, 2.0 * std::f64::consts::PI);
self.context.fill().unwrap();
render_try!(self.context.fill());
}
// Cursor indicator
if self.fade_alpha > 0.0 && self.cursor_position > 0 {
let cursor_angle =
((self.cursor_position as f64 - 0.5) * angle_step) - std::f64::consts::FRAC_PI_2;
let x1 = center_x + (dot_radius - 8.0) * cursor_angle.cos();
let y1 = center_y + (dot_radius - 8.0) * cursor_angle.sin();
let x2 = center_x + (dot_radius + 8.0) * cursor_angle.cos();
let y2 = center_y + (dot_radius + 8.0) * cursor_angle.sin();
let cursor_t = ((self.cursor_position as f64 - 0.5) / max_dots) + t_offset;
let (cx, cy) =
ring_shape::perimeter_point(center_x, center_y, dot_radius, shape, cursor_t);
let dx = cx - center_x;
let dy = cy - center_y;
let len = (dx * dx + dy * dy).sqrt().max(1.0);
let nx = dx / len;
let ny = dy / len;
let x1 = cx - 8.0 * nx;
let y1 = cy - 8.0 * ny;
let x2 = cx + 8.0 * nx;
let y2 = cy + 8.0 * ny;
self.context.new_path();
self.context.set_source_rgba(0.0, 0.8, 1.0, self.fade_alpha);
self.context.set_line_width(2.0);
self.context.move_to(x1, y1);
self.context.line_to(x2, y2);
self.context.stroke().unwrap();
render_try!(self.context.stroke());
}
}
@@ -114,9 +130,9 @@ impl Renderer {
self.context.set_source_rgba(r, g, b, a * self.fade_alpha);
self.context.set_font_size(24.0);
let text = "Caps Lock";
let te = self.context.text_extents(text).unwrap();
let te = render_try!(self.context.text_extents(text));
self.context
.move_to(center_x - te.width() / 2.0, center_y - radius - 10.0);
self.context.show_text(text).unwrap();
render_try!(self.context.show_text(text));
}
}
+65 -80
View File
@@ -7,7 +7,6 @@ impl Renderer {
let center_x = self.width as f64 / 2.0;
let start_y = self.height as f64 - 120.0;
let art_size = 56.0;
let spacing = 80.0;
if self.config.show_album_art && self.system_status.media_art_url != self.last_art_url {
self.last_art_url = self.system_status.media_art_url.clone();
@@ -16,45 +15,30 @@ impl Renderer {
if let Ok(img) = image::load_from_memory(data) {
let img = img.to_rgba8();
let (w, h) = img.dimensions();
let mut surface =
ImageSurface::create(Format::ARgb32, w as i32, h as i32).unwrap();
{
let mut surface_data = surface.data().unwrap();
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2];
surface_data[idx + 1] = pixel[1];
surface_data[idx + 2] = pixel[0];
surface_data[idx + 3] = pixel[3];
if let Ok(mut surface) = ImageSurface::create(Format::ARgb32, w as i32, h as i32) {
if let Ok(mut surface_data) = surface.data() {
for y in 0..h {
for x in 0..w {
let pixel = img.get_pixel(x, y);
let idx = ((y * w + x) * 4) as usize;
surface_data[idx] = pixel[2];
surface_data[idx + 1] = pixel[1];
surface_data[idx + 2] = pixel[0];
surface_data[idx + 3] = pixel[3];
}
}
} else {
log::error!("Failed to access album art surface data");
}
self.media_art_surface = Some(surface);
} else {
log::error!("Failed to create album art surface");
}
self.media_art_surface = Some(surface);
}
}
}
let has_art = self.config.show_album_art && self.media_art_surface.is_some();
let text_x = if has_art {
center_x - spacing / 2.0
} else {
center_x
};
let art_x = center_x - spacing - art_size / 2.0;
if has_art {
if let Some(ref art) = self.media_art_surface {
self.context.save().unwrap();
let scale = art_size / art.width() as f64;
self.context.translate(art_x, start_y);
self.context.scale(scale, scale);
self.context.set_source_surface(art, 0.0, 0.0).unwrap();
self.context.paint_with_alpha(self.fade_alpha).unwrap();
self.context.restore().unwrap();
}
}
self.context.new_path();
self.context
@@ -67,65 +51,66 @@ impl Renderer {
title.clone()
};
let te = self.context.text_extents(&display_text).unwrap();
self.context
.move_to(text_x - te.width() / 2.0, start_y + 20.0);
self.context.show_text(&display_text).unwrap();
let te = render_try!(self.context.text_extents(&display_text));
if self.system_status.media_playing {
if let Some(ref icon) = self.media_pause_icon_surface {
let pause_y = start_y + 40.0;
let rx = center_x - icon.width() as f64 / 2.0;
let ry = pause_y - icon.height() as f64 / 2.0;
self.draw_icon_at(rx, ry, icon);
self.media_rects.push((
"play_pause",
rx,
ry,
icon.width() as f64,
icon.height() as f64,
));
// Center art + text as a group with 16px gap between them
let art_text_gap = 16.0;
let group_width = te.width() + art_size + art_text_gap;
let group_start_x = center_x - group_width / 2.0;
let art_x = group_start_x;
let text_center_x = art_x + art_size + art_text_gap + te.width() / 2.0;
if has_art {
if let Some(ref art) = self.media_art_surface {
render_try!(self.context.save());
let scale = art_size / art.width() as f64;
self.context.translate(art_x, start_y);
self.context.scale(scale, scale);
render_try!(self.context.set_source_surface(art, 0.0, 0.0));
render_try!(self.context.paint_with_alpha(self.fade_alpha));
render_try!(self.context.restore());
}
} else if let Some(ref icon) = self.media_play_icon_surface {
let play_y = start_y + 40.0;
let rx = center_x - icon.width() as f64 / 2.0;
let ry = play_y - icon.height() as f64 / 2.0;
self.draw_icon_at(rx, ry, icon);
self.media_rects.push((
"play_pause",
rx,
ry,
icon.width() as f64,
icon.height() as f64,
));
}
let controls_y = start_y + 65.0;
let icon_size = 20.0;
let icon_spacing = 40.0;
self.context.move_to(text_center_x - te.width() / 2.0, start_y + 20.0);
render_try!(self.context.show_text(&display_text));
// All media buttons on one row, evenly spaced.
// Each gets a 24×24 hit area, matching draw_icon_at's target_size.
let btn_size = 24.0;
let btn_gap = 48.0;
let btn_y = start_y + 50.0;
// Layout: prev | play_pause | next (centered as a group)
let total_buttons: f64 =
(self.media_prev_icon_surface.is_some() as u32
+ 1
+ self.media_next_icon_surface.is_some() as u32) as f64;
let group_width = (total_buttons - 1.0) * btn_gap + btn_size;
let group_start_x = center_x - group_width / 2.0;
let mut btn_x = group_start_x;
if let Some(ref icon) = self.media_prev_icon_surface {
let rx = center_x - icon_spacing - icon_size / 2.0;
let ry = controls_y - icon_size / 2.0;
self.draw_icon_at(rx, ry, icon);
self.media_rects
.push(("prev", rx, ry, icon_size, icon_size));
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("prev", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
btn_x += btn_gap;
}
if let Some(ref icon) = self.media_stop_icon_surface {
let rx = center_x - icon_size / 2.0;
let ry = controls_y - icon_size / 2.0;
self.draw_icon_at(rx, ry, icon);
self.media_rects
.push(("stop", rx, ry, icon_size, icon_size));
// Play/pause — always present (at least one of play/pause icon should load)
if self.system_status.media_playing {
if let Some(ref icon) = self.media_pause_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
}
} else if let Some(ref icon) = self.media_play_icon_surface {
self.draw_icon_at(btn_x, btn_y - btn_size / 2.0, icon);
self.media_rects.push(("play_pause", btn_x, btn_y - btn_size / 2.0, btn_size, btn_size));
}
btn_x += btn_gap;
if let Some(ref icon) = self.media_next_icon_surface {
let rx = center_x + icon_spacing - icon_size / 2.0;
let ry = controls_y - icon_size / 2.0;
self.draw_icon_at(rx, ry, icon);
self.media_rects
.push(("next", rx, ry, icon_size, icon_size));
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));
}
}
}
+27
View File
@@ -4,9 +4,24 @@ use std::time::Instant;
use crate::config::Config;
use crate::system::SystemStatus;
/// Log cairo errors and return early instead of propagating panics.
/// Defined once here and available to all render submodules.
macro_rules! render_try {
($expr:expr) => {
match $expr {
Ok(v) => v,
Err(e) => {
log::error!("cairo error: {:?}", e);
return;
}
}
};
}
mod feedback;
mod indicator;
mod media_bar;
mod ring_shape;
mod status_bar;
pub struct Renderer {
@@ -19,9 +34,11 @@ pub struct Renderer {
pub(crate) wrong_password_shown: bool,
pub(crate) key_highlight_shown: bool,
pub(crate) cleared_feedback_shown: bool,
pub(crate) verifying_shown: bool,
pub(crate) wrong_password_start: Option<Instant>,
pub(crate) key_highlight_start: Option<Instant>,
pub(crate) cleared_feedback_start: Option<Instant>,
pub(crate) verifying_start: Option<Instant>,
pub(crate) key_highlight_angle: f64,
pub(crate) background: Option<ImageSurface>,
pub(crate) password_display: String,
@@ -60,9 +77,11 @@ impl Renderer {
wrong_password_shown: false,
key_highlight_shown: false,
cleared_feedback_shown: false,
verifying_shown: false,
wrong_password_start: None,
key_highlight_start: None,
cleared_feedback_start: None,
verifying_start: None,
key_highlight_angle: 0.0,
background: None,
password_display: String::new(),
@@ -110,6 +129,10 @@ impl Renderer {
self.password_display = ".".repeat(length);
}
pub fn peek_password(&mut self, password: &str) {
self.password_display = password.to_string();
}
pub fn set_cursor_position(&mut self, position: usize) {
self.cursor_position = position;
}
@@ -193,6 +216,10 @@ impl Renderer {
self.draw_caps_lock_indicator();
}
if self.verifying_shown {
self.draw_verifying_feedback();
}
if self.wrong_password_shown {
self.draw_wrong_password_feedback();
}
+230
View File
@@ -0,0 +1,230 @@
use cairo::Context;
use crate::config::RingShape;
/// Number of linear segments used to approximate curved portions of a shape.
/// Higher = smoother, lower = faster.
const SEGMENTS: usize = 120;
/// Return the (x, y) point on the shape's perimeter at normalized position `t` ∈ [0, 1].
/// `t = 0` is a reference point (rightmost for most shapes); `t` increases clockwise.
/// `r` is the shape's characteristic radius (distance from center to side/vertex).
pub(crate) fn perimeter_point(
cx: f64,
cy: f64,
r: f64,
shape: RingShape,
t: f64,
) -> (f64, f64) {
// Normalize t to [0, 1). Rust's % preserves sign, and the shape-specific
// functions use floor/truncation that break on negative values.
let t = t - t.floor();
match shape {
RingShape::Circle => {
let angle = t * 2.0 * std::f64::consts::PI;
(cx + r * angle.cos(), cy + r * angle.sin())
}
RingShape::Square => square_perimeter_point(cx, cy, r, t),
RingShape::Diamond => diamond_perimeter_point(cx, cy, r, t),
RingShape::Hexagon => hexagon_perimeter_point(cx, cy, r, t),
RingShape::Pill => pill_perimeter_point(cx, cy, r, t),
}
}
/// Right-top-right-bottom-left-bottom-left-top order (clockwise from right).
fn square_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 4.0).floor() as u32;
let local = t * 4.0 - side as f64;
match side {
0 => {
// Right side: top → bottom
(cx + r, cy - r + local * 2.0 * r)
}
1 => {
// Bottom side: right → left
(cx + r - local * 2.0 * r, cy + r)
}
2 => {
// Left side: bottom → top
(cx - r, cy + r - local * 2.0 * r)
}
_ => {
// Top side: left → right
(cx - r + local * 2.0 * r, cy - r)
}
}
}
/// Right-bottom-left-top order (clockwise from right).
fn diamond_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 4.0).floor() as u32;
let local = t * 4.0 - side as f64;
match side {
0 => {
// Right to bottom
(cx + r - local * r, cy + local * r)
}
1 => {
// Bottom to left
(cx - local * r, cy + r - local * r)
}
2 => {
// Left to top
(cx - r + local * r, cy - local * r)
}
_ => {
// Top to right
(cx + local * r, cy - r + local * r)
}
}
}
/// 0: right → bottom-right (vertex to vertex)
/// 1: bottom edge (right → left)
/// 2: bottom-left → left
/// 3: left → top-left
/// 4: top edge (left → right)
/// 5: top-right → right
fn hexagon_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let side = (t * 6.0).floor() as u32;
let local = t * 6.0 - side as f64;
// Shared helper for edges between two vertices
let vert = |angle_rad: f64| -> (f64, f64) {
(cx + r * angle_rad.cos(), cy + r * angle_rad.sin())
};
// Vertices clockwise from right (angle = 0)
let v = [
vert(0.0), // V0: right
vert(std::f64::consts::PI * (1.0 / 3.0)), // V1: bottom-right
vert(std::f64::consts::PI * (2.0 / 3.0)), // V2: bottom-left
vert(std::f64::consts::PI), // V3: left
vert(std::f64::consts::PI * (4.0 / 3.0)), // V4: top-left
vert(std::f64::consts::PI * (5.0 / 3.0)), // V5: top-right
];
let (x0, y0) = v[side as usize];
let (x1, y1) = v[((side + 1) % 6) as usize];
(x0 + local * (x1 - x0), y0 + local * (y1 - y0))
}
/// Clockwise from top-right corner: right cap (downward) → bottom straight
/// (leftward) → left cap (upward) → top straight (rightward).
///
/// The pill is a stadium / capsule: cap radius = r, straight-section length = 2r,
/// total width = 4r, total height = 2r.
fn pill_perimeter_point(cx: f64, cy: f64, r: f64, t: f64) -> (f64, f64) {
let t = t % 1.0;
let total_p = 4.0 + 2.0 * std::f64::consts::PI; // 4r + 2πr, normalised by r
let straights = 2.0 / total_p; // each straight segment's t fraction
let caps = std::f64::consts::PI / total_p; // each cap's t fraction
if t < caps {
// Right cap: semicircle, top → bottom, centred at (r, 0)
let local = t / caps;
let angle = -std::f64::consts::PI / 2.0 + local * std::f64::consts::PI;
(cx + r + r * angle.cos(), cy + r * angle.sin())
} else if t < caps + straights {
// Bottom straight: right → left
let local = (t - caps) / straights;
(cx + r - local * 2.0 * r, cy + r)
} else if t < caps + straights + caps {
// Left cap: semicircle, bottom → top, centred at (-r, 0)
let local = (t - caps - straights) / caps;
let angle = std::f64::consts::PI / 2.0 + local * std::f64::consts::PI;
(cx - r + r * angle.cos(), cy + r * angle.sin())
} else {
// Top straight: left → right
let local = (t - 2.0 * caps - straights) / straights;
(cx - r + local * 2.0 * r, cy - r)
}
}
/// Build the full closed path of the shape outline (at radius `r`).
/// Call `stroke()` after this to draw the ring.
pub(crate) fn build_ring_path(ctx: &Context, cx: f64, cy: f64, r: f64, shape: RingShape) {
match shape {
RingShape::Circle => {
ctx.arc(cx, cy, r, 0.0, 2.0 * std::f64::consts::PI);
}
RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => {
let (x0, y0) = perimeter_point(cx, cy, r, shape, 0.0);
ctx.move_to(x0, y0);
// Subdivide perimeter into enough segments for smooth rendering
let n = 80;
for i in 1..=n {
let pt = i as f64 / n as f64;
let (x, y) = perimeter_point(cx, cy, r, shape, pt);
ctx.line_to(x, y);
}
ctx.close_path();
}
}
}
/// Build a partial path along the shape perimeter from normalized position
/// `t_start` to `t_end`. Call `stroke()` after this to draw a sector.
pub(crate) fn build_sector_path(
ctx: &Context,
cx: f64,
cy: f64,
r: f64,
shape: RingShape,
t_start: f64,
t_end: f64,
) {
match shape {
RingShape::Circle => {
let a_start = t_start * 2.0 * std::f64::consts::PI;
let a_end = t_end * 2.0 * std::f64::consts::PI;
ctx.arc(cx, cy, r, a_start, a_end);
}
RingShape::Square | RingShape::Diamond | RingShape::Hexagon | RingShape::Pill => {
let (x0, y0) = perimeter_point(cx, cy, r, shape, t_start);
ctx.move_to(x0, y0);
for i in 1..=SEGMENTS {
let t = t_start + (t_end - t_start) * (i as f64 / SEGMENTS as f64);
let (x, y) = perimeter_point(cx, cy, r, shape, t);
ctx.line_to(x, y);
}
}
}
}
/// Build the filled interior path (inset from outer ring by `thickness / 2`).
/// Call `fill()` after this.
pub(crate) fn build_fill_path(
ctx: &Context,
cx: f64,
cy: f64,
radius: f64,
thickness: f64,
shape: RingShape,
) {
let inner_r = (radius - thickness / 2.0).max(0.0);
if inner_r <= 0.0 {
return;
}
build_ring_path(ctx, cx, cy, inner_r, shape);
}
/// Return the normalized `t` offset that places the first password dot at the
/// visual top-centre of the shape. May be negative; callers should NOT wrap.
pub(crate) fn top_centre_offset(shape: RingShape) -> f64 {
match shape {
// Circle/Diamond: top at t=0.75 → offset -(1-0.75) = -0.25
RingShape::Circle | RingShape::Diamond => -0.25,
// Square: top edge centre at t=0.875 → offset -(1-0.875) = -0.125
RingShape::Square => -0.125,
// Hexagon: top edge centre at t=0.75 → offset -0.25
RingShape::Hexagon => -0.25,
// Pill: top straight centre at t = 1 - 1/(4+2π) ≈ 0.9027
RingShape::Pill => -1.0 / (4.0 + 2.0 * std::f64::consts::PI),
}
}
+32 -26
View File
@@ -3,7 +3,7 @@ use cairo::{Format, ImageSurface};
impl Renderer {
pub(crate) fn load_icons(&mut self) {
log::info!("Attempting to load status icons...");
log::debug!("Attempting to load status icons...");
let wifi_names = [
"network-wireless-signal-excellent-symbolic",
"network-wireless-signal-excellent",
@@ -25,7 +25,7 @@ impl Renderer {
.unwrap_or_default();
if !wifi_path.is_empty() {
log::info!("Resolved WiFi icon path: {}", wifi_path);
log::debug!("Resolved WiFi icon path: {}", wifi_path);
self.wifi_icon_surface = self.load_icon(&wifi_path);
}
@@ -50,7 +50,7 @@ impl Renderer {
.unwrap_or_default();
if !bt_path.is_empty() {
log::info!("Resolved Bluetooth icon path: {}", bt_path);
log::debug!("Resolved Bluetooth icon path: {}", bt_path);
self.bluetooth_icon_surface = self.load_icon(&bt_path);
}
@@ -77,7 +77,7 @@ impl Renderer {
.unwrap_or_default();
if !batt_path.is_empty() {
log::info!("Resolved Battery icon path: {}", batt_path);
log::debug!("Resolved Battery icon path: {}", batt_path);
self.battery_icon_surface = self.load_icon(&batt_path);
}
@@ -353,27 +353,27 @@ impl Renderer {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(48.0);
let te = self.context.text_extents(&time_str).unwrap();
let te = render_try!(self.context.text_extents(&time_str));
self.context
.move_to(center_x - te.width() / 2.0, center_y + te.height() / 4.0);
self.context.show_text(&time_str).unwrap();
render_try!(self.context.show_text(&time_str));
self.context.new_path();
self.context.set_font_size(14.0);
let de = self.context.text_extents(&date_str).unwrap();
let de = render_try!(self.context.text_extents(&date_str));
self.context.move_to(
center_x - de.width() / 2.0,
center_y + te.height() / 4.0 + 25.0,
);
self.context.show_text(&date_str).unwrap();
render_try!(self.context.show_text(&date_str));
self.context.new_path();
let ue = self.context.text_extents(&self.uptime_cache).unwrap();
let ue = render_try!(self.context.text_extents(&self.uptime_cache));
self.context.move_to(
center_x - ue.width() / 2.0,
center_y + te.height() / 4.0 + 43.0,
);
self.context.show_text(&self.uptime_cache).unwrap();
render_try!(self.context.show_text(&self.uptime_cache));
}
pub(crate) fn draw_network(&self) {
@@ -392,13 +392,13 @@ impl Renderer {
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(text_x, y);
self.context.show_text(ssid).unwrap();
render_try!(self.context.show_text(ssid));
} else {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(x, y);
self.context.show_text(ssid).unwrap();
render_try!(self.context.show_text(ssid));
}
}
}
@@ -418,7 +418,7 @@ impl Renderer {
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(text_x, y);
self.context.show_text(&battery_text).unwrap();
render_try!(self.context.show_text(&battery_text));
} else {
self.draw_battery_icon_at(
x,
@@ -433,7 +433,7 @@ impl Renderer {
self.context.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha);
self.context.set_font_size(16.0);
self.context.move_to(x + icon_width + 10.0, y);
self.context.show_text(&battery_text).unwrap();
render_try!(self.context.show_text(&battery_text));
}
}
}
@@ -462,7 +462,7 @@ impl Renderer {
.set_source_rgba(1.0, 1.0, 1.0, self.fade_alpha * alpha_mult);
self.context.set_font_size(14.0);
self.context.move_to(text_x, y);
self.context.show_text(&status_text).unwrap();
render_try!(self.context.show_text(&status_text));
}
}
@@ -478,7 +478,7 @@ impl Renderer {
self.context.set_font_size(16.0);
let text = format!("Layout: {}", layout);
self.context.move_to(x, y);
self.context.show_text(&text).unwrap();
render_try!(self.context.show_text(&text));
}
}
}
@@ -490,8 +490,12 @@ impl Renderer {
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
self.context.translate(x, y);
self.context.scale(scale, scale);
self.context.set_source_surface(surface, 0.0, 0.0).unwrap();
self.context.paint_with_alpha(self.fade_alpha).unwrap();
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
log::error!("cairo error: {:?}", e);
}
if let Err(e) = self.context.paint_with_alpha(self.fade_alpha) {
log::error!("cairo error: {:?}", e);
}
self.context.restore().unwrap();
}
@@ -508,10 +512,12 @@ impl Renderer {
(target_size / surface.width() as f64).min(target_size / surface.height() as f64);
self.context.translate(x, y);
self.context.scale(scale, scale);
self.context.set_source_surface(surface, 0.0, 0.0).unwrap();
self.context
.paint_with_alpha(self.fade_alpha * alpha)
.unwrap();
if let Err(e) = self.context.set_source_surface(surface, 0.0, 0.0) {
log::error!("cairo error: {:?}", e);
}
if let Err(e) = self.context.paint_with_alpha(self.fade_alpha * alpha) {
log::error!("cairo error: {:?}", e);
}
self.context.restore().unwrap();
}
@@ -529,11 +535,11 @@ impl Renderer {
self.context.set_source_rgba(1.0, 1.0, 1.0, alpha * 0.5);
self.context.set_line_width(2.0);
self.context.rectangle(x, y, width, height);
self.context.stroke().unwrap();
render_try!(self.context.stroke());
self.context.new_path();
self.context
.rectangle(x + width, y + height / 4.0, 3.0, height / 2.0);
self.context.fill().unwrap();
render_try!(self.context.fill());
let fill_width = (width - 4.0) * (percent / 100.0);
self.context.new_path();
if percent < 20.0 {
@@ -543,7 +549,7 @@ impl Renderer {
}
self.context
.rectangle(x + 2.0, y + 2.0, fill_width, height - 4.0);
self.context.fill().unwrap();
render_try!(self.context.fill());
if charging {
self.context.new_path();
self.context.set_source_rgba(1.0, 1.0, 0.0, alpha);
@@ -556,7 +562,7 @@ impl Renderer {
self.context.line_to(bx - 1.0, by - 3.0);
self.context.line_to(bx + 1.0, by - 3.0);
self.context.close_path();
self.context.fill().unwrap();
render_try!(self.context.fill());
}
}
}
+58 -34
View File
@@ -4,7 +4,7 @@
use anyhow::{Context, Result};
use cairo::ImageSurface;
use log::debug;
use log::warn;
use smithay_client_toolkit::shm::{slot::Buffer, slot::SlotPool};
use std::sync::Mutex;
use wayland_client::globals::GlobalList;
@@ -39,22 +39,22 @@ impl Screenshot {
self.apply_blur(radius, times)?;
}
if let Some((base, factor)) = config.effect_vignette {
self.apply_vignette(base, factor);
self.apply_vignette(base, factor)?;
}
if let Some(pixel_size) = config.effect_pixelate {
self.apply_pixelate(pixel_size);
self.apply_pixelate(pixel_size)?;
}
if let Some(angle) = config.effect_swirl {
self.apply_swirl(angle);
self.apply_swirl(angle)?;
}
if let Some(factor) = config.effect_melting {
self.apply_melting(factor);
self.apply_melting(factor)?;
}
Ok(())
}
/// Apply a swirl effect.
pub fn apply_swirl(&mut self, angle: f32) {
pub fn apply_swirl(&mut self, angle: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
@@ -65,7 +65,7 @@ impl Screenshot {
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
.context("swirl: failed to read surface data")?;
let original = data.clone();
for y in 0..height {
@@ -92,12 +92,13 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().unwrap();
let mut surface_data = self.surface.data().context("swirl: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a melting effect (vertical smear).
pub fn apply_melting(&mut self, factor: f32) {
pub fn apply_melting(&mut self, factor: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
@@ -105,7 +106,7 @@ impl Screenshot {
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
.context("melting: failed to read surface data")?;
use rand::RngExt;
let mut rng = rand::rng();
@@ -130,14 +131,15 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().unwrap();
let mut surface_data = self.surface.data().context("melting: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Pixelate the surface.
pub fn apply_pixelate(&mut self, pixel_size: u32) {
pub fn apply_pixelate(&mut self, pixel_size: u32) -> Result<()> {
if pixel_size <= 1 {
return;
return Ok(());
}
let width = self.surface.width();
@@ -146,7 +148,7 @@ impl Screenshot {
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
.context("pixelate: failed to read surface data")?;
for y in (0..height).step_by(pixel_size as usize) {
for x in (0..width).step_by(pixel_size as usize) {
@@ -192,8 +194,9 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().unwrap();
let mut surface_data = self.surface.data().context("pixelate: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
/// Apply a Gaussian blur effect.
@@ -202,31 +205,42 @@ impl Screenshot {
return Ok(());
}
let width = self.surface.width();
let height = self.surface.height();
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 as usize];
let mut data = vec![0u8; stride * height];
self.surface
.with_data(|src| data.copy_from_slice(src))
.context("Failed to get surface data")?;
.context("blur: failed to read surface data")?;
// Convert from stride-padded surface data to tight RgbaImage.
// Cairo stride may be larger than width*4 for alignment, so copy
// row by row to strip the padding.
let tight_stride = width * 4;
let mut tight = vec![0u8; tight_stride * height];
for y in 0..height {
let src_off = y * stride;
let dst_off = y * tight_stride;
tight[dst_off..dst_off + tight_stride]
.copy_from_slice(&data[src_off..src_off + tight_stride]);
}
// Convert to image::RgbaImage for processing
let mut img: image::ImageBuffer<image::Rgba<u8>, Vec<u8>> =
image::ImageBuffer::from_raw(width as u32, height as u32, data)
.context("Failed to create image buffer")?;
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 as usize) * (height as usize));
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 as usize,
height as usize,
width,
height,
radius as f32,
);
@@ -237,15 +251,20 @@ impl Screenshot {
}
}
// Copy back to surface
// Copy back from tight buffer into stride-padded surface data
let new_data = img.into_raw();
let mut surface_data = self.surface.data()?;
surface_data.copy_from_slice(&new_data);
for y in 0..height {
let src_off = y * tight_stride;
let dst_off = y * stride;
surface_data[dst_off..dst_off + tight_stride]
.copy_from_slice(&new_data[src_off..src_off + tight_stride]);
}
Ok(())
}
/// Apply a vignette effect (darken edges).
pub fn apply_vignette(&mut self, base: f32, factor: f32) {
pub fn apply_vignette(&mut self, base: f32, factor: f32) -> Result<()> {
let width = self.surface.width();
let height = self.surface.height();
let center_x = width as f32 / 2.0;
@@ -256,7 +275,7 @@ impl Screenshot {
let mut data = vec![0u8; stride * height as usize];
self.surface
.with_data(|src| data.copy_from_slice(src))
.unwrap();
.context("vignette: failed to read surface data")?;
for y in 0..height {
for x in 0..width {
@@ -265,7 +284,7 @@ impl Screenshot {
let distance = (dx * dx + dy * dy).sqrt();
let vignette_factor = base + (1.0 - base) * (distance / max_distance).powf(factor);
let index = ((y * width + x) * 4) as usize;
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;
@@ -273,8 +292,9 @@ impl Screenshot {
}
}
let mut surface_data = self.surface.data().unwrap();
let mut surface_data = self.surface.data().context("vignette: failed to write surface data")?;
surface_data.copy_from_slice(&data);
Ok(())
}
}
@@ -312,7 +332,7 @@ impl ScreenshotManager {
.ok();
if manager.is_none() {
debug!("zwlr_screencopy_manager_v1 not available");
warn!("zwlr_screencopy_manager_v1 not available — backgrounds will not be captured");
}
Ok(Self { manager })
@@ -361,11 +381,15 @@ impl ScreenshotManager {
let raw_data = {
let mut data = vec![0u8; (info.width * info.height * 4) as usize];
let canvas_end = canvas.len();
for row in 0..height {
let src_offset = row * stride;
let dst_offset = row * pixel_width;
data[dst_offset..dst_offset + pixel_width]
.copy_from_slice(&canvas[src_offset..src_offset + pixel_width]);
let copy_end = (src_offset + pixel_width).min(canvas_end);
if copy_end > src_offset {
data[dst_offset..dst_offset + pixel_width]
.copy_from_slice(&canvas[src_offset..copy_end]);
}
}
data
};
+79 -48
View File
@@ -21,22 +21,30 @@ pub struct SystemStatus {
}
#[derive(Debug, Clone, Copy)]
pub enum SystemCommand {
pub enum BackendCommand {
PowerOff,
Reboot,
Suspend,
MediaPlayPause,
MediaStop,
MediaNext,
MediaPrev,
}
pub struct SystemManager {
status: Arc<Mutex<SystemStatus>>,
cmd_tx: mpsc::UnboundedSender<SystemCommand>,
cmd_tx: mpsc::UnboundedSender<BackendCommand>,
}
impl SystemManager {
pub fn new() -> Self {
pub fn new(config: &crate::config::Config) -> Self {
let poll_interval = tokio::time::Duration::from_secs(config.system_poll_interval);
let reconnect_delay = tokio::time::Duration::from_secs(config.dbus_reconnect_delay);
let command_timeout = tokio::time::Duration::from_secs(config.command_timeout);
let status = Arc::new(Mutex::new(SystemStatus::default()));
let s_clone = status.clone();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<SystemCommand>();
let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<BackendCommand>();
std::thread::spawn(move || {
let rt = match tokio::runtime::Runtime::new() {
@@ -49,7 +57,7 @@ impl SystemManager {
rt.block_on(async {
let mut conn: Option<Connection> = None;
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(2));
let mut interval = tokio::time::interval(poll_interval);
let mut last_art_url: Option<String> = None;
let mut last_art_data: Option<Arc<Vec<u8>>> = None;
@@ -59,7 +67,9 @@ impl SystemManager {
Ok(c) => conn = Some(c),
Err(e) => {
error!("Failed to connect to system DBus: {}", e);
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
tokio::time::sleep(reconnect_delay).await;
interval = tokio::time::interval(poll_interval);
continue;
}
}
}
@@ -244,26 +254,60 @@ impl SystemManager {
}
}
}
Some(command) = cmd_rx.recv() => {
if let Some(ref c) = conn {
let method = match command {
SystemCommand::PowerOff => "PowerOff",
SystemCommand::Reboot => "Reboot",
SystemCommand::Suspend => "Suspend",
};
debug!("Executing system command: {}", method);
let result = tokio::time::timeout(
tokio::time::Duration::from_secs(5),
c.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
method,
&(true),
)
).await;
if result.is_err() {
error!("System command {} timed out", method);
Some(cmd) = cmd_rx.recv() => {
match cmd {
BackendCommand::PowerOff
| BackendCommand::Reboot
| BackendCommand::Suspend => {
if let Some(ref c) = conn {
// Safety: only PowerOff/Reboot/Suspend reach this
// branch due to the outer match arm.
let method = match cmd {
BackendCommand::PowerOff => "PowerOff",
BackendCommand::Reboot => "Reboot",
BackendCommand::Suspend => "Suspend",
BackendCommand::MediaPlayPause
| BackendCommand::MediaStop
| BackendCommand::MediaNext
| BackendCommand::MediaPrev => {
unreachable!("media command in power branch: {:?}", cmd)
}
};
debug!("Executing system command: {}", method);
let result = tokio::time::timeout(
command_timeout,
c.call_method(
Some("org.freedesktop.login1"),
"/org/freedesktop/login1",
Some("org.freedesktop.login1.Manager"),
method,
&(true),
)
).await;
if result.is_err() {
error!("System command {} timed out", method);
}
}
}
BackendCommand::MediaPlayPause
| BackendCommand::MediaStop
| BackendCommand::MediaNext
| BackendCommand::MediaPrev => {
let action = cmd;
// Fire-and-forget: don't block the polling loop on MPRIS.
tokio::task::spawn_blocking(move || {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
match action {
BackendCommand::MediaPlayPause => { let _ = player.play_pause(); }
BackendCommand::MediaStop => { let _ = player.stop(); }
BackendCommand::MediaNext => { let _ = player.next(); }
BackendCommand::MediaPrev => { let _ = player.previous(); }
_ => {}
}
}
}
});
}
}
}
@@ -276,42 +320,29 @@ impl SystemManager {
}
pub fn get_status(&self) -> SystemStatus {
self.status.lock().unwrap().clone()
self.status
.lock()
.map(|s| s.clone())
.unwrap_or_default()
}
pub fn send_command(&self, cmd: SystemCommand) {
pub fn send_command(&self, cmd: BackendCommand) {
let _ = self.cmd_tx.send(cmd);
}
pub fn media_play_pause(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.play_pause();
}
}
let _ = self.cmd_tx.send(BackendCommand::MediaPlayPause);
}
pub fn media_stop(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.stop();
}
}
let _ = self.cmd_tx.send(BackendCommand::MediaStop);
}
pub fn media_next(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.next();
}
}
let _ = self.cmd_tx.send(BackendCommand::MediaNext);
}
pub fn media_prev(&self) {
if let Ok(finder) = PlayerFinder::new() {
if let Ok(player) = finder.find_active() {
let _ = player.previous();
}
}
let _ = self.cmd_tx.send(BackendCommand::MediaPrev);
}
}