From 189ae75a01b113549fa238d81027dedc7cdc201c Mon Sep 17 00:00:00 2001 From: mr-forust Date: Fri, 3 Jul 2026 23:39:47 +0200 Subject: [PATCH] chore: release v1.1.2 --- CHANGELOG.md | 8 ++ Cargo.lock | 2 +- Cargo.toml | 2 +- VERSION | 2 +- src/main.rs | 210 +++++++++++++++++++++++++++++++++++++++++++++------ 5 files changed, 198 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad786fb..4842df6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. +## [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 ### 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 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 diff --git a/Cargo.lock b/Cargo.lock index 79cd212..7fb2415 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,7 +298,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "gosleep-timer" -version = "1.1.1" +version = "1.1.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 85e5374..4097264 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "gosleep-timer" -version = "1.1.1" +version = "1.1.2" edition = "2024" rust-version = "1.85" description = "Terminal sleep timer for Linux desktop actions, with YAML config and a Rust TUI" diff --git a/VERSION b/VERSION index 524cb55..45a1b3f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.1 +1.1.2 diff --git a/src/main.rs b/src/main.rs index bce2242..78254ed 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,7 +2,7 @@ use std::{ fs, io::{self, Write}, path::PathBuf, - process::Command, + process::{Command, Stdio}, sync::{ Arc, atomic::{AtomicBool, Ordering}, @@ -199,7 +199,13 @@ fn main() -> Result<()> { if let Some(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) => { for command in preview_commands(&config) { @@ -467,42 +473,86 @@ fn preview_commands(config: &Config) -> Vec { .collect() } -fn run_timer(config: &Config, stop: &Arc) -> 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, + mode: RunMode, + mut output: Option<&mut dyn Write>, +) -> Result<()> { validate_config(config)?; let duration = parse_duration(&config.duration)?; 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 { if stop.load(Ordering::Relaxed) { - println!("\nstopped"); + if let Some(output) = output.as_deref_mut() { + write_timer_line(output, "stopped"); + } bail!("timer stopped"); } let elapsed = started.elapsed(); if elapsed >= duration { break; } - print!("\rremaining: {}", format_duration(duration - elapsed)); - io::stdout().flush().ok(); + if let Some(output) = output.as_deref_mut() { + write_timer_progress( + output, + &format!("remaining: {}", format_duration(duration - elapsed)), + ); + } 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) { - println!("run: {}", command.display()); - run_command(&command).with_context(|| format!("{} failed", command.label))?; + if let Some(output) = output.as_deref_mut() { + write_timer_line(output, &format!("run: {}", command.display())); + } + run_command(&command, mode).with_context(|| format!("{} failed", command.label))?; } 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 { - 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 { let Some(program) = command.args.first() else { 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() { bail!("command exited with {status}"); @@ -601,7 +651,7 @@ impl App { fn draw(&mut self, frame: &mut Frame) { 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); return; } @@ -609,7 +659,7 @@ impl App { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(4), + Constraint::Length(5), Constraint::Min(5), Constraint::Length(1), ]) @@ -633,11 +683,6 @@ impl App { fn draw_header(&self, frame: &mut Frame, area: Rect) { 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() .direction(Direction::Vertical) .constraints([ @@ -645,6 +690,7 @@ impl App { Constraint::Length(1), Constraint::Length(1), Constraint::Length(1), + Constraint::Length(1), ]) .split(area); frame.render_widget( @@ -664,12 +710,21 @@ impl App { Paragraph::new(self.status.clone()).style(Style::default().fg(Color::Yellow)), 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( Gauge::default() .gauge_style(Style::default().fg(Color::Magenta)) - .label(format!("{label} {:>3}%", (progress * 100.0).round() as u16)) + .label(format_gauge_label(progress)) .ratio(progress), - header[3], + header[4], ); } @@ -847,7 +902,7 @@ impl App { let stop = Arc::new(AtomicBool::new(false)); let thread_stop = Arc::clone(&stop); 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 { started: Instant::now(), 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 { let width = width.max(8); let mut lines = Vec::new(); @@ -1125,6 +1214,22 @@ fn split_list(value: &str, separator: char) -> Vec { mod tests { 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] fn parse_duration_accepts_go_style_values() { assert!(parse_duration("25m").is_ok()); @@ -1169,4 +1274,63 @@ mod tests { 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, + } + } }