9 Commits

Author SHA1 Message Date
forust 2c32f5856b chore: release v1.2.0
release / verify (push) Successful in 26s
ci / rust (push) Successful in 45s
ci / lint-yaml (push) Successful in 5s
release / linux-amd64 (push) Successful in 29s
2026-07-05 02:37:18 +02:00
forust 1babd404f9 docs: document timer utilities
ci / rust (push) Successful in 45s
ci / lint-yaml (push) Successful in 5s
2026-07-05 02:33:21 +02:00
forust ce35617bbe feat: show fullscreen timer view 2026-07-05 02:31:58 +02:00
forust f4c54837de feat: add timer cli utilities 2026-07-05 02:30:27 +02:00
forust 483ef2fecf fix: publish valid release assets
ci / rust (push) Successful in 41s
ci / lint-yaml (push) Successful in 4s
2026-07-05 02:20:20 +02:00
forust ce236bc49f feat: hide disabled action settings 2026-07-05 02:20:04 +02:00
forust b067cf572e fix: harden timer lifecycle 2026-07-05 02:19:46 +02:00
forust af95742f69 build: publish raw release binary 2026-07-05 01:49:17 +02:00
forust 0b0f0af82d docs: remove version-specific README text
ci / rust (push) Successful in 43s
ci / lint-yaml (push) Successful in 5s
2026-07-03 23:54:23 +02:00
9 changed files with 774 additions and 84 deletions
+5 -4
View File
@@ -56,9 +56,10 @@ jobs:
tag="${GITHUB_REF##*/}" tag="${GITHUB_REF##*/}"
version="${tag#v}" version="${tag#v}"
mkdir -p dist mkdir -p dist
cp target/release/gosleep-timer dist/gosleep-timer binary="gosleep-timer-${version}-linux-amd64"
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer cp target/release/gosleep-timer "dist/${binary}"
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" cd dist
sha256sum "${binary}" > "${binary}.sha256"
- name: Create Gitea release - name: Create Gitea release
id: release id: release
@@ -100,7 +101,7 @@ jobs:
run: | run: |
release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases" release_url="https://gitea.forust.xyz/api/v1/repos/forust/gosleep/releases"
release_id="${{ steps.release.outputs.release_id }}" release_id="${{ steps.release.outputs.release_id }}"
for asset in dist/*.tar.gz dist/*.sha256; do for asset in dist/gosleep-timer-*; do
name="$(basename "$asset")" name="$(basename "$asset")"
release="$(curl -fsS \ release="$(curl -fsS \
"${release_url}/${release_id}" \ "${release_url}/${release_id}" \
+8 -5
View File
@@ -46,10 +46,12 @@ Use a temporary config path so local tests do not mutate the user's real config:
```bash ```bash
tmpdir="$(mktemp -d)" tmpdir="$(mktemp -d)"
cargo run -- --config "$tmpdir/config.yaml" init cargo run -- --config "$tmpdir/config.yaml" init
cargo run -- --config "$tmpdir/config.yaml" validate
cargo run -- --config "$tmpdir/config.yaml" status
cargo run -- --config "$tmpdir/config.yaml" preview cargo run -- --config "$tmpdir/config.yaml" preview
``` ```
For `run` smoke tests, disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands. For `run` smoke tests, pass `--no-actions` or disable actions first so the command does not call `playerctl`, `systemctl`, `pkill`, workspace tools, or custom shell commands.
## Versioning ## Versioning
@@ -73,20 +75,21 @@ test "$(cat VERSION)" = "$(cargo metadata --no-deps --format-version 1 | jq -r '
Release workflow is tag-based: Release workflow is tag-based:
```bash ```bash
git tag -a v1.1.1 -m "gosleep-timer v1.1.1" git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
git push origin main git push origin main
git push origin v1.1.1 git push origin vMAJOR.MINOR.PATCH
``` ```
The Gitea release workflow: The Gitea release workflow:
- runs formatting, tests, clippy, and YAML lint before building
- builds `target/release/gosleep-timer` - builds `target/release/gosleep-timer`
- creates `gosleep-timer-<version>-linux-amd64.tar.gz` - copies `gosleep-timer-<version>-linux-amd64`
- writes a `.sha256` - writes a `.sha256`
- creates or reuses the matching Gitea release - creates or reuses the matching Gitea release
- replaces release assets with matching names - replaces release assets with matching names
The workflow uses the built-in `GITEA_TOKEN` and declares `permissions: contents: write`. The workflow exposes `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN` and declares `permissions: contents: write`.
## Git Remote ## Git Remote
+16
View File
@@ -4,6 +4,21 @@ All notable changes to this project are documented here.
The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`. The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`.
## [1.2.0] - 2026-07-05
### Added
- Added `status`, `validate`, `edit`, `history`, and `stats` CLI commands.
- Added `run --dry-run` and `run --no-actions` for safer timer verification.
- Added TUI pause/resume support and a fullscreen running timer view with large ASCII time-left display.
- Added history recording for completed sessions and aggregate sleep/timer statistics.
- Added red highlighting for dangerous post-timer actions and common destructive custom commands.
### Changed
- TUI settings now hide dependent options when their parent action is disabled.
- Release assets are published as a raw Linux binary plus `.sha256` instead of a tar archive.
## [1.1.3] - 2026-07-03 ## [1.1.3] - 2026-07-03
### Fixed ### Fixed
@@ -27,6 +42,7 @@ The project uses semantic versioning. Tags are formatted as `vMAJOR.MINOR.PATCH`
- Added YAML configuration, CLI `init`, `preview`, and `run` commands. - Added YAML configuration, CLI `init`, `preview`, and `run` commands.
- Added Gitea CI and tag-based release workflows. - Added Gitea CI and tag-based release workflows.
[1.2.0]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.2.0
[1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3 [1.1.3]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.3
[1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2 [1.1.2]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.2
[1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1 [1.1.1]: https://gitea.forust.xyz/forust/gosleep/releases/tag/v1.1.1
Generated
+1 -1
View File
@@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.3" version = "1.2.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"clap", "clap",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "gosleep-timer" name = "gosleep-timer"
version = "1.1.3" version = "1.2.0"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI"
+45 -8
View File
@@ -6,8 +6,7 @@ Rust TUI sleep timer for Linux desktop actions.
## Status ## Status
- Current version: `1.1.1` - Release tags: `vMAJOR.MINOR.PATCH`
- Release tags: `vMAJOR.MINOR.PATCH`, for example `v1.1.1`
- Primary repository: <https://gitea.forust.xyz/forust/gosleep> - Primary repository: <https://gitea.forust.xyz/forust/gosleep>
- SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git` - SSH upstream: `ssh://git@gitssh.forust.xyz:2221/forust/gosleep.git`
@@ -34,11 +33,11 @@ install -Dm755 target/release/gosleep-timer ~/.local/bin/gosleep-timer
### From release artifact ### From release artifact
Download `gosleep-timer-<version>-linux-amd64.tar.gz` from the Gitea release page, then: Download `gosleep-timer-<version>-linux-amd64` and its `.sha256` from the Gitea release page, then:
```bash ```bash
tar -xzf gosleep-timer-1.1.1-linux-amd64.tar.gz sha256sum -c gosleep-timer-<version>-linux-amd64.sha256
install -Dm755 gosleep-timer ~/.local/bin/gosleep-timer install -Dm755 gosleep-timer-<version>-linux-amd64 ~/.local/bin/gosleep-timer
``` ```
## Usage ## Usage
@@ -61,6 +60,24 @@ Preview commands that will run after the countdown:
gosleep-timer preview gosleep-timer preview
``` ```
Show the current config, enabled actions, and dangerous-action markers:
```bash
gosleep-timer status
```
Validate the config without starting the timer:
```bash
gosleep-timer validate
```
Open the config in `$VISUAL`, `$EDITOR`, or `vi`:
```bash
gosleep-timer edit
```
Run the saved timer config: Run the saved timer config:
```bash ```bash
@@ -74,6 +91,20 @@ gosleep-timer run 45m
gosleep-timer run 1h30m gosleep-timer run 1h30m
``` ```
Run the countdown without executing post-timer actions:
```bash
gosleep-timer run --dry-run 10s
gosleep-timer run --no-actions 10s
```
Inspect recorded sessions and aggregate stats:
```bash
gosleep-timer history
gosleep-timer stats
```
Use a custom config path: Use a custom config path:
```bash ```bash
@@ -90,6 +121,7 @@ gosleep-timer --config ./config.yaml preview
| `Enter` while editing | Apply edit | | `Enter` while editing | Apply edit |
| `Esc` while editing | Cancel edit | | `Esc` while editing | Cancel edit |
| `r` | Start/restart timer | | `r` | Start/restart timer |
| `p` | Pause/resume running timer |
| `x` | Stop timer | | `x` | Stop timer |
| `s` | Save config | | `s` | Save config |
| `q` / `Esc` | Quit | | `q` / `Esc` | Quit |
@@ -155,6 +187,12 @@ Other actions use common Linux desktop tools:
- `systemctl` - `systemctl`
Install only the tools needed for the actions you enable. Install only the tools needed for the actions you enable.
Dangerous actions such as `pkill`, power actions, and common destructive custom commands are highlighted in red in the TUI preview.
## History
Completed timer sessions are appended to `history.yaml` next to the active config file.
If no poweroff or reboot action is configured, CLI `run` asks for wake confirmation after the timer finishes and records the elapsed sleep time.
## Development ## Development
@@ -185,7 +223,7 @@ Version is stored in:
Release tags must match the package version: Release tags must match the package version:
```bash ```bash
git tag -a v1.1.1 -m "gosleep-timer v1.1.1" git tag -a vMAJOR.MINOR.PATCH -m "gosleep-timer vMAJOR.MINOR.PATCH"
git push origin main --tags git push origin main --tags
``` ```
@@ -198,12 +236,11 @@ Required runner tools:
- Rust stable toolchain with `cargo`, `rustfmt`, and `clippy` - Rust stable toolchain with `cargo`, `rustfmt`, and `clippy`
- `curl` - `curl`
- `jq` - `jq`
- `tar`
- `sha256sum` - `sha256sum`
Release authentication: Release authentication:
- The workflow uses Gitea Actions' built-in `GITEA_TOKEN`. - The workflow exposes Gitea Actions' built-in `${{ secrets.GITEA_TOKEN }}` as `GITEA_TOKEN`.
- The workflow declares `permissions: contents: write` so the token can create releases and upload assets. - The workflow declares `permissions: contents: write` so the token can create releases and upload assets.
See [RELEASING.md](RELEASING.md) for the full release checklist. See [RELEASING.md](RELEASING.md) for the full release checklist.
+11 -11
View File
@@ -36,7 +36,6 @@ https://gitea.forust.xyz/forust/gosleep.git
- `clippy` - `clippy`
- `curl` - `curl`
- `jq` - `jq`
- `tar`
- `sha256sum` - `sha256sum`
- Docker, if using the YAML lint job as written - Docker, if using the YAML lint job as written
4. Confirm Actions job token permissions allow `contents: write`. 4. Confirm Actions job token permissions allow `contents: write`.
@@ -44,10 +43,10 @@ https://gitea.forust.xyz/forust/gosleep.git
## Version Bump Checklist ## Version Bump Checklist
For a patch bump, for example `1.1.1` to `1.1.2`: For a patch bump, for example `1.1.3` to `1.1.4`:
```bash ```bash
version=1.1.2 version=1.1.4
``` ```
Update: Update:
@@ -70,20 +69,20 @@ cargo build --release --locked
```bash ```bash
git status --short git status --short
git commit -am "chore: release v1.1.2" git commit -am "chore: release v${version}"
git tag -a v1.1.2 -m "gosleep-timer v1.1.2" git tag -a "v${version}" -m "gosleep-timer v${version}"
git push origin main git push origin main
git push origin v1.1.2 git push origin "v${version}"
``` ```
The `.gitea/workflows/release.yaml` workflow will: The `.gitea/workflows/release.yaml` workflow will:
1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint. 1. Re-run release gates: `cargo fmt --check`, `cargo test --locked`, `cargo clippy --locked -- -D warnings`, and YAML lint.
2. Build `target/release/gosleep-timer`. 2. Build `target/release/gosleep-timer`.
3. Package `gosleep-timer-<version>-linux-amd64.tar.gz`. 3. Copy binary as `gosleep-timer-<version>-linux-amd64`.
4. Generate a SHA-256 checksum. 4. Generate a SHA-256 checksum.
5. Create a Gitea release. 5. Create a Gitea release.
6. Upload the archive and checksum as release assets. 6. Upload the binary and checksum as release assets.
The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names. The workflow is idempotent: if the release already exists, it reuses it and replaces assets with matching names.
If any lint or test step fails, the build and release job will not run. If any lint or test step fails, the build and release job will not run.
@@ -96,7 +95,8 @@ If Actions are unavailable:
version="$(cat VERSION)" version="$(cat VERSION)"
cargo build --release --locked cargo build --release --locked
mkdir -p dist mkdir -p dist
cp target/release/gosleep-timer dist/gosleep-timer binary="gosleep-timer-${version}-linux-amd64"
tar -C dist -czf "dist/gosleep-timer-${version}-linux-amd64.tar.gz" gosleep-timer cp target/release/gosleep-timer "dist/${binary}"
sha256sum "dist/gosleep-timer-${version}-linux-amd64.tar.gz" > "dist/gosleep-timer-${version}-linux-amd64.tar.gz.sha256" cd dist
sha256sum "${binary}" > "${binary}.sha256"
``` ```
+1 -1
View File
@@ -1 +1 @@
1.1.3 1.2.0
+686 -53
View File
@@ -1,14 +1,14 @@
use std::{ use std::{
fs, fs,
io::{self, Write}, io::{self, Write},
path::PathBuf, path::{Path, PathBuf},
process::{Command, Stdio}, process::{Command, Stdio},
sync::{ sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, AtomicU64, Ordering},
}, },
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant, SystemTime, UNIX_EPOCH},
}; };
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
@@ -42,7 +42,18 @@ struct Cli {
#[derive(Subcommand)] #[derive(Subcommand)]
enum Commands { enum Commands {
Run { duration: Option<String> }, Run {
#[arg(long)]
dry_run: bool,
#[arg(long)]
no_actions: bool,
duration: Option<String>,
},
Status,
Validate,
Edit,
History,
Stats,
Preview, Preview,
Init, Init,
} }
@@ -129,6 +140,34 @@ struct ActionCommand {
shell: Option<String>, shell: Option<String>,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HistoryEntry {
started_at: u64,
duration_seconds: u64,
slept_seconds: Option<u64>,
status: String,
actions: Vec<String>,
failures: Vec<String>,
dry_run: bool,
no_actions: bool,
}
#[derive(Debug, Clone)]
struct TimerReport {
started_at: u64,
duration: Duration,
actions: Vec<String>,
failures: Vec<String>,
dry_run: bool,
no_actions: bool,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct TimerOptions {
dry_run: bool,
no_actions: bool,
}
impl Default for Config { impl Default for Config {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -187,6 +226,44 @@ impl ActionCommand {
fn display(&self) -> String { fn display(&self) -> String {
self.shell.clone().unwrap_or_else(|| self.args.join(" ")) self.shell.clone().unwrap_or_else(|| self.args.join(" "))
} }
fn is_dangerous(&self) -> bool {
if matches!(self.label, "kill" | "power") {
return true;
}
let command = self.display().to_lowercase();
[
"rm -rf",
"mkfs",
"dd if=",
"shutdown",
"reboot",
"poweroff",
"systemctl poweroff",
"systemctl reboot",
"wipefs",
"shred",
"killall",
"pkill",
]
.iter()
.any(|needle| command.contains(needle))
}
}
impl TimerReport {
fn to_history(&self, status: &str, slept_seconds: Option<u64>) -> HistoryEntry {
HistoryEntry {
started_at: self.started_at,
duration_seconds: self.duration.as_secs(),
slept_seconds,
status: status.to_string(),
actions: self.actions.clone(),
failures: self.failures.clone(),
dry_run: self.dry_run,
no_actions: self.no_actions,
}
}
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@@ -195,18 +272,52 @@ fn main() -> Result<()> {
let mut config = ensure_config(&path)?; let mut config = ensure_config(&path)?;
match cli.command { match cli.command {
Some(Commands::Run { duration }) => { Some(Commands::Run {
dry_run,
no_actions,
duration,
}) => {
if let Some(duration) = duration { if let Some(duration) = duration {
config.duration = duration; config.duration = duration;
} }
let mut stdout = io::stdout(); let mut stdout = io::stdout();
run_timer( let options = TimerOptions {
dry_run,
no_actions,
};
let report = run_timer(
&config, &config,
&Arc::new(AtomicBool::new(false)), &Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli, RunMode::Cli,
options,
Some(&mut stdout), Some(&mut stdout),
)?;
let slept_seconds = prompt_wake_confirmation(&config, options)?;
append_history(
&history_path(&path),
report.to_history("finished", slept_seconds),
) )
} }
Some(Commands::Status) => {
print_status(&path, &config);
Ok(())
}
Some(Commands::Validate) => {
validate_config(&config)?;
println!("valid {}", path.display());
Ok(())
}
Some(Commands::Edit) => edit_config(&path),
Some(Commands::History) => {
print_history(&history_path(&path))?;
Ok(())
}
Some(Commands::Stats) => {
print_stats(&history_path(&path))?;
Ok(())
}
Some(Commands::Preview) => { Some(Commands::Preview) => {
for command in preview_commands(&config) { for command in preview_commands(&config) {
println!("{command}"); println!("{command}");
@@ -233,7 +344,7 @@ fn default_config_path() -> PathBuf {
.join("config.yaml") .join("config.yaml")
} }
fn ensure_config(path: &PathBuf) -> Result<Config> { fn ensure_config(path: &Path) -> Result<Config> {
if !path.exists() { if !path.exists() {
let config = Config::default(); let config = Config::default();
save_config(path, &config)?; save_config(path, &config)?;
@@ -242,10 +353,11 @@ fn ensure_config(path: &PathBuf) -> Result<Config> {
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?; let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
let mut config: Config = serde_yaml::from_str(&data).context("parse config")?; let mut config: Config = serde_yaml::from_str(&data).context("parse config")?;
normalize_config(&mut config); normalize_config(&mut config);
validate_config(&config)?;
Ok(config) Ok(config)
} }
fn save_config(path: &PathBuf, config: &Config) -> Result<()> { fn save_config(path: &Path, config: &Config) -> Result<()> {
let mut config = config.clone(); let mut config = config.clone();
normalize_config(&mut config); normalize_config(&mut config);
validate_config(&config)?; validate_config(&config)?;
@@ -257,6 +369,131 @@ fn save_config(path: &PathBuf, config: &Config) -> Result<()> {
Ok(()) Ok(())
} }
fn history_path(config_path: &Path) -> PathBuf {
config_path.with_file_name("history.yaml")
}
fn unix_timestamp() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn read_history(path: &Path) -> Result<Vec<HistoryEntry>> {
if !path.exists() {
return Ok(Vec::new());
}
let data = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
serde_yaml::from_str(&data).with_context(|| format!("parse {}", path.display()))
}
fn append_history(path: &Path, entry: HistoryEntry) -> Result<()> {
let mut entries = read_history(path)?;
entries.push(entry);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
}
let data = serde_yaml::to_string(&entries).context("serialize history")?;
fs::write(path, data).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
fn print_status(path: &Path, config: &Config) {
println!("config: {}", path.display());
println!("history: {}", history_path(path).display());
println!("duration: {}", config.duration);
println!("actions:");
let commands = build_action_commands(config);
if commands.is_empty() {
println!(" none");
} else {
for command in commands {
let danger = if command.is_dangerous() { " !" } else { "" };
println!(" - [{}{}] {}", command.label, danger, command.display());
}
}
}
fn print_history(path: &Path) -> Result<()> {
let entries = read_history(path)?;
if entries.is_empty() {
println!("no history");
return Ok(());
}
for entry in entries.iter().rev().take(20) {
let slept = entry
.slept_seconds
.map(|seconds| format!(" slept={}", format_duration(Duration::from_secs(seconds))))
.unwrap_or_default();
println!(
"{} status={} duration={} actions={} failures={}{}",
entry.started_at,
entry.status,
format_duration(Duration::from_secs(entry.duration_seconds)),
entry.actions.len(),
entry.failures.len(),
slept
);
}
Ok(())
}
fn print_stats(path: &Path) -> Result<()> {
let entries = read_history(path)?;
let finished = entries
.iter()
.filter(|entry| entry.status == "finished")
.count();
let total_duration: u64 = entries.iter().map(|entry| entry.duration_seconds).sum();
let total_slept: u64 = entries.iter().filter_map(|entry| entry.slept_seconds).sum();
let failures: usize = entries.iter().map(|entry| entry.failures.len()).sum();
println!("sessions: {}", entries.len());
println!("finished: {finished}");
println!(
"scheduled: {}",
format_duration(Duration::from_secs(total_duration))
);
println!(
"slept: {}",
format_duration(Duration::from_secs(total_slept))
);
println!("action failures: {failures}");
Ok(())
}
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")?;
if !status.success() {
bail!("editor exited with {status}");
}
let _ = ensure_config(path)?;
Ok(())
}
fn prompt_wake_confirmation(config: &Config, options: TimerOptions) -> Result<Option<u64>> {
if options.dry_run
|| options.no_actions
|| matches!(config.actions.power.mode.as_str(), "poweroff" | "reboot")
{
return Ok(None);
}
print!("timer finished. Press Enter when awake to record sleep time...");
io::stdout().flush().ok();
let started = Instant::now();
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.context("read wake confirmation")?;
Ok(Some(started.elapsed().as_secs()))
}
fn normalize_config(config: &mut Config) { fn normalize_config(config: &mut Config) {
if config.duration.is_empty() { if config.duration.is_empty() {
config.duration = "25m".to_string(); config.duration = "25m".to_string();
@@ -491,12 +728,22 @@ fn write_timer_progress(output: &mut dyn Write, message: &str) {
fn run_timer( fn run_timer(
config: &Config, config: &Config,
stop: &Arc<AtomicBool>, stop: &Arc<AtomicBool>,
pause: &Arc<AtomicBool>,
remaining_ms: &Arc<AtomicU64>,
mode: RunMode, mode: RunMode,
options: TimerOptions,
mut output: Option<&mut dyn Write>, mut output: Option<&mut dyn Write>,
) -> Result<()> { ) -> Result<TimerReport> {
validate_config(config)?; validate_config(config)?;
let duration = parse_duration(&config.duration)?; let duration = parse_duration(&config.duration)?;
let started_at = unix_timestamp();
let started = Instant::now(); let started = Instant::now();
let mut remaining = duration;
let mut last_tick = started;
remaining_ms.store(
duration.as_millis().min(u128::from(u64::MAX)) as u64,
Ordering::Relaxed,
);
if let Some(output) = output.as_deref_mut() { 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));
} }
@@ -508,29 +755,79 @@ fn run_timer(
} }
bail!("timer stopped"); bail!("timer stopped");
} }
let elapsed = started.elapsed();
if elapsed >= duration { if pause.load(Ordering::Relaxed) {
last_tick = Instant::now();
thread::sleep(Duration::from_millis(250));
continue;
}
let now = Instant::now();
let elapsed = now.saturating_duration_since(last_tick);
last_tick = now;
remaining = remaining.saturating_sub(elapsed);
remaining_ms.store(
remaining.as_millis().min(u128::from(u64::MAX)) as u64,
Ordering::Relaxed,
);
if remaining.is_zero() {
break; break;
} }
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_progress( write_timer_progress(
output, output,
&format!("remaining: {}", format_duration(duration - elapsed)), &format!("remaining: {}", format_duration(remaining)),
); );
} }
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
remaining_ms.store(0, Ordering::Relaxed);
if stop.load(Ordering::Relaxed) {
if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped");
}
bail!("timer stopped");
}
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "finished"); write_timer_line(output, "finished");
} }
for command in build_action_commands(config) { let mut failures = Vec::new();
let actions = if options.no_actions {
Vec::new()
} else {
build_action_commands(config)
};
for command in &actions {
if let Some(output) = output.as_deref_mut() { if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("run: {}", command.display())); let prefix = if options.dry_run { "would run" } else { "run" };
write_timer_line(output, &format!("{prefix}: {}", command.display()));
}
if options.dry_run {
continue;
}
if let Err(err) = run_command(command, mode) {
failures.push(format!("{} failed: {err}", command.label));
} }
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?;
} }
Ok(()) let report = TimerReport {
started_at,
duration,
actions: actions.iter().map(ActionCommand::display).collect(),
failures: failures.clone(),
dry_run: options.dry_run,
no_actions: options.no_actions,
};
if !failures.is_empty() {
bail!(
"{} action(s) failed: {}",
failures.len(),
failures.join("; ")
);
}
Ok(report)
} }
fn prepare_command_stdio(process: &mut Command, mode: RunMode) { fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
@@ -562,15 +859,18 @@ fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
fn run_tui(path: PathBuf, config: Config) -> Result<()> { fn run_tui(path: PathBuf, config: Config) -> Result<()> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let result = (|| {
execute!(stdout, EnterAlternateScreen)?; let mut stdout = io::stdout();
let backend = CrosstermBackend::new(stdout); execute!(stdout, EnterAlternateScreen)?;
let mut terminal = Terminal::new(backend)?; let backend = CrosstermBackend::new(stdout);
let mut app = App::new(path, config); let mut terminal = Terminal::new(backend)?;
let result = app.run(&mut terminal); let mut app = App::new(path, config);
let result = app.run(&mut terminal);
terminal.show_cursor().ok();
result
})();
disable_raw_mode().ok(); disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok(); execute!(io::stdout(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result result
} }
@@ -585,10 +885,11 @@ struct App {
} }
struct TimerState { struct TimerState {
started: Instant,
duration: Duration, duration: Duration,
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
handle: thread::JoinHandle<Result<()>>, pause: Arc<AtomicBool>,
remaining_ms: Arc<AtomicU64>,
handle: thread::JoinHandle<Result<TimerReport>>,
} }
struct EditState { struct EditState {
@@ -636,11 +937,13 @@ impl App {
self.focus = self.focus.saturating_sub(1) self.focus = self.focus.saturating_sub(1)
} }
KeyCode::Down | KeyCode::Char('j') => { KeyCode::Down | KeyCode::Char('j') => {
self.focus = (self.focus + 1).min(FIELDS.len() - 1); let last = self.visible_fields().len().saturating_sub(1);
self.focus = (self.focus + 1).min(last);
} }
KeyCode::Char(' ') | KeyCode::Enter => self.activate()?, KeyCode::Char(' ') | KeyCode::Enter => self.activate()?,
KeyCode::Char('s') => self.save(), KeyCode::Char('s') => self.save(),
KeyCode::Char('r') => self.start_timer(), KeyCode::Char('r') => self.start_timer(),
KeyCode::Char('p') => self.toggle_pause(),
KeyCode::Char('x') => self.stop_timer(), KeyCode::Char('x') => self.stop_timer(),
_ => {} _ => {}
} }
@@ -655,6 +958,10 @@ impl App {
frame.render_widget(Paragraph::new("terminal too small"), area); frame.render_widget(Paragraph::new("terminal too small"), area);
return; return;
} }
if self.timer.is_some() {
self.draw_running(frame, area);
return;
}
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
@@ -673,7 +980,7 @@ impl App {
edit.field.label, edit.value edit.field.label, edit.value
) )
} else { } else {
"space/enter edit-toggle | r run | x stop | s save | q quit".to_string() "space/enter edit-toggle | r run | p pause | x stop | s save | q quit".to_string()
}; };
frame.render_widget( frame.render_widget(
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)), Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
@@ -681,6 +988,90 @@ impl App {
); );
} }
fn draw_running(&self, frame: &mut Frame, area: Rect) {
let (_, remaining, progress) = self.timer_snapshot();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(2),
Constraint::Length(7),
Constraint::Length(2),
Constraint::Min(4),
Constraint::Length(1),
])
.split(area);
frame.render_widget(
Paragraph::new("gosleep-timer").style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
chunks[0],
);
let time_lines = ascii_time(&format_compact_duration(remaining))
.into_iter()
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Cyan))))
.collect::<Vec<_>>();
frame.render_widget(Paragraph::new(time_lines), chunks[1]);
frame.render_widget(
Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta))
.label(format_gauge_label(progress))
.ratio(progress),
chunks[2],
);
let commands = build_action_commands(&self.config);
let lines = if commands.is_empty() {
vec![Line::from("no post-timer actions enabled")]
} else {
commands
.iter()
.flat_map(|command| {
let style = if command.is_dangerous() {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
wrap_command(
&command.display(),
chunks[3].width.saturating_sub(4) as usize,
)
.into_iter()
.map(move |line| Line::from(Span::styled(line, style)))
})
.collect::<Vec<_>>()
};
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.title(" commands after countdown ")
.borders(Borders::ALL)
.border_style(Color::Blue),
)
.wrap(Wrap { trim: false }),
chunks[3],
);
let footer = if self
.timer
.as_ref()
.is_some_and(|timer| timer.pause.load(Ordering::Relaxed))
{
"paused | p resume | x stop | q quit"
} else {
"running | p pause | x stop | q quit"
};
frame.render_widget(
Paragraph::new(footer).style(Style::default().fg(Color::DarkGray)),
chunks[4],
);
}
fn draw_header(&self, frame: &mut Frame, area: Rect) { fn draw_header(&self, frame: &mut Frame, area: Rect) {
let (_, remaining, progress) = self.timer_snapshot(); let (_, remaining, progress) = self.timer_snapshot();
let header = Layout::default() let header = Layout::default()
@@ -747,8 +1138,10 @@ impl App {
} }
fn draw_fields(&mut self, frame: &mut Frame, area: Rect) { fn draw_fields(&mut self, frame: &mut Frame, area: Rect) {
let fields = self.visible_fields();
let visible = area.height.saturating_sub(2) as usize; let visible = area.height.saturating_sub(2) as usize;
self.offset = self.offset.min(FIELDS.len().saturating_sub(visible)); self.focus = self.focus.min(fields.len().saturating_sub(1));
self.offset = self.offset.min(fields.len().saturating_sub(visible));
if self.focus < self.offset { if self.focus < self.offset {
self.offset = self.focus; self.offset = self.focus;
} }
@@ -756,7 +1149,7 @@ impl App {
self.offset = self.focus.saturating_sub(visible.saturating_sub(1)); self.offset = self.focus.saturating_sub(visible.saturating_sub(1));
} }
let items = FIELDS let items = fields
.iter() .iter()
.enumerate() .enumerate()
.skip(self.offset) .skip(self.offset)
@@ -785,10 +1178,19 @@ impl App {
} }
fn draw_preview(&self, frame: &mut Frame, area: Rect) { fn draw_preview(&self, frame: &mut Frame, area: Rect) {
let lines = preview_commands(&self.config) let commands = build_action_commands(&self.config);
.into_iter() let lines = commands
.flat_map(|command| wrap_command(&command, area.width.saturating_sub(4) as usize)) .iter()
.map(|line| Line::from(Span::styled(line, Style::default().fg(Color::Green)))) .flat_map(|command| {
let style = if command.is_dangerous() {
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::Green)
};
wrap_command(&command.display(), area.width.saturating_sub(4) as usize)
.into_iter()
.map(move |line| Line::from(Span::styled(line, style)))
})
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let lines = if lines.is_empty() { let lines = if lines.is_empty() {
vec![Line::from("no post-timer actions enabled")] vec![Line::from("no post-timer actions enabled")]
@@ -809,11 +1211,13 @@ impl App {
} }
fn activate(&mut self) -> Result<()> { fn activate(&mut self) -> Result<()> {
match FIELDS[self.focus].kind { let Some(field) = self.focused_field() else {
FieldKind::Bool => self.toggle_bool(FIELDS[self.focus].key), return Ok(());
FieldKind::Cycle(options) => self.cycle(FIELDS[self.focus].key, options), };
match field.kind {
FieldKind::Bool => self.toggle_bool(field.key),
FieldKind::Cycle(options) => self.cycle(field.key, options),
FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => { FieldKind::Edit | FieldKind::Int | FieldKind::Csv | FieldKind::Semi => {
let field = FIELDS[self.focus];
self.edit = Some(EditState { self.edit = Some(EditState {
field, field,
value: self.field_value(&field), value: self.field_value(&field),
@@ -900,23 +1304,55 @@ impl App {
} }
}; };
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let pause = Arc::new(AtomicBool::new(false));
let remaining_ms = Arc::new(AtomicU64::new(
duration.as_millis().min(u128::from(u64::MAX)) as u64,
));
let thread_stop = Arc::clone(&stop); let thread_stop = Arc::clone(&stop);
let thread_pause = Arc::clone(&pause);
let thread_remaining_ms = Arc::clone(&remaining_ms);
let config = self.config.clone(); let config = self.config.clone();
let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None)); let handle = thread::spawn(move || {
run_timer(
&config,
&thread_stop,
&thread_pause,
&thread_remaining_ms,
RunMode::Tui,
TimerOptions::default(),
None,
)
});
self.timer = Some(TimerState { self.timer = Some(TimerState {
started: Instant::now(),
duration, duration,
stop, stop,
pause,
remaining_ms,
handle, handle,
}); });
self.status = "timer running".to_string(); self.status = "timer running".to_string();
} }
fn toggle_pause(&mut self) {
let Some(timer) = &self.timer else {
return;
};
let paused = !timer.pause.load(Ordering::Relaxed);
timer.pause.store(paused, Ordering::Relaxed);
self.status = if paused {
"timer paused".to_string()
} else {
"timer running".to_string()
};
}
fn stop_timer(&mut self) { fn stop_timer(&mut self) {
if let Some(timer) = self.timer.take() { if let Some(timer) = self.timer.take() {
timer.stop.store(true, Ordering::Relaxed); timer.stop.store(true, Ordering::Relaxed);
self.apply_timer_result(timer.handle.join());
} else {
self.status = "timer stopped".to_string();
} }
self.status = "timer stopped".to_string();
} }
fn reap_timer(&mut self) { fn reap_timer(&mut self) {
@@ -926,11 +1362,27 @@ impl App {
.is_some_and(|timer| timer.handle.is_finished()) .is_some_and(|timer| timer.handle.is_finished())
{ {
let timer = self.timer.take().expect("timer exists"); let timer = self.timer.take().expect("timer exists");
match timer.handle.join() { self.apply_timer_result(timer.handle.join());
Ok(Ok(())) => self.status = "timer finished".to_string(), }
Ok(Err(err)) => self.status = format!("timer failed: {err}"), }
Err(_) => self.status = "timer failed: thread panicked".to_string(),
fn apply_timer_result(&mut self, result: thread::Result<Result<TimerReport>>) {
match result {
Ok(Ok(report)) => {
if let Err(err) = append_history(
&history_path(&self.path),
report.to_history("finished", None),
) {
self.status = format!("history failed: {err}");
} else {
self.status = "timer finished".to_string();
}
} }
Ok(Err(err)) if err.to_string() == "timer stopped" => {
self.status = "timer stopped".to_string()
}
Ok(Err(err)) => self.status = format!("timer failed: {err}"),
Err(_) => self.status = "timer failed: thread panicked".to_string(),
} }
} }
@@ -938,9 +1390,13 @@ impl App {
let Some(timer) = &self.timer else { let Some(timer) = &self.timer else {
return (Duration::ZERO, Duration::ZERO, 0.0); return (Duration::ZERO, Duration::ZERO, 0.0);
}; };
let elapsed = timer.started.elapsed().min(timer.duration); let remaining = Duration::from_millis(timer.remaining_ms.load(Ordering::Relaxed));
let remaining = timer.duration.saturating_sub(elapsed); let elapsed = timer.duration.saturating_sub(remaining).min(timer.duration);
let progress = elapsed.as_secs_f64() / timer.duration.as_secs_f64(); let progress = if timer.duration.is_zero() {
0.0
} else {
elapsed.as_secs_f64() / timer.duration.as_secs_f64()
};
(elapsed, remaining, progress.clamp(0.0, 1.0)) (elapsed, remaining, progress.clamp(0.0, 1.0))
} }
@@ -1005,6 +1461,14 @@ impl App {
.unwrap_or(0); .unwrap_or(0);
*current = options[(index + 1) % options.len()].to_string(); *current = options[(index + 1) % options.len()].to_string();
} }
fn visible_fields(&self) -> Vec<&'static Field> {
visible_fields(&self.config)
}
fn focused_field(&self) -> Option<Field> {
self.visible_fields().get(self.focus).map(|field| **field)
}
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy)]
@@ -1024,7 +1488,7 @@ enum FieldKind {
Semi, Semi,
} }
#[derive(Clone, Copy)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FieldKey { enum FieldKey {
Duration, Duration,
WorkspaceEnabled, WorkspaceEnabled,
@@ -1127,6 +1591,33 @@ const FIELDS: &[Field] = &[
}, },
]; ];
fn visible_fields(config: &Config) -> Vec<&'static Field> {
FIELDS
.iter()
.filter(|field| field_visible(config, field.key))
.collect()
}
fn field_visible(config: &Config, key: FieldKey) -> bool {
match key {
FieldKey::WorkspaceBackend | FieldKey::WorkspaceNumber => config.actions.workspace.enabled,
FieldKey::MediaAction => config.actions.media.enabled,
FieldKey::BrightnessValue => config.actions.brightness.enabled,
FieldKey::LockCommand => config.actions.lock.enabled,
FieldKey::KillProcesses => config.actions.kill.enabled,
FieldKey::CustomCommands => config.actions.custom.enabled,
FieldKey::Duration
| FieldKey::WorkspaceEnabled
| FieldKey::MediaEnabled
| FieldKey::BrightnessEnabled
| FieldKey::MuteEnabled
| FieldKey::LockEnabled
| FieldKey::KillEnabled
| FieldKey::PowerMode
| FieldKey::CustomEnabled => true,
}
}
fn on_off(value: bool) -> String { fn on_off(value: bool) -> String {
if value { "on" } else { "off" }.to_string() if value { "on" } else { "off" }.to_string()
} }
@@ -1179,6 +1670,37 @@ fn format_gauge_label(progress: f64) -> String {
format!("{:>3}%", (progress * 100.0).round() as u16) format!("{:>3}%", (progress * 100.0).round() as u16)
} }
fn ascii_time(value: &str) -> Vec<String> {
let mut rows = vec![String::new(); 5];
for ch in value.chars() {
let glyph = ascii_glyph(ch);
for (row, part) in rows.iter_mut().zip(glyph) {
if !row.is_empty() {
row.push(' ');
}
row.push_str(part);
}
}
rows
}
fn ascii_glyph(ch: char) -> [&'static str; 5] {
match ch {
'0' => [" ### ", "# #", "# #", "# #", " ### "],
'1' => [" # ", " ## ", " # ", " # ", " ### "],
'2' => [" ### ", "# #", " # ", " # ", "#####"],
'3' => ["#### ", " #", " ### ", " #", "#### "],
'4' => ["# #", "# #", "#####", " #", " #"],
'5' => ["#####", "# ", "#### ", " #", "#### "],
'6' => [" ### ", "# ", "#### ", "# #", " ### "],
'7' => ["#####", " #", " # ", " # ", " # "],
'8' => [" ### ", "# #", " ### ", "# #", " ### "],
'9' => [" ### ", "# #", " ####", " #", " ### "],
':' => [" ", " # ", " ", " # ", " "],
_ => [" ", " ", " ", " ", " "],
}
}
fn wrap_command(command: &str, width: usize) -> Vec<String> { fn wrap_command(command: &str, width: usize) -> Vec<String> {
let width = width.max(8); let width = width.max(8);
let mut lines = Vec::new(); let mut lines = Vec::new();
@@ -1280,7 +1802,16 @@ mod tests {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("1ns"); let config = quiet_config("1ns");
run_timer(&config, &stop, RunMode::Tui, None).unwrap(); run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Tui,
TimerOptions::default(),
None,
)
.unwrap();
} }
#[test] #[test]
@@ -1289,7 +1820,16 @@ mod tests {
let config = quiet_config("10ms"); let config = quiet_config("10ms");
let mut output = Vec::new(); let mut output = Vec::new();
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap(); run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli,
TimerOptions::default(),
Some(&mut output),
)
.unwrap();
let text = String::from_utf8(output).unwrap(); let text = String::from_utf8(output).unwrap();
assert!(text.contains("timer started: 10ms")); assert!(text.contains("timer started: 10ms"));
@@ -1297,6 +1837,50 @@ mod tests {
assert!(text.contains("finished")); assert!(text.contains("finished"));
} }
#[test]
fn run_timer_attempts_all_actions_before_returning_failures() {
let stop = Arc::new(AtomicBool::new(false));
let mut config = quiet_config("1ns");
config.actions.custom.enabled = true;
config.actions.custom.commands = vec!["false".to_string(), "true".to_string()];
let mut output = Vec::new();
let error = run_timer(
&config,
&stop,
&Arc::new(AtomicBool::new(false)),
&Arc::new(AtomicU64::new(0)),
RunMode::Cli,
TimerOptions::default(),
Some(&mut output),
)
.unwrap_err();
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"));
}
#[test]
fn ensure_config_validates_loaded_config() {
let path = std::env::temp_dir().join(format!(
"gosleep-timer-invalid-{}-{}.yaml",
std::process::id(),
"power"
));
fs::write(
&path,
"duration: 25m\nactions:\n power:\n mode: suspend\n",
)
.unwrap();
let error = ensure_config(&path).unwrap_err();
fs::remove_file(path).ok();
assert!(error.to_string().contains("invalid power mode"));
}
#[test] #[test]
fn timer_header_text_compacts_for_narrow_widths() { fn timer_header_text_compacts_for_narrow_widths() {
let remaining = Duration::from_secs(65); let remaining = Duration::from_secs(65);
@@ -1323,13 +1907,62 @@ mod tests {
assert_eq!(format_gauge_label(1.0), "100%"); assert_eq!(format_gauge_label(1.0), "100%");
} }
#[test]
fn visible_fields_hide_disabled_action_settings() {
let mut config = quiet_config("25m");
let keys = visible_field_keys(&config);
assert!(keys.contains(&FieldKey::Duration));
assert!(keys.contains(&FieldKey::MediaEnabled));
assert!(!keys.contains(&FieldKey::MediaAction));
assert!(!keys.contains(&FieldKey::WorkspaceBackend));
assert!(!keys.contains(&FieldKey::WorkspaceNumber));
assert!(!keys.contains(&FieldKey::BrightnessValue));
assert!(!keys.contains(&FieldKey::LockCommand));
assert!(!keys.contains(&FieldKey::KillProcesses));
assert!(!keys.contains(&FieldKey::CustomCommands));
config.actions.media.enabled = true;
config.actions.workspace.enabled = true;
config.actions.brightness.enabled = true;
config.actions.lock.enabled = true;
config.actions.kill.enabled = true;
config.actions.custom.enabled = true;
let keys = visible_field_keys(&config);
assert!(keys.contains(&FieldKey::MediaAction));
assert!(keys.contains(&FieldKey::WorkspaceBackend));
assert!(keys.contains(&FieldKey::WorkspaceNumber));
assert!(keys.contains(&FieldKey::BrightnessValue));
assert!(keys.contains(&FieldKey::LockCommand));
assert!(keys.contains(&FieldKey::KillProcesses));
assert!(keys.contains(&FieldKey::CustomCommands));
}
fn visible_field_keys(config: &Config) -> Vec<FieldKey> {
visible_fields(config)
.into_iter()
.map(|field| field.key)
.collect()
}
fn dummy_timer_state() -> TimerState { fn dummy_timer_state() -> TimerState {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn(|| Ok(())); let handle = thread::spawn(|| {
Ok(TimerReport {
started_at: 0,
duration: Duration::from_secs(1),
actions: Vec::new(),
failures: Vec::new(),
dry_run: false,
no_actions: false,
})
});
TimerState { TimerState {
started: Instant::now(),
duration: Duration::from_secs(1), duration: Duration::from_secs(1),
stop, stop,
pause: Arc::new(AtomicBool::new(false)),
remaining_ms: Arc::new(AtomicU64::new(1000)),
handle, handle,
} }
} }