chore: release v1.1.2

This commit is contained in:
2026-07-03 23:39:47 +02:00
parent c39fa10c9c
commit 003ede1290
5 changed files with 198 additions and 26 deletions
+8
View File
@@ -4,6 +4,13 @@ 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.1.2] - 2026-07-03
### Fixed
- Suppressed all background `stdout`/`stderr` writes while the Ratatui screen is active, including child command output from timer actions.
- Split `time left` out of the gauge label and reduced the gauge label to a compact percentage for narrow terminal safety.
## [1.1.1] - 2026-07-03 ## [1.1.1] - 2026-07-03
### Changed ### Changed
@@ -13,4 +20,5 @@ 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.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.1" version = "1.1.2"
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.1" version = "1.1.2"
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"
+1 -1
View File
@@ -1 +1 @@
1.1.1 1.1.2
+187 -23
View File
@@ -2,7 +2,7 @@ use std::{
fs, fs,
io::{self, Write}, io::{self, Write},
path::PathBuf, path::PathBuf,
process::Command, process::{Command, Stdio},
sync::{ sync::{
Arc, Arc,
atomic::{AtomicBool, Ordering}, atomic::{AtomicBool, Ordering},
@@ -199,7 +199,13 @@ fn main() -> Result<()> {
if let Some(duration) = duration { if let Some(duration) = duration {
config.duration = duration; config.duration = duration;
} }
run_timer(&config, &Arc::new(AtomicBool::new(false))) let mut stdout = io::stdout();
run_timer(
&config,
&Arc::new(AtomicBool::new(false)),
RunMode::Cli,
Some(&mut stdout),
)
} }
Some(Commands::Preview) => { Some(Commands::Preview) => {
for command in preview_commands(&config) { for command in preview_commands(&config) {
@@ -467,42 +473,86 @@ fn preview_commands(config: &Config) -> Vec<String> {
.collect() .collect()
} }
fn run_timer(config: &Config, stop: &Arc<AtomicBool>) -> Result<()> { #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum RunMode {
Cli,
Tui,
}
fn write_timer_line(output: &mut dyn Write, message: &str) {
writeln!(output, "{message}").ok();
}
fn write_timer_progress(output: &mut dyn Write, message: &str) {
write!(output, "\r{message}").ok();
output.flush().ok();
}
fn run_timer(
config: &Config,
stop: &Arc<AtomicBool>,
mode: RunMode,
mut output: Option<&mut dyn Write>,
) -> Result<()> {
validate_config(config)?; validate_config(config)?;
let duration = parse_duration(&config.duration)?; let duration = parse_duration(&config.duration)?;
let started = Instant::now(); let started = Instant::now();
println!("timer started: {}", config.duration); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, &format!("timer started: {}", config.duration));
}
loop { loop {
if stop.load(Ordering::Relaxed) { if stop.load(Ordering::Relaxed) {
println!("\nstopped"); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "stopped");
}
bail!("timer stopped"); bail!("timer stopped");
} }
let elapsed = started.elapsed(); let elapsed = started.elapsed();
if elapsed >= duration { if elapsed >= duration {
break; break;
} }
print!("\rremaining: {}", format_duration(duration - elapsed)); if let Some(output) = output.as_deref_mut() {
io::stdout().flush().ok(); write_timer_progress(
output,
&format!("remaining: {}", format_duration(duration - elapsed)),
);
}
thread::sleep(Duration::from_millis(250)); thread::sleep(Duration::from_millis(250));
} }
println!("\nfinished"); if let Some(output) = output.as_deref_mut() {
write_timer_line(output, "finished");
}
for command in build_action_commands(config) { for command in build_action_commands(config) {
println!("run: {}", command.display()); if let Some(output) = output.as_deref_mut() {
run_command(&command).with_context(|| format!("{} failed", command.label))?; write_timer_line(output, &format!("run: {}", command.display()));
}
run_command(&command, mode).with_context(|| format!("{} failed", command.label))?;
} }
Ok(()) Ok(())
} }
fn run_command(command: &ActionCommand) -> Result<()> { fn prepare_command_stdio(process: &mut Command, mode: RunMode) {
if mode == RunMode::Tui {
process.stdout(Stdio::null()).stderr(Stdio::null());
}
}
fn run_command(command: &ActionCommand, mode: RunMode) -> Result<()> {
let status = if let Some(shell) = &command.shell { let status = if let Some(shell) = &command.shell {
Command::new("sh").arg("-c").arg(shell).status()? let mut process = Command::new("sh");
process.arg("-c").arg(shell);
prepare_command_stdio(&mut process, mode);
process.status()?
} else { } else {
let Some(program) = command.args.first() else { let Some(program) = command.args.first() else {
return Ok(()); return Ok(());
}; };
Command::new(program).args(&command.args[1..]).status()? let mut process = Command::new(program);
process.args(&command.args[1..]);
prepare_command_stdio(&mut process, mode);
process.status()?
}; };
if !status.success() { if !status.success() {
bail!("command exited with {status}"); bail!("command exited with {status}");
@@ -601,7 +651,7 @@ impl App {
fn draw(&mut self, frame: &mut Frame) { fn draw(&mut self, frame: &mut Frame) {
let area = frame.area(); let area = frame.area();
if area.width < 36 || area.height < 10 { if area.width < 36 || area.height < 11 {
frame.render_widget(Paragraph::new("terminal too small"), area); frame.render_widget(Paragraph::new("terminal too small"), area);
return; return;
} }
@@ -609,7 +659,7 @@ impl App {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(4), Constraint::Length(5),
Constraint::Min(5), Constraint::Min(5),
Constraint::Length(1), Constraint::Length(1),
]) ])
@@ -633,11 +683,6 @@ impl App {
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 label = if self.timer.is_some() {
format!("time left: {}", format_duration(remaining))
} else {
"timer not started".to_string()
};
let header = Layout::default() let header = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
@@ -645,6 +690,7 @@ impl App {
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1), Constraint::Length(1),
Constraint::Length(1),
]) ])
.split(area); .split(area);
frame.render_widget( frame.render_widget(
@@ -664,12 +710,21 @@ impl App {
Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)), Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)),
header[2], header[2],
); );
frame.render_widget(
Paragraph::new(format_time_left_text(
self.timer.as_ref(),
remaining,
header[3].width,
))
.style(Style::default().fg(Color::White)),
header[3],
);
frame.render_widget( frame.render_widget(
Gauge::default() Gauge::default()
.gauge_style(Style::default().fg(Color::Magenta)) .gauge_style(Style::default().fg(Color::Magenta))
.label(format!("{label} {:>3}%", (progress * 100.0).round() as u16)) .label(format_gauge_label(progress))
.ratio(progress), .ratio(progress),
header[3], header[4],
); );
} }
@@ -847,7 +902,7 @@ impl App {
let stop = Arc::new(AtomicBool::new(false)); let stop = Arc::new(AtomicBool::new(false));
let thread_stop = Arc::clone(&stop); let thread_stop = Arc::clone(&stop);
let config = self.config.clone(); let config = self.config.clone();
let handle = thread::spawn(move || run_timer(&config, &thread_stop)); let handle = thread::spawn(move || run_timer(&config, &thread_stop, RunMode::Tui, None));
self.timer = Some(TimerState { self.timer = Some(TimerState {
started: Instant::now(), started: Instant::now(),
duration, duration,
@@ -1090,6 +1145,40 @@ fn format_duration(duration: Duration) -> String {
} }
} }
fn format_compact_duration(duration: Duration) -> String {
let total_seconds = duration.as_secs();
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
if hours > 0 {
format!("{hours}:{minutes:02}:{seconds:02}")
} else {
format!("{minutes:02}:{seconds:02}")
}
}
fn format_time_left_text(timer: Option<&TimerState>, remaining: Duration, width: u16) -> String {
if timer.is_none() {
return "time left: --".to_string();
}
let full = format!("time left: {}", format_duration(remaining));
if full.len() <= width as usize {
return full;
}
let compact = format!("left: {}", format_compact_duration(remaining));
if compact.len() <= width as usize {
return compact;
}
format_compact_duration(remaining)
}
fn format_gauge_label(progress: f64) -> String {
format!("{:>3}%", (progress * 100.0).round() as u16)
}
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();
@@ -1125,6 +1214,22 @@ fn split_list(value: &str, separator: char) -> Vec<String> {
mod tests { mod tests {
use super::*; use super::*;
fn quiet_config(duration: &str) -> Config {
let mut config = Config {
duration: duration.to_string(),
..Config::default()
};
config.actions.workspace.enabled = false;
config.actions.media.enabled = false;
config.actions.brightness.enabled = false;
config.actions.mute.enabled = false;
config.actions.lock.enabled = false;
config.actions.kill.enabled = false;
config.actions.power.mode = "none".to_string();
config.actions.custom.enabled = false;
config
}
#[test] #[test]
fn parse_duration_accepts_go_style_values() { fn parse_duration_accepts_go_style_values() {
assert!(parse_duration("25m").is_ok()); assert!(parse_duration("25m").is_ok());
@@ -1169,4 +1274,63 @@ mod tests {
vec!["firefox", "telegram-desktop"] vec!["firefox", "telegram-desktop"]
); );
} }
#[test]
fn run_timer_quiet_mode_writes_nothing() {
let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("1ns");
run_timer(&config, &stop, RunMode::Tui, None).unwrap();
}
#[test]
fn run_timer_cli_mode_writes_status_lines() {
let stop = Arc::new(AtomicBool::new(false));
let config = quiet_config("10ms");
let mut output = Vec::new();
run_timer(&config, &stop, RunMode::Cli, Some(&mut output)).unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("timer started: 10ms"));
assert!(text.contains("remaining:"));
assert!(text.contains("finished"));
}
#[test]
fn timer_header_text_compacts_for_narrow_widths() {
let remaining = Duration::from_secs(65);
assert_eq!(format_time_left_text(None, remaining, 20), "time left: --");
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 40),
"time left: 1m5s"
);
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 12),
"left: 01:05"
);
assert_eq!(
format_time_left_text(Some(&dummy_timer_state()), remaining, 5),
"01:05"
);
}
#[test]
fn gauge_label_is_short_percent_only() {
assert_eq!(format_gauge_label(0.0), " 0%");
assert_eq!(format_gauge_label(0.58), " 58%");
assert_eq!(format_gauge_label(1.0), "100%");
}
fn dummy_timer_state() -> TimerState {
let stop = Arc::new(AtomicBool::new(false));
let handle = thread::spawn(|| Ok(()));
TimerState {
started: Instant::now(),
duration: Duration::from_secs(1),
stop,
handle,
}
}
} }