Skip to content
Open
12 changes: 6 additions & 6 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion src/studio_app/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,12 @@ fn app_with_surfaces(surfaces: Vec<SceneObject>) -> StudioApp {
update_status: crate::dashboard::UpdateStatus::Idle,
diagnostics: studio_diagnostics::DiagnosticsView::new(),
page: Page::Studio,
settings: SettingsFile::default(),
// Pin English so UI text assertions do not follow the system language.
settings: {
let mut settings = SettingsFile::default();
settings.language = Some("en-US".into());
settings
},
synced_poll_interval_ms: SettingsFile::default().poll_interval_ms,
synced_taskbar_lock: false,
poll_interval_editor_generation: 0,
Expand Down
57 changes: 56 additions & 1 deletion src/theme_engine/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -485,9 +485,64 @@ fn templates_apply_numeric_character_formats() {
format_template("Used {claude.session.percentage:0.0}%", &context),
"Used 73.5%"
);
assert_eq!(format_template("{reset:duration_short}", &context), "4d");
assert_eq!(format_template("{reset:duration_short}", &context), "5d");
}

#[test]
fn duration_short_precision_scale_and_half_steps() {
let mut context = DataContext::default();
context.insert_string("i18n.day_suffix", "д");
context.insert_string("i18n.hour_suffix", "ч");
context.insert_string("i18n.minute_suffix", "м");
context.insert_string("i18n.second_suffix", "с");
context.insert_string("i18n.now", "сейчас");

let check = |secs: f64, expected: &str, ctx: &DataContext| {
let mut c = ctx.clone();
c.insert("val", secs);
assert_eq!(format_template("{val:duration_short}", &c), expected);
};

// Minutes under 1 hour (< 3600s)
check(3580.0, "59м", &context); // 59m 40s must clamp to 59m, never 60m
check(3300.0, "55м", &context);
check(2700.0, "45м", &context);
check(900.0, "15м", &context);
check(60.0, "1м", &context);

// Hours under 10 hours (1h .. 10h): 0.5 step rounding with dot
check(3600.0, "1ч", &context);
check(3900.0, "1ч", &context); // 1h 05m -> 1ч
check(5400.0, "1.5ч", &context); // 1h 30m -> 1.5ч
check(7800.0, "2ч", &context); // 2h 10m -> 2ч
check(9300.0, "2.5ч", &context); // 2h 35m -> 2.5ч
check(10500.0, "3ч", &context); // 2h 55m -> 3ч
check(34200.0, "9.5ч", &context); // 9h 30m -> 9.5ч

// Hours >= 10 hours: integer math rounding
check(36000.0, "10ч", &context);
check(43200.0, "12ч", &context);
check(66300.0, "18ч", &context); // 18h 25m -> 18ч
check(85500.0, "1д", &context); // 23h 45m -> rolls over to 1д

// Days < 10 days: 0.5 step rounding with dot
check(86400.0, "1д", &context);
check(129600.0, "1.5д", &context); // 1d 12h -> 1.5д
check(216000.0, "2.5д", &context); // 2d 12h -> 2.5д
check(532800.0, "6д", &context); // 6d 4h -> strictly 6д
check(565200.0, "6.5д", &context); // 6d 13h -> 6.5д
check(604800.0, "7д", &context); // 7d -> 7д

// Days >= 10 days: integer math rounding
check(892800.0, "10д", &context); // 10d 8h -> 10д
check(1036800.0, "12д", &context); // 12d -> 12д

// Seconds and now
check(45.0, "45с", &context);
check(0.0, "сейчас", &context);
}


#[test]
fn text_templates_allow_if_to_return_quoted_strings() {
let mut context = DataContext::default();
Expand Down
60 changes: 46 additions & 14 deletions src/theme_engine/theme_expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -605,26 +605,58 @@ pub(super) fn localized<'a>(context: &'a DataContext, name: &str, fallback: &'a
context.get_string(name).unwrap_or(fallback)
}

fn format_half_step(val: f64, suffix: &str) -> String {
let rounded = (val * 2.0).round() / 2.0;
if rounded.fract().abs() < 1e-4 {
format!("{:.0}{suffix}", rounded)
} else {
format!("{:.1}{suffix}", rounded)
}
}

pub(super) fn format_duration_short(seconds: u64, context: &DataContext) -> String {
let day_suffix = localized(context, "i18n.day_suffix", "d");
let hour_suffix = localized(context, "i18n.hour_suffix", "h");
let minute_suffix = localized(context, "i18n.minute_suffix", "m");
let second_suffix = localized(context, "i18n.second_suffix", "s");

if seconds >= 86_400 {
if seconds >= 864_000 {
let days = (seconds + 43_200) / 86_400;
format!("{days}{day_suffix}")
} else {
let days = seconds as f64 / 86_400.0;
format_half_step(days, day_suffix)
}
} else if seconds >= 3_600 {
if seconds >= 36_000 {
let hours = (seconds + 1800) / 3600;
if hours >= 24 {
format!("1{day_suffix}")
} else {
format!("{hours}{hour_suffix}")
}
} else {
let hours = seconds as f64 / 3600.0;
format_half_step(hours, hour_suffix)
}
} else if seconds >= 60 {
let minutes = ((seconds + 30) / 60).min(59);
format!("{minutes}{minute_suffix}")
} else if seconds > 0 {
format!("{seconds}{second_suffix}")
} else {
localized(context, "i18n.now", "now").to_string()
}
}

pub(super) fn format_value(value: f64, format: &str, context: &DataContext) -> String {
if let Some(value) = format_timestamp(value, format, context) {
return value;
}
if format.eq_ignore_ascii_case("duration_short") {
let seconds = value.max(0.0).round() as u64;
let days = seconds / 86_400;
let hours = seconds / 3_600;
let minutes = seconds / 60;
return if days > 0 {
format!("{days}{}", localized(context, "i18n.day_suffix", "d"))
} else if hours > 0 {
format!("{hours}{}", localized(context, "i18n.hour_suffix", "h"))
} else if minutes > 0 {
format!("{minutes}{}", localized(context, "i18n.minute_suffix", "m"))
} else if seconds > 0 {
format!("{seconds}{}", localized(context, "i18n.second_suffix", "s"))
} else {
localized(context, "i18n.now", "now").to_string()
};
return format_duration_short(seconds, context);
}
if format.eq_ignore_ascii_case("duration") {
let seconds = value.max(0.0).round() as u64;
Expand Down