diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 024d3d9..9cd45e0 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -14,6 +14,14 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Cache Cargo registry and build + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + target + key: cargo-${{ hashFiles('Cargo.lock') }} + - name: Check formatting shell: bash run: cargo fmt --check diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml index 1fc9a63..d444a37 100644 --- a/.gitea/workflows/release.yaml +++ b/.gitea/workflows/release.yaml @@ -3,7 +3,7 @@ name: release "on": push: tags: - - "v*.*.*" + - "v[0-9]*.[0-9]*.[0-9]*" workflow_dispatch: permissions: @@ -69,7 +69,7 @@ jobs: release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" existing="$(curl -fsS \ "${release_url}/tags/${tag}" \ - -H "Authorization: token ${GITEA_TOKEN}" || true)" + -H "Authorization: token ${GITEA_TOKEN}")" || existing='{}' release_id="$(printf '%s' "$existing" | jq -r '.id // empty')" if [ -z "$release_id" ]; then @@ -112,14 +112,15 @@ jobs: -X DELETE "${release_url}/${release_id}/assets/${asset_id}" \ -H "Authorization: token ${GITEA_TOKEN}" fi + encoded="$(printf '%s' "$name" | jq -sRr @uri)" curl -fsS \ - -X POST "${release_url}/${release_id}/assets?name=${name}" \ + -X POST "${release_url}/${release_id}/assets?name=${encoded}" \ -H "Authorization: token ${GITEA_TOKEN}" \ -F "attachment=@${asset}" done - name: Upload artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: gosleep-timer-linux-amd64 path: dist/* diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c93979..0ae9bb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ All notable changes to this project are documented here. The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`. +## [1.3.2] - 2026-08-07 + +### Changed + +- `mute_input` action now always mutes the microphone instead of toggling its current state. + +## [1.3.1] - 2026-07-12 + +### Added + +- Added `mute_input` action to toggle microphone mute via `wpctl`/`pactl`. +- Added `inhibit_sleep` config option to run `systemd-inhibit` during the timer. +- Added `monitor` action with backend selection (hyprland, niri, sway, kde, gnome, x11) to turn off displays. +- Added `notify` action to send desktop notification on timer finish. +- Added `suspend`, `hibernate`, `hybrid-sleep` power modes. +- Added workspace backend support for `sway` and `i3`. +- Added custom `workspace command` override. + +### Changed + +- Lock action refactored with backend selection: `loginctl`, `hyprlock`, `swaylock`, `i3lock`, `gnome`, `kde`, `xscreensaver`. +- Editor command now correctly splits `VISUAL`/`EDITOR` into program and arguments. +- Timer action loop checks the stop flag between actions. + +### Fixed + +- History now records `partial` status when some actions fail instead of bailing entirely. + ## [1.2.2] - 2026-07-05 ### Changed @@ -55,6 +83,8 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH` - Added YAML configuration, CLI `init`, `preview`, and `run` commands. - Added Gitea CI and tag-based release workflows. +[1.3.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.3.2 +[1.3.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.3.1 [1.2.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.2 [1.2.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.1 [1.2.0]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.0 diff --git a/Cargo.lock b/Cargo.lock index d19dc58..1adad95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -537,7 +537,7 @@ dependencies = [ [[package]] name = "gosleep-timer" -version = "1.2.2" +version = "1.3.2" dependencies = [ "anyhow", "clap", @@ -807,9 +807,9 @@ dependencies = [ [[package]] name = "noyalib" -version = "0.0.12" +version = "0.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "903f06e76b14b388b47d651b9498b008888a9475084087e51c294731acbf16c1" +checksum = "2095d0b3b6017ebbdaffedf83ffb2bff7e468ab379ac391239abea4880833290" dependencies = [ "indexmap", "itoa", diff --git a/Cargo.toml b/Cargo.toml index 6a9401d..683d221 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,13 @@ [package] name = "gosleep-timer" -version = "1.2.2" +version = "1.3.2" +authors = ["forust"] edition = "2024" rust-version = "1.88" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" license = "MIT" repository = "https://gitea.forust.xyz/forust/gosleep" +homepage = "https://gitea.forust.xyz/forust/gosleep" readme = "README.md" keywords = ["timer", "tui", "linux", "desktop", "sleep"] categories = ["command-line-utilities"] @@ -20,7 +22,7 @@ clap = { version = "4.5", features = ["derive"] } crossterm = "0.29" ratatui = "0.30.2" serde = { version = "1.0", features = ["derive"] } -noyalib = "0.0.12" +noyalib = "0.0.13" [profile.release] codegen-units = 1 diff --git a/README.md b/README.md index 29a8414..50639d7 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Rust TUI sleep timer for Linux desktop actions. - Single Rust binary, independent of Python. - Ratatui/Crossterm terminal UI. - YAML config at `~/.config/gosleep-timer/config.yaml` or `$XDG_CONFIG_HOME/gosleep-timer/config.yaml`. -- CLI commands for initialization, preview, and direct timer runs. +- CLI commands for init, status, validate, edit, preview, run, history, and stats. - Time-left display and progress bar. - Wrapped command preview for smaller terminals. - Linux desktop action support for niri, Hyprland, KDE, playerctl, brightnessctl/light, PipeWire/PulseAudio, systemd, and custom shell commands. @@ -145,6 +145,8 @@ actions: value: 30 mute: enabled: false + mute_input: + enabled: false lock: enabled: false command: loginctl lock-session @@ -224,7 +226,8 @@ Release tags must match the package version: ```bash git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH" -git push origin main --tags +git push origin main +git push origin vMAJOR.MINOR.PATCH ``` ## Releases diff --git a/RELEASING.md b/RELEASING.md index 51e47b2..6fc3a12 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -52,7 +52,7 @@ version=1.1.4 Update: - `Cargo.toml`: `package.version` -- `Cargo.lock`: package version, regenerated by Cargo +- `Cargo.lock`: run `cargo build --release` (without `--locked`) to regenerate - `VERSION` - `CHANGELOG.md` diff --git a/VERSION b/VERSION index 23aa839..1892b92 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.2.2 +1.3.2 diff --git a/src/main.rs b/src/main.rs index 88d7633..d154236 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use std::{ }; use anyhow::{Context, Result, bail}; + +const TIMER_STOPPED_MSG: &str = "timer stopped"; use clap::{Parser, Subcommand}; use crossterm::{ event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}, @@ -62,6 +64,7 @@ enum Commands { #[serde(default)] struct Config { duration: String, + inhibit_sleep: bool, actions: Actions, } @@ -72,7 +75,10 @@ struct Actions { media: MediaAction, brightness: BrightnessAction, mute: ToggleAction, + mute_input: ToggleAction, lock: LockAction, + monitor: MonitorAction, + notify: NotifyAction, kill: KillAction, power: PowerAction, custom: CustomAction, @@ -84,6 +90,7 @@ struct WorkspaceAction { enabled: bool, backend: String, number: u32, + command: String, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -110,9 +117,25 @@ struct ToggleAction { #[serde(default)] struct LockAction { enabled: bool, + backend: String, command: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct MonitorAction { + enabled: bool, + backend: String, + command: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +struct NotifyAction { + enabled: bool, + message: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] struct KillAction { @@ -168,10 +191,22 @@ struct TimerOptions { no_actions: bool, } +struct InhibitGuard(Option); + +impl Drop for InhibitGuard { + fn drop(&mut self) { + if let Some(mut child) = self.0.take() { + let _ = child.kill(); + let _ = child.wait(); + } + } +} + impl Default for Config { fn default() -> Self { Self { duration: "25m".to_string(), + inhibit_sleep: false, actions: Actions::default(), } } @@ -183,6 +218,7 @@ impl Default for WorkspaceAction { enabled: true, backend: "auto".to_string(), number: 3, + command: String::new(), } } } @@ -209,7 +245,27 @@ impl Default for LockAction { fn default() -> Self { Self { enabled: false, - command: "loginctl lock-session".to_string(), + backend: "auto".to_string(), + command: String::new(), + } + } +} + +impl Default for MonitorAction { + fn default() -> Self { + Self { + enabled: false, + backend: "auto".to_string(), + command: String::new(), + } + } +} + +impl Default for NotifyAction { + fn default() -> Self { + Self { + enabled: false, + message: "Timer finished".to_string(), } } } @@ -295,10 +351,23 @@ fn main() -> Result<()> { Some(&mut stdout), )?; let slept_seconds = prompt_wake_confirmation(&config, options)?; + let status = if report.failures.is_empty() { + "finished" + } else { + "partial" + }; append_history( &history_path(&path), - report.to_history("finished", slept_seconds), - ) + report.to_history(status, slept_seconds), + )?; + if !report.failures.is_empty() { + bail!( + "{} action(s) failed: {}", + report.failures.len(), + report.failures.join("; ") + ); + } + Ok(()) } Some(Commands::Status) => { print_status(&path, &config); @@ -466,10 +535,12 @@ fn edit_config(path: &Path) -> Result<()> { let editor = std::env::var("VISUAL") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "vi".to_string()); - let status = Command::new(editor) - .arg(path) - .status() - .context("open editor")?; + let mut parts = editor.split_whitespace(); + let program = parts.next().context("empty editor command")?; + let mut cmd = Command::new(program); + cmd.args(parts); + cmd.arg(path); + let status = cmd.status().context("open editor")?; if !status.success() { bail!("editor exited with {status}"); } @@ -513,8 +584,14 @@ fn normalize_config(config: &mut Config) { if config.actions.brightness.value > 100 { config.actions.brightness.value = 100; } - if config.actions.lock.command.is_empty() { - config.actions.lock.command = "loginctl lock-session".to_string(); + if config.actions.lock.backend.is_empty() { + config.actions.lock.backend = "auto".to_string(); + } + if config.actions.monitor.backend.is_empty() { + config.actions.monitor.backend = "auto".to_string(); + } + if config.actions.notify.message.is_empty() { + config.actions.notify.message = "Timer finished".to_string(); } if config.actions.power.mode.is_empty() { config.actions.power.mode = "none".to_string(); @@ -524,13 +601,22 @@ fn normalize_config(config: &mut Config) { fn validate_config(config: &Config) -> Result<()> { parse_duration(&config.duration)?; match config.actions.power.mode.as_str() { - "none" | "poweroff" | "reboot" => {} + "none" | "suspend" | "hibernate" | "hybrid-sleep" | "poweroff" | "reboot" => {} mode => bail!("invalid power mode {mode:?}"), } match config.actions.workspace.backend.as_str() { - "auto" | "niri" | "hyprland" | "kde" => {} + "auto" | "niri" | "hyprland" | "sway" | "i3" | "kde" => {} backend => bail!("invalid workspace backend {backend:?}"), } + match config.actions.monitor.backend.as_str() { + "auto" | "hyprland" | "niri" | "sway" | "kde" | "gnome" | "x11" => {} + backend => bail!("invalid monitor backend {backend:?}"), + } + match config.actions.lock.backend.as_str() { + "auto" | "loginctl" | "hyprlock" | "swaylock" | "i3lock" | "gnome" | "kde" + | "xscreensaver" => {} + backend => bail!("invalid lock backend {backend:?}"), + } match config.actions.media.action.as_str() { "none" | "stop" | "pause" | "play-pause" | "next" | "previous" => {} action => bail!("invalid media action {action:?}"), @@ -612,11 +698,29 @@ fn build_action_commands(config: &Config) -> Vec { ), }); } - if actions.lock.enabled { + if actions.mute_input.enabled { commands.push(ActionCommand { - label: "lock", + label: "mute_input", args: vec![], - shell: Some(actions.lock.command.clone()), + shell: Some( + "wpctl set-mute @DEFAULT_AUDIO_SOURCE@ 0 || pactl set-source-mute @DEFAULT_SOURCE@ 1".to_string(), + ), + }); + } + if actions.lock.enabled { + commands.push(lock_command(&actions.lock)); + } + if actions.monitor.enabled { + commands.push(monitor_command(&actions.monitor)); + } + if actions.notify.enabled { + commands.push(ActionCommand { + label: "notify", + args: vec![], + shell: Some(format!( + "notify-send 'gosleep-timer' '{}'", + actions.notify.message + )), }); } if actions.kill.enabled { @@ -632,6 +736,21 @@ fn build_action_commands(config: &Config) -> Vec { } } match actions.power.mode.as_str() { + "suspend" => commands.push(ActionCommand { + label: "power", + args: vec!["systemctl".to_string(), "suspend".to_string()], + shell: None, + }), + "hibernate" => commands.push(ActionCommand { + label: "power", + args: vec!["systemctl".to_string(), "hibernate".to_string()], + shell: None, + }), + "hybrid-sleep" => commands.push(ActionCommand { + label: "power", + args: vec!["systemctl".to_string(), "hybrid-sleep".to_string()], + shell: None, + }), "poweroff" => commands.push(ActionCommand { label: "power", args: vec!["systemctl".to_string(), "poweroff".to_string()], @@ -660,6 +779,13 @@ fn build_action_commands(config: &Config) -> Vec { } fn workspace_command(action: &WorkspaceAction) -> ActionCommand { + if !action.command.is_empty() { + return ActionCommand { + label: "workspace", + args: vec![], + shell: Some(action.command.clone()), + }; + } let number = action.number.to_string(); match action.backend.as_str() { "niri" => ActionCommand { @@ -678,6 +804,22 @@ fn workspace_command(action: &WorkspaceAction) -> ActionCommand { .collect(), shell: None, }, + "sway" => ActionCommand { + label: "workspace", + args: vec!["swaymsg", "workspace", "number", &number] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "i3" => ActionCommand { + label: "workspace", + args: vec!["i3-msg", "workspace", "number", &number] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, "kde" => ActionCommand { label: "workspace", args: vec![ @@ -696,13 +838,140 @@ fn workspace_command(action: &WorkspaceAction) -> ActionCommand { label: "workspace", args: vec![], shell: Some(format!( - "if command -v niri >/dev/null 2>&1; then niri msg action focus-workspace {0}; elif command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch workspace {0}; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop {0}; fi", + "if command -v niri >/dev/null 2>&1; then niri msg action focus-workspace {0}; elif command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch workspace {0}; elif command -v swaymsg >/dev/null 2>&1; then swaymsg workspace number {0}; elif command -v i3-msg >/dev/null 2>&1; then i3-msg workspace number {0}; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop {0}; fi", number )), }, } } +fn monitor_command(action: &MonitorAction) -> ActionCommand { + if !action.command.is_empty() { + return ActionCommand { + label: "monitor", + args: vec![], + shell: Some(action.command.clone()), + }; + } + match action.backend.as_str() { + "hyprland" => ActionCommand { + label: "monitor", + args: vec!["hyprctl", "dispatch", "dpms", "off"] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "niri" => ActionCommand { + label: "monitor", + args: vec!["niri", "msg", "action", "power-off-monitors"] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "sway" => ActionCommand { + label: "monitor", + args: vec!["swaymsg", "output", "*", "dpms", "off"] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "kde" => ActionCommand { + label: "monitor", + args: vec![ + "qdbus", + "org.kde.ScreenSaver", + "/ScreenSaver", + "org.freedesktop.ScreenSaver.SetActive", + "true", + ] + .into_iter() + .map(str::to_string) + .collect(), + shell: None, + }, + "gnome" => ActionCommand { + label: "monitor", + args: vec![], + shell: Some("gnome-screensaver-command -l".to_string()), + }, + "x11" => ActionCommand { + label: "monitor", + args: vec![], + shell: Some("xset dpms force off".to_string()), + }, + _ => ActionCommand { + label: "monitor", + args: vec![], + shell: Some( + "if command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch dpms off; elif command -v niri >/dev/null 2>&1; then niri msg action power-off-monitors; elif command -v swaymsg >/dev/null 2>&1; then swaymsg output '*' dpms off; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.ScreenSaver /ScreenSaver org.freedesktop.ScreenSaver.SetActive true; elif command -v gnome-screensaver-command >/dev/null 2>&1; then gnome-screensaver-command -l; elif command -v xset >/dev/null 2>&1; then xset dpms force off; fi".to_string() + ), + }, + } +} + +fn lock_command(action: &LockAction) -> ActionCommand { + if !action.command.is_empty() { + return ActionCommand { + label: "lock", + args: vec![], + shell: Some(action.command.clone()), + }; + } + match action.backend.as_str() { + "loginctl" => ActionCommand { + label: "lock", + args: vec!["loginctl".to_string(), "lock-session".to_string()], + shell: None, + }, + "hyprlock" => ActionCommand { + label: "lock", + args: vec![], + shell: Some("hyprctl dispatch exec hyprlock".to_string()), + }, + "swaylock" => ActionCommand { + label: "lock", + args: vec!["swaylock".to_string()], + shell: None, + }, + "i3lock" => ActionCommand { + label: "lock", + args: vec!["i3lock".to_string()], + shell: None, + }, + "gnome" => ActionCommand { + label: "lock", + args: vec!["gnome-screensaver-command".to_string(), "-l".to_string()], + shell: None, + }, + "kde" => ActionCommand { + label: "lock", + args: vec![ + "qdbus".to_string(), + "org.kde.ScreenSaver".to_string(), + "/ScreenSaver".to_string(), + "org.freedesktop.ScreenSaver.SetActive".to_string(), + "true".to_string(), + ], + shell: None, + }, + "xscreensaver" => ActionCommand { + label: "lock", + args: vec!["xscreensaver-command".to_string(), "-lock".to_string()], + shell: None, + }, + _ => ActionCommand { + label: "lock", + args: vec![], + shell: Some( + "if command -v loginctl >/dev/null 2>&1; then loginctl lock-session; elif command -v hyprctl >/dev/null 2>&1; then hyprctl dispatch exec hyprlock; elif command -v swaylock >/dev/null 2>&1; then swaylock; elif command -v i3lock >/dev/null 2>&1; then i3lock; elif command -v gnome-screensaver-command >/dev/null 2>&1; then gnome-screensaver-command -l; elif command -v qdbus >/dev/null 2>&1; then qdbus org.kde.ScreenSaver /ScreenSaver org.freedesktop.ScreenSaver.SetActive true; elif command -v xscreensaver-command >/dev/null 2>&1; then xscreensaver-command -lock; fi".to_string() + ), + }, + } +} + fn preview_commands(config: &Config) -> Vec { build_action_commands(config) .iter() @@ -716,13 +985,13 @@ enum RunMode { Tui, } -fn write_timer_line(output: &mut dyn Write, message: &str) { - writeln!(output, "{message}").ok(); +fn write_timer_line(output: &mut dyn Write, message: &str) -> io::Result<()> { + writeln!(output, "{message}") } -fn write_timer_progress(output: &mut dyn Write, message: &str) { - write!(output, "\r{message}").ok(); - output.flush().ok(); +fn write_timer_progress(output: &mut dyn Write, message: &str) -> io::Result<()> { + write!(output, "\r{message}")?; + output.flush() } fn run_timer( @@ -744,16 +1013,32 @@ fn run_timer( duration.as_millis().min(u128::from(u64::MAX)) as u64, Ordering::Relaxed, ); + let _inhibit = if config.inhibit_sleep { + let child = Command::new("systemd-inhibit") + .args([ + "--what=sleep", + "--who=gosleep-timer", + "--why=Timer running", + "sleep", + "infinity", + ]) + .spawn() + .context("spawn systemd-inhibit")?; + Some(InhibitGuard(Some(child))) + } else { + None + }; + if let Some(output) = output.as_deref_mut() { - write_timer_line(output, &format!("timer started: {}", config.duration)); + write_timer_line(output, &format!("timer started: {}", config.duration))?; } loop { if stop.load(Ordering::Relaxed) { if let Some(output) = output.as_deref_mut() { - write_timer_line(output, "stopped"); + write_timer_line(output, "stopped")?; } - bail!("timer stopped"); + bail!(TIMER_STOPPED_MSG); } if pause.load(Ordering::Relaxed) { @@ -778,7 +1063,7 @@ fn run_timer( write_timer_progress( output, &format!("remaining: {}", format_duration(remaining)), - ); + )?; } thread::sleep(Duration::from_millis(250)); } @@ -786,13 +1071,13 @@ fn run_timer( if stop.load(Ordering::Relaxed) { if let Some(output) = output.as_deref_mut() { - write_timer_line(output, "stopped"); + write_timer_line(output, "stopped")?; } bail!("timer stopped"); } if let Some(output) = output.as_deref_mut() { - write_timer_line(output, "finished"); + write_timer_line(output, "finished")?; } let mut failures = Vec::new(); let actions = if options.no_actions { @@ -801,9 +1086,15 @@ fn run_timer( build_action_commands(config) }; for command in &actions { + if stop.load(Ordering::Relaxed) { + if let Some(output) = output.as_deref_mut() { + write_timer_line(output, "stopped")?; + } + break; + } if let Some(output) = output.as_deref_mut() { let prefix = if options.dry_run { "would run" } else { "run" }; - write_timer_line(output, &format!("{prefix}: {}", command.display())); + write_timer_line(output, &format!("{prefix}: {}", command.display()))?; } if options.dry_run { continue; @@ -820,12 +1111,10 @@ fn run_timer( dry_run: options.dry_run, no_actions: options.no_actions, }; - if !failures.is_empty() { - bail!( - "{} action(s) failed: {}", - failures.len(), - failures.join("; ") - ); + if !failures.is_empty() + && let Some(output) = &mut output + { + write_timer_line(output, &format!("{} action(s) failed", failures.len()))?; } Ok(report) } @@ -1253,7 +1542,10 @@ impl App { match field.kind { FieldKind::Edit => match field.key { FieldKey::Duration => self.config.duration = value, + FieldKey::WorkspaceCommand => self.config.actions.workspace.command = value, FieldKey::LockCommand => self.config.actions.lock.command = value, + FieldKey::MonitorCommand => self.config.actions.monitor.command = value, + FieldKey::NotifyMessage => self.config.actions.notify.message = value, _ => {} }, FieldKind::Int => { @@ -1359,7 +1651,9 @@ impl App { .as_ref() .is_some_and(|timer| timer.handle.is_finished()) { - let timer = self.timer.take().expect("timer exists"); + let Some(timer) = self.timer.take() else { + return; + }; self.apply_timer_result(timer.handle.join()); } } @@ -1367,16 +1661,25 @@ impl App { fn apply_timer_result(&mut self, result: thread::Result>) { match result { Ok(Ok(report)) => { - if let Err(err) = append_history( - &history_path(&self.path), - report.to_history("finished", None), - ) { + let status = if report.failures.is_empty() { + "finished" + } else { + "partial" + }; + if let Err(err) = + append_history(&history_path(&self.path), report.to_history(status, None)) + { self.status = format!("history failed: {err}"); + } else if !report.failures.is_empty() { + self.status = format!( + "timer finished ({} action(s) failed)", + report.failures.len() + ); } else { self.status = "timer finished".to_string(); } } - Ok(Err(err)) if err.to_string() == "timer stopped" => { + Ok(Err(err)) if err.to_string() == TIMER_STOPPED_MSG => { self.status = "timer stopped".to_string() } Ok(Err(err)) => self.status = format!("timer failed: {err}"), @@ -1401,16 +1704,25 @@ impl App { fn field_value(&self, field: &Field) -> String { match field.key { FieldKey::Duration => self.config.duration.clone(), + FieldKey::InhibitSleep => on_off(self.config.inhibit_sleep), FieldKey::WorkspaceEnabled => on_off(self.config.actions.workspace.enabled), FieldKey::WorkspaceBackend => self.config.actions.workspace.backend.clone(), FieldKey::WorkspaceNumber => self.config.actions.workspace.number.to_string(), + FieldKey::WorkspaceCommand => self.config.actions.workspace.command.clone(), FieldKey::MediaEnabled => on_off(self.config.actions.media.enabled), FieldKey::MediaAction => self.config.actions.media.action.clone(), FieldKey::BrightnessEnabled => on_off(self.config.actions.brightness.enabled), FieldKey::BrightnessValue => self.config.actions.brightness.value.to_string(), FieldKey::MuteEnabled => on_off(self.config.actions.mute.enabled), + FieldKey::MuteInputEnabled => on_off(self.config.actions.mute_input.enabled), FieldKey::LockEnabled => on_off(self.config.actions.lock.enabled), + FieldKey::LockBackend => self.config.actions.lock.backend.clone(), FieldKey::LockCommand => self.config.actions.lock.command.clone(), + FieldKey::MonitorEnabled => on_off(self.config.actions.monitor.enabled), + FieldKey::MonitorBackend => self.config.actions.monitor.backend.clone(), + FieldKey::MonitorCommand => self.config.actions.monitor.command.clone(), + FieldKey::NotifyEnabled => on_off(self.config.actions.notify.enabled), + FieldKey::NotifyMessage => self.config.actions.notify.message.clone(), FieldKey::KillEnabled => on_off(self.config.actions.kill.enabled), FieldKey::KillProcesses => self.config.actions.kill.processes.join(","), FieldKey::PowerMode => self.config.actions.power.mode.clone(), @@ -1421,6 +1733,7 @@ impl App { fn toggle_bool(&mut self, key: FieldKey) { match key { + FieldKey::InhibitSleep => self.config.inhibit_sleep = !self.config.inhibit_sleep, FieldKey::WorkspaceEnabled => { self.config.actions.workspace.enabled = !self.config.actions.workspace.enabled } @@ -1433,12 +1746,21 @@ impl App { FieldKey::MuteEnabled => { self.config.actions.mute.enabled = !self.config.actions.mute.enabled } + FieldKey::MuteInputEnabled => { + self.config.actions.mute_input.enabled = !self.config.actions.mute_input.enabled + } FieldKey::LockEnabled => { self.config.actions.lock.enabled = !self.config.actions.lock.enabled } FieldKey::KillEnabled => { self.config.actions.kill.enabled = !self.config.actions.kill.enabled } + FieldKey::MonitorEnabled => { + self.config.actions.monitor.enabled = !self.config.actions.monitor.enabled + } + FieldKey::NotifyEnabled => { + self.config.actions.notify.enabled = !self.config.actions.notify.enabled + } FieldKey::CustomEnabled => { self.config.actions.custom.enabled = !self.config.actions.custom.enabled } @@ -1451,6 +1773,8 @@ impl App { FieldKey::WorkspaceBackend => &mut self.config.actions.workspace.backend, FieldKey::MediaAction => &mut self.config.actions.media.action, FieldKey::PowerMode => &mut self.config.actions.power.mode, + FieldKey::MonitorBackend => &mut self.config.actions.monitor.backend, + FieldKey::LockBackend => &mut self.config.actions.lock.backend, _ => return, }; let index = options @@ -1489,16 +1813,25 @@ enum FieldKind { #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum FieldKey { Duration, + InhibitSleep, WorkspaceEnabled, WorkspaceBackend, WorkspaceNumber, + WorkspaceCommand, MediaEnabled, MediaAction, BrightnessEnabled, BrightnessValue, MuteEnabled, + MuteInputEnabled, LockEnabled, + LockBackend, LockCommand, + MonitorEnabled, + MonitorBackend, + MonitorCommand, + NotifyEnabled, + NotifyMessage, KillEnabled, KillProcesses, PowerMode, @@ -1512,6 +1845,11 @@ const FIELDS: &[Field] = &[ key: FieldKey::Duration, kind: FieldKind::Edit, }, + Field { + label: "inhibit sleep", + key: FieldKey::InhibitSleep, + kind: FieldKind::Bool, + }, Field { label: "workspace enabled", key: FieldKey::WorkspaceEnabled, @@ -1520,13 +1858,18 @@ const FIELDS: &[Field] = &[ Field { label: "workspace backend", key: FieldKey::WorkspaceBackend, - kind: FieldKind::Cycle(&["auto", "niri", "hyprland", "kde"]), + kind: FieldKind::Cycle(&["auto", "niri", "hyprland", "sway", "i3", "kde"]), }, Field { label: "workspace number", key: FieldKey::WorkspaceNumber, kind: FieldKind::Int, }, + Field { + label: "workspace command", + key: FieldKey::WorkspaceCommand, + kind: FieldKind::Edit, + }, Field { label: "media enabled", key: FieldKey::MediaEnabled, @@ -1552,16 +1895,60 @@ const FIELDS: &[Field] = &[ key: FieldKey::MuteEnabled, kind: FieldKind::Bool, }, + Field { + label: "mute input enabled", + key: FieldKey::MuteInputEnabled, + kind: FieldKind::Bool, + }, Field { label: "lock enabled", key: FieldKey::LockEnabled, kind: FieldKind::Bool, }, + Field { + label: "lock backend", + key: FieldKey::LockBackend, + kind: FieldKind::Cycle(&[ + "auto", + "loginctl", + "hyprlock", + "swaylock", + "i3lock", + "gnome", + "kde", + "xscreensaver", + ]), + }, Field { label: "lock command", key: FieldKey::LockCommand, kind: FieldKind::Edit, }, + Field { + label: "monitor enabled", + key: FieldKey::MonitorEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "monitor backend", + key: FieldKey::MonitorBackend, + kind: FieldKind::Cycle(&["auto", "hyprland", "niri", "sway", "kde", "gnome", "x11"]), + }, + Field { + label: "monitor command", + key: FieldKey::MonitorCommand, + kind: FieldKind::Edit, + }, + Field { + label: "notify enabled", + key: FieldKey::NotifyEnabled, + kind: FieldKind::Bool, + }, + Field { + label: "notify message", + key: FieldKey::NotifyMessage, + kind: FieldKind::Edit, + }, Field { label: "kill enabled", key: FieldKey::KillEnabled, @@ -1575,7 +1962,14 @@ const FIELDS: &[Field] = &[ Field { label: "power mode", key: FieldKey::PowerMode, - kind: FieldKind::Cycle(&["none", "poweroff", "reboot"]), + kind: FieldKind::Cycle(&[ + "none", + "suspend", + "hibernate", + "hybrid-sleep", + "poweroff", + "reboot", + ]), }, Field { label: "custom enabled", @@ -1598,18 +1992,26 @@ fn visible_fields(config: &Config) -> Vec<&'static Field> { fn field_visible(config: &Config, key: FieldKey) -> bool { match key { - FieldKey::WorkspaceBackend | FieldKey::WorkspaceNumber => config.actions.workspace.enabled, + FieldKey::WorkspaceBackend | FieldKey::WorkspaceNumber | FieldKey::WorkspaceCommand => { + config.actions.workspace.enabled + } FieldKey::MediaAction => config.actions.media.enabled, FieldKey::BrightnessValue => config.actions.brightness.enabled, - FieldKey::LockCommand => config.actions.lock.enabled, + FieldKey::LockBackend | FieldKey::LockCommand => config.actions.lock.enabled, + FieldKey::MonitorBackend | FieldKey::MonitorCommand => config.actions.monitor.enabled, + FieldKey::NotifyMessage => config.actions.notify.enabled, FieldKey::KillProcesses => config.actions.kill.enabled, FieldKey::CustomCommands => config.actions.custom.enabled, FieldKey::Duration + | FieldKey::InhibitSleep | FieldKey::WorkspaceEnabled | FieldKey::MediaEnabled | FieldKey::BrightnessEnabled | FieldKey::MuteEnabled + | FieldKey::MuteInputEnabled | FieldKey::LockEnabled + | FieldKey::MonitorEnabled + | FieldKey::NotifyEnabled | FieldKey::KillEnabled | FieldKey::PowerMode | FieldKey::CustomEnabled => true, @@ -1851,7 +2253,10 @@ mod tests { config.actions.media.enabled = false; config.actions.brightness.enabled = false; config.actions.mute.enabled = false; + config.actions.mute_input.enabled = false; config.actions.lock.enabled = false; + config.actions.monitor.enabled = false; + config.actions.notify.enabled = false; config.actions.kill.enabled = false; config.actions.power.mode = "none".to_string(); config.actions.custom.enabled = false; @@ -1871,6 +2276,7 @@ mod tests { fn preview_matches_expected_commands() { let mut config = Config::default(); config.actions.lock.enabled = true; + config.actions.lock.backend = "loginctl".to_string(); config.actions.kill.enabled = true; config.actions.kill.processes = vec!["firefox".to_string(), "telegram-desktop".to_string()]; config.actions.custom.enabled = true; @@ -1951,7 +2357,7 @@ mod tests { config.actions.custom.commands = vec!["false".to_string(), "true".to_string()]; let mut output = Vec::new(); - let error = run_timer( + let report = run_timer( &config, &stop, &Arc::new(AtomicBool::new(false)), @@ -1960,12 +2366,13 @@ mod tests { TimerOptions::default(), Some(&mut output), ) - .unwrap_err(); + .unwrap(); let text = String::from_utf8(output).unwrap(); assert!(text.contains("run: false")); assert!(text.contains("run: true")); - assert!(error.to_string().contains("1 action(s) failed")); + assert_eq!(report.failures.len(), 1); + assert!(report.failures[0].contains("custom failed")); } #[test] @@ -1977,7 +2384,7 @@ mod tests { )); fs::write( &path, - "duration: 25m\nactions:\n power:\n mode: suspend\n", + "duration: 25m\nactions:\n power:\n mode: bogus\n", ) .unwrap(); @@ -2019,12 +2426,20 @@ mod tests { let keys = visible_field_keys(&config); assert!(keys.contains(&FieldKey::Duration)); + assert!(keys.contains(&FieldKey::InhibitSleep)); assert!(keys.contains(&FieldKey::MediaEnabled)); + assert!(keys.contains(&FieldKey::MonitorEnabled)); + assert!(keys.contains(&FieldKey::NotifyEnabled)); assert!(!keys.contains(&FieldKey::MediaAction)); assert!(!keys.contains(&FieldKey::WorkspaceBackend)); assert!(!keys.contains(&FieldKey::WorkspaceNumber)); + assert!(!keys.contains(&FieldKey::WorkspaceCommand)); assert!(!keys.contains(&FieldKey::BrightnessValue)); + assert!(!keys.contains(&FieldKey::LockBackend)); assert!(!keys.contains(&FieldKey::LockCommand)); + assert!(!keys.contains(&FieldKey::MonitorBackend)); + assert!(!keys.contains(&FieldKey::MonitorCommand)); + assert!(!keys.contains(&FieldKey::NotifyMessage)); assert!(!keys.contains(&FieldKey::KillProcesses)); assert!(!keys.contains(&FieldKey::CustomCommands)); @@ -2032,6 +2447,8 @@ mod tests { config.actions.workspace.enabled = true; config.actions.brightness.enabled = true; config.actions.lock.enabled = true; + config.actions.monitor.enabled = true; + config.actions.notify.enabled = true; config.actions.kill.enabled = true; config.actions.custom.enabled = true; @@ -2039,8 +2456,13 @@ mod tests { assert!(keys.contains(&FieldKey::MediaAction)); assert!(keys.contains(&FieldKey::WorkspaceBackend)); assert!(keys.contains(&FieldKey::WorkspaceNumber)); + assert!(keys.contains(&FieldKey::WorkspaceCommand)); assert!(keys.contains(&FieldKey::BrightnessValue)); + assert!(keys.contains(&FieldKey::LockBackend)); assert!(keys.contains(&FieldKey::LockCommand)); + assert!(keys.contains(&FieldKey::MonitorBackend)); + assert!(keys.contains(&FieldKey::MonitorCommand)); + assert!(keys.contains(&FieldKey::NotifyMessage)); assert!(keys.contains(&FieldKey::KillProcesses)); assert!(keys.contains(&FieldKey::CustomCommands)); }