Durations are now parsed and displayed with possible hour, minute and second markers.

This commit is contained in:
Filipe Rodrigues 2025-09-11 23:53:48 +01:00
parent cac7059747
commit 310bdab36f
Signed by: zenithsiz
SSH Key Fingerprint: SHA256:Mb5ppb3Sh7IarBO/sBTXLHbYEOz37hJAlslLQPPAPaU
10 changed files with 106 additions and 27 deletions

1
Cargo.lock generated
View File

@ -4865,6 +4865,7 @@ dependencies = [
"pin-project",
"serde",
"serde_json",
"serde_with",
"tokio",
"tracing",
]

View File

@ -4,6 +4,6 @@ display = "cross-screen"
[panels.shader]
type = "fade"
playlists = ["example1"]
duration = 5.0
fade_duration = 1.0
duration = "5s"
fade_duration = "1s"
fade = "basic"

View File

@ -4,8 +4,8 @@ display = "multiple"
[panels.shader]
type = "fade"
playlists = ["example1"]
duration = 5.0
fade_duration = 1.0
duration = "1h1m1.1s"
fade_duration = "1m1.1s"
fade = "out"
strength = 1.0
@ -16,7 +16,7 @@ display = "quarter"
[panels.shader]
type = "fade"
playlists = ["example1"]
duration = 5.0
fade_duration = 1.0
duration = "5s"
fade_duration = "1s"
fade = "white"
strength = 1.0

View File

@ -4,6 +4,6 @@ display = "full"
[panels.shader]
type = "fade"
playlists = ["example1"]
duration = 5.0
fade_duration = 1.0
duration = "5s"
fade_duration = "1s"
fade = "basic"

View File

@ -13,6 +13,7 @@ image = { workspace = true }
pin-project = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_with = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }

View File

@ -0,0 +1,84 @@
//! Duration display
// Imports
use {
crate::AppError,
app_error::Context,
core::{fmt, str::FromStr, time::Duration},
};
/// Duration
#[derive(Clone, Copy, Debug)]
#[derive(serde_with::SerializeDisplay)]
#[derive(serde_with::DeserializeFromStr)]
pub struct DurationDisplay(pub Duration);
impl FromStr for DurationDisplay {
type Err = AppError;
fn from_str(mut s: &str) -> Result<Self, Self::Err> {
let mut time = Duration::ZERO;
// Remove any hours from the time
if let Some((hours, rest)) = s.split_once('h') {
let hours = hours
.parse::<u64>()
.with_context(|| format!("Expected an integer before `h`, found {hours:?}"))?;
time += Duration::from_hours(hours);
s = rest;
}
// Remove any minutes from the time
if let Some((mins, rest)) = s.split_once('m') {
let mins = mins
.parse::<u64>()
.with_context(|| format!("Expected an integer before `m`, found {mins:?}"))?;
time += Duration::from_mins(mins);
s = rest;
}
// Then remove any trailing `s` the user might have added
let secs = s.strip_suffix('s').unwrap_or(s);
// And parse the rest as seconds (may be empty)
let secs = match secs {
"" => 0.0,
_ => secs
.parse::<f64>()
.with_context(|| format!("Expected a number of seconds, found {secs:?}"))?,
};
time += Duration::from_secs_f64(secs);
Ok(Self(time))
}
}
impl fmt::Display for DurationDisplay {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let hours = self.0.as_secs() / 3600;
if hours != 0 {
write!(f, "{hours}h")?;
}
let mins = (self.0 - Duration::from_hours(hours)).as_secs() / 60;
if mins != 0 {
write!(f, "{mins}m")?;
}
let secs = (self.0 - Duration::from_hours(hours) - Duration::from_mins(mins)).as_secs_f64();
if secs != 0.0 || (hours == 0 && mins == 0) {
// TODO: Find some other way of having variable precision (up to millisecond)
let mut secs = format!("{secs:.3}");
while secs.ends_with('0') {
_ = secs.pop();
}
if secs.ends_with('.') {
_ = secs.pop();
}
secs.push('s');
f.pad(&secs)?;
}
Ok(())
}
}

View File

@ -24,6 +24,7 @@
)]
// Modules
pub mod duration_display;
pub mod loadable;
mod rect;
mod tuple_collect_res;
@ -32,6 +33,7 @@ pub mod walk_dir;
// Exports
pub use {
duration_display::DurationDisplay,
loadable::Loadable,
rect::Rect,
tuple_collect_res::{TupleCollectRes1, TupleCollectRes2, TupleCollectRes3, TupleCollectRes4, TupleCollectRes5},

View File

@ -76,8 +76,8 @@ impl Profiles {
ser::ProfilePanelShader::Fade(shader) =>
ProfilePanelShader::Fade(ProfilePanelFadeShader {
playlists: shader.playlists.into_iter().map(PlaylistName::from).collect(),
duration: shader.duration,
fade_duration: shader.fade_duration,
duration: shader.duration.0,
fade_duration: shader.fade_duration.0,
inner: match shader.inner {
ser::ProfilePanelFadeShaderInner::Basic =>
ProfilePanelFadeShaderInner::Basic,

View File

@ -1,12 +1,7 @@
//! Serialized profile
// TODO: Allow deserializing durations from strings such as "500ms", "5s", "1m2s", etc.
// Imports
use {
core::time::Duration,
serde_with::{DurationSecondsWithFrac, serde_as},
};
use zsw_util::DurationDisplay;
/// Profile
#[derive(Debug)]
@ -45,16 +40,11 @@ pub struct ProfilePanelNoneShader {
/// Panel shader fade
#[derive(Debug)]
#[serde_as]
#[derive(serde::Serialize, serde::Deserialize)]
pub struct ProfilePanelFadeShader {
pub playlists: Vec<String>,
#[serde_as(as = "DurationSecondsWithFrac<f64>")]
pub duration: Duration,
#[serde_as(as = "DurationSecondsWithFrac<f64>")]
pub fade_duration: Duration,
pub playlists: Vec<String>,
pub duration: DurationDisplay,
pub fade_duration: DurationDisplay,
/// Inner
#[serde(flatten)]

View File

@ -10,12 +10,12 @@ mod panels;
// Imports
use {
crate::{AppEvent, display::Displays, panel::Panel},
core::{ops::RangeInclusive, time::Duration},
core::{ops::RangeInclusive, str::FromStr, time::Duration},
egui::{Widget, mutex::Mutex},
std::{path::Path, sync::Arc},
strum::IntoEnumIterator,
winit::{dpi::LogicalPosition, event_loop::EventLoopProxy},
zsw_util::{AppError, Rect},
zsw_util::{AppError, DurationDisplay, Rect},
zsw_wgpu::Wgpu,
};
@ -124,7 +124,8 @@ fn draw_duration(ui: &mut egui::Ui, duration: &mut Duration, range: RangeInclusi
let start = range.start().as_secs_f32();
let end = range.end().as_secs_f32();
egui::Slider::new(&mut secs, start..=end)
.suffix("s")
.custom_formatter(|secs, _| DurationDisplay(Duration::from_secs_f64(secs)).to_string())
.custom_parser(|s| DurationDisplay::from_str(s).ok().map(|d| d.0.as_secs_f64()))
.clamping(egui::SliderClamping::Edits)
.ui(ui);
*duration = Duration::from_secs_f32(secs);