Export: the time left counts down between batches; the status bar shows it once a second, in place (#3816)

* Export: the time left counts down between batches; the status bar shows it once a second, in place

An export finishes its frames in batches of up to 16. The estimate measured the speed to the
latest reading, so between batches the speed sagged and the time left grew, then fell when the
next batch landed: about ±13 % on a heavy 4K60 export, enough to flip between 1:04:50 and 56:11.
It now measures the speed between batch arrivals and counts down from the last one; a batch three
intervals late falls back to the speed up to now, so a stall still makes the estimate grow.

The status bar redrew its text every frame, right-aligned against the bar, in Inter's proportional
digits, so "Exporting… 46%" moved sideways with every change of the time left. The text now
changes at most once a second, its digits sit in cells of equal width, and its room only grows
while the job runs (from the start it fits 100 % and a time under an hour).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Export: no "0 s left" while the last batch is late; the status text has room for any time left from the start

The countdown between batches reached zero when the last batch took longer than the speed said,
and showed "0 s left" until it was three intervals late; it now gives way to the speed up to now as
soon as it runs out. The status bar's room was for a time under an hour, so an export's first
estimate over an hour still moved the text once; it is now for 99:59:59 from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Export: the queue's and the Progress panel's job text change once a second, with digits of one width

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* ui-egui: tabular_label is read by screen readers as a label

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrik Lermon
2026-11-01 21:14:46 +01:00
committed by GitHub
parent 58f73aecf2
commit 04476bf9cb
9 changed files with 368 additions and 32 deletions
+90 -3
View File
@@ -3,7 +3,10 @@
//! A job's `done` counter goes up in batches, and the speed of an export changes with what is on the
//! timeline (an intro, a banner, plain camera footage), so the estimate uses the speed of a trailing
//! window, not the average since the start, and it stays quiet until a second of readings shows
//! progress. A stall shows as a falling speed, so the estimate grows instead of freezing.
//! progress. The speed is measured between the times the count moved (a batch landed), and the
//! estimate counts down from the last of them, so it does not swing up while a batch is being made
//! and down when it lands. A batch that is well overdue shows as a falling speed, so a stalled job's
//! estimate grows instead of freezing.
use std::collections::VecDeque;
use std::time::Duration;
@@ -16,6 +19,8 @@ const WINDOW: Duration = Duration::from_secs(15);
const MIN_GAP: Duration = Duration::from_millis(100);
/// The estimate needs at least this much time between its first and last reading.
const MIN_SPAN: Duration = Duration::from_secs(1);
/// A batch this many times the mean interval between batches late means the job slowed down.
const LATE: u32 = 3;
/// The longest estimate given: a job that all but stopped would otherwise promise millennia.
const MAX_ETA: Duration = Duration::from_secs(100 * 3600);
@@ -59,13 +64,38 @@ impl Pace {
if span < MIN_SPAN || d1 <= d0 {
return None;
}
let per_second = (d1 - d0) as f64 / span.as_secs_f64();
let left = remaining as f64 / per_second;
// on time: the speed between batches, counting down since the last one landed
let countdown = self
.batches()
.filter(|(_, gap, since)| *since <= gap.saturating_mul(LATE))
.map(|(per_second, _, since)| remaining as f64 / per_second - since.as_secs_f64());
let left = match countdown {
Some(left) if left > 0.0 => left,
// a batch is overdue, the countdown ran out with work left, or the count moves too
// seldom to tell: the speed up to now
_ => remaining as f64 / ((d1 - d0) as f64 / span.as_secs_f64()),
};
// `try_from_secs_f64` refuses what a `Duration` cannot hold; that is "more than the cap" here
(left >= 0.0).then(|| Duration::try_from_secs_f64(left).map_or(MAX_ETA, |d| d.min(MAX_ETA)))
}
}
impl Pace {
/// The speed between the first and the last time the count moved in the window, the mean time
/// between those moves, and the time since the last one; `None` before two moves a second apart.
fn batches(&self) -> Option<(f64, Duration, Duration)> {
let now = self.readings.back()?.0;
let mut moves = self.readings.iter().zip(self.readings.iter().skip(1)).filter(|(a, b)| b.1 != a.1).map(|(_, b)| *b);
let (ta, da) = moves.next()?;
let (n, (tb, db)) = moves.fold((0u32, (ta, da)), |(n, _), m| (n.saturating_add(1), m));
let span = tb.saturating_duration_since(ta);
if span < MIN_SPAN || db <= da {
return None;
}
Some(((db - da) as f64 / span.as_secs_f64(), span.checked_div(n)?, now.saturating_duration_since(tb)))
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -149,6 +179,63 @@ mod tests {
assert!(after > before * 1.5, "{before} -> {after}");
}
/// A job that finishes 16 units every 2 s (8 a second, as an export's batches of frames), read
/// every 0.1 s for `secs`; the estimate at each reading, with the time of the reading.
fn batched(secs: f64, total: u64) -> Vec<(f64, Option<f64>)> {
let t0 = Instant::now();
let mut p = Pace::default();
(0..=(secs * 10.0) as u64)
.map(|i| {
let s = i as f64 * 0.1;
let done = (i / 20) * 16;
p.observe(at(t0, s), done);
(s, p.eta(total.saturating_sub(done)).map(|d| d.as_secs_f64()))
})
.collect()
}
#[test]
fn the_estimate_counts_down_between_batches_instead_of_jumping() {
// 20 000 units at 8 a second end at 2500 s: once the window is full, each estimate plus the
// time it was made says so, at every reading, not only when a batch lands. Measured to the
// end of the window it swung by a batch (16 of ~120 units), ~13 %.
let ends: Vec<f64> = batched(40.0, 20_000).into_iter().filter(|r| r.0 >= 20.0).map(|(s, e)| s + e.unwrap_or(f64::NAN)).collect();
let (lo, hi) = ends.iter().fold((f64::MAX, f64::MIN), |(lo, hi), e| (lo.min(*e), hi.max(*e)));
assert!(hi - lo < 5.0 && (lo - 2500.0).abs() < 5.0, "the end moved between {lo} and {hi}");
}
#[test]
fn the_last_batch_never_shows_nothing_left_before_it_lands() {
// batches of 16 every 2 s; at 20 s 160 of 176 are done, and the last batch is a little late
let t0 = Instant::now();
let mut p = Pace::default();
for i in 0..=200u64 {
p.observe(at(t0, i as f64 * 0.1), (i / 20) * 16);
}
for i in 201..=260u64 {
p.observe(at(t0, i as f64 * 0.1), 160);
let left = p.eta(16).map(|d| d.as_secs_f64());
// the countdown runs out at 22 s; until the batch was overdue at 26 s it said 0
assert!(left.is_some_and(|l| l > 0.0), "{:.1} s in: {left:?} left of 16 units", i as f64 * 0.1);
}
}
#[test]
fn a_late_batch_makes_the_estimate_grow() {
// batches every 2 s for 20 s, then 8 s with nothing done (four batch intervals)
let t0 = Instant::now();
let mut p = Pace::default();
for i in 0..=200u64 {
p.observe(at(t0, i as f64 * 0.1), (i / 20) * 16);
}
let before = p.eta(1000 - 160).unwrap();
for i in 201..=280u64 {
p.observe(at(t0, i as f64 * 0.1), 160);
}
let after = p.eta(1000 - 160).unwrap();
assert!(after > before, "{before:?} -> {after:?}");
}
#[test]
fn a_count_that_goes_back_starts_over() {
// a second pass, or a job reusing its counters: the old history must not count
+2
View File
@@ -120,6 +120,8 @@ impl FilmcraftApp {
stale_clipboard_media: None,
gpu: None,
watched_render: None,
job_text: None,
steady_texts: Default::default(),
applied_prefs: None,
applied_ui_scale: None,
workspace_restored: false,
+70 -12
View File
@@ -1,4 +1,4 @@
use super::{FilmcraftApp, Tick, Tokens, crash, header, i18n, panels, state, updates_ui};
use super::{FilmcraftApp, Tick, Tokens, crash, header, i18n, panels, state, updates_ui, widgets};
impl FilmcraftApp {
// ---------------------------------------------------------------- frame
@@ -154,32 +154,43 @@ impl FilmcraftApp {
use std::sync::atomic::Ordering;
let job = self.running_job()?;
let f = job.progress.fraction().clamp(0.0, 1.0);
let left = job.progress.eta().map(panels::left_text).unwrap_or_default();
// asked every frame though shown once a second: each call is also a reading of the job's speed
let eta = job.progress.eta();
let cancel = egui::Rect::from_center_size(egui::pos2(sb.max.x - 14.0, sb.center().y), egui::vec2(14.0, 14.0));
let bar = egui::Rect::from_min_size(egui::pos2(cancel.min.x - 128.0, sb.center().y - 3.0), egui::vec2(120.0, 6.0));
let p = ui.painter();
p.rect_filled(bar, 3.0, t.separator);
p.rect_filled(egui::Rect::from_min_size(bar.min, egui::vec2(bar.width() * f, bar.height())), 3.0, t.accent);
let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() };
let progress = p.text(
egui::pos2(bar.min.x - 8.0, sb.center().y),
egui::Align2::RIGHT_CENTER,
format!("{verb}… {:.0}%{left}", f * 100.0),
Tokens::ui(11.0),
t.text_dim,
);
let (now, font) = (self.now(), Tokens::ui(11.0));
let shown = match self.job_text.take().filter(|s| s.job == job.id) {
Some(s) if (s.at..s.at + panels::JobText::EVERY).contains(&now) => s,
old => {
let verb = if job.label.starts_with("Rendering") { crate::i18n::t(&job.label).to_string() } else { tl!("Exporting").to_string() };
let label = format!("{:.0}%{}", f * 100.0, eta.map(panels::left_text).unwrap_or_default());
let text = format!("{verb}… {label}");
// room from the start for 100 % and the longest time shown (99:59:59), so no estimate
// moves the start of the text
let room = format!("{verb}… 100%{}", panels::left_text(std::time::Duration::from_secs(100 * 3600 - 1)));
let width = [&text, &room].map(|s| widgets::tabular_text(p, s, &font, t.text_dim).1).into_iter().fold(old.map_or(0.0, |o| o.width), f32::max);
panels::JobText { job: job.id, at: now, text, label, width }
}
};
let start = bar.min.x - 8.0 - shown.width;
widgets::paint_tabular(p, egui::pos2(start, sb.center().y), widgets::tabular_text(p, &shown.text, &font, t.text_dim).0, t.text_dim);
let left = shown.label.clone();
self.job_text = Some(shown);
let resp = ui.interact(cancel, egui::Id::unique(("job-cancel", job.id)), egui::Sense::click());
let c = if resp.hovered() { t.hot_text } else { t.text_dim };
let k = 3.5;
p.line_segment([cancel.center() - egui::vec2(k, k), cancel.center() + egui::vec2(k, k)], egui::Stroke::new(1.4, c));
p.line_segment([cancel.center() + egui::vec2(-k, k), cancel.center() + egui::vec2(k, -k)], egui::Stroke::new(1.4, c));
self.auto.add("status.job.cancel", cancel, &format!("Cancel {}", job.label));
self.auto.add("status.job.progress", bar, &format!("{:.0}%{left}", f * 100.0));
self.auto.add("status.job.progress", bar, &left);
if resp.on_hover_text(tl!("Cancel")).clicked() {
job.progress.cancel.store(true, Ordering::Relaxed);
self.watched_render = None;
}
Some(progress.min.x)
Some(start)
}
/// Contextual hint for the status bar (Premiere shows tool/gesture hints here).
@@ -245,3 +256,50 @@ impl FilmcraftApp {
}
}
}
#[cfg(test)]
mod job_status_tests {
use std::sync::atomic::Ordering;
use std::time::Duration;
use super::*;
/// The status bar's export text changed on every frame, and right-aligned against the bar, so
/// "Exporting… 46%" jumped sideways whenever the time left gained or lost a digit or its hour.
#[test]
fn the_job_text_changes_once_a_second_and_where_it_starts_stays_put() {
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
let job = filmcraft_engine::Job { id: 7, label: "Exporting".into(), progress: Default::default(), result: Default::default() };
let progress = job.progress.clone();
progress.total.store(1_000_000, Ordering::Relaxed);
progress.done.store(460_000, Ordering::Relaxed);
app.session.jobs.push(job);
let (ctx, t) = (egui::Context::default(), Tokens::for_kind(crate::theme::ThemeKind::Dark));
let sb = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 24.0));
let draw = |app: &mut FilmcraftApp, now: f64| {
app.set_now_for_test(now);
let mut start = None;
ctx.run_ui(egui::RawInput::default(), |ui| start = app.job_status(ui, sb, &t)).textures_delta.clear();
(start, app.job_text.as_ref().map(|s| s.label.clone()))
};
// before any estimate
let (first, label) = draw(&mut app, 8.0);
assert_eq!(label.as_deref(), Some("46%"));
// 100 units a second (readings from the near future, which the real clock reading every frame
// does not disturb): 5395 s left, over an hour
let t0 = web_time::Instant::now() + Duration::from_secs(60);
for i in 0..=50u64 {
progress.done.store(460_000 + i * 10, Ordering::Relaxed);
progress.eta_at(t0 + Duration::from_millis(i * 100));
}
let (start, label) = draw(&mut app, 10.0);
assert_eq!(label.as_deref(), Some("46% · 1:29:55 left"));
assert_eq!(start, first, "the first estimate, over an hour, starts where the text without one did");
// a smaller total: under an hour, but not shown before a second has passed
progress.total.store(600_000, Ordering::Relaxed);
assert_eq!(draw(&mut app, 10.5), (start, label));
let (later, label) = draw(&mut app, 11.0);
assert_eq!(label.as_deref(), Some("77% · 23:15 left"));
assert_eq!(later, start, "the shorter text starts where the longer one did");
}
}
+4
View File
@@ -377,6 +377,10 @@ pub struct FilmcraftApp {
pub gpu: Option<GpuState>,
/// Preview render job being watched (job id, where to start playing when it completes).
watched_render: Option<(u64, Tick)>,
/// The status bar's running-job text as last shown.
job_text: Option<panels::JobText>,
/// Running jobs' texts in the export queue and the Progress panel as last shown.
steady_texts: panels::SteadyTexts,
/// Settings last applied to the UI (theme, tooltips, frame cache, audio device).
applied_prefs: Option<filmcraft_engine::autosave::Preferences>,
/// Settings ▸ Appearance ▸ UI Scale last applied, with the system scale it was applied for.
+59
View File
@@ -66,6 +66,65 @@ use crate::FilmcraftApp;
use crate::dock::PanelKind;
use crate::theme::Tokens;
/// The status bar's job text as last shown. It changes at most every [`JobText::EVERY`] seconds,
/// and the room it takes only grows while the job runs, so where it starts stays put.
pub struct JobText {
pub job: u64,
/// [`FilmcraftApp::now`] when `text` was made.
pub at: f64,
/// `Exporting… 46% · 56:11 left`.
pub text: String,
/// The same without the verb, for agents (`status.job.progress`).
pub label: String,
/// The widest the text has been for this job.
pub width: f32,
}
impl JobText {
/// More often than about once a second, a changing number is flicker rather than news.
pub const EVERY: f64 = 1.0;
}
/// The texts of running jobs elsewhere (an export queue item, a row of the Progress panel) as last
/// shown, by key: each changes at most every [`JobText::EVERY`] seconds.
#[derive(Default)]
pub struct SteadyTexts(std::collections::HashMap<String, (f64, String)>);
impl SteadyTexts {
/// The text last shown under `key`, or `make`'s when that is [`JobText::EVERY`] old at `now`.
/// Texts no longer asked for (their job ended) are let go.
pub fn get(&mut self, key: &str, now: f64, make: impl FnOnce() -> String) -> String {
self.0.retain(|_, (at, _)| (*at..*at + 10.0 * JobText::EVERY).contains(&now));
if let Some((at, text)) = self.0.get(key)
&& (*at..*at + JobText::EVERY).contains(&now)
{
return text.clone();
}
let text = make();
self.0.insert(key.to_owned(), (now, text.clone()));
text
}
}
#[cfg(test)]
mod steady_text_tests {
use super::SteadyTexts;
#[test]
fn a_text_is_made_again_once_a_second_and_forgotten_when_no_longer_asked_for() {
let mut texts = SteadyTexts::default();
assert_eq!(texts.get("a", 5.0, || "1".into()), "1");
assert_eq!(texts.get("a", 5.9, || "2".into()), "1");
assert_eq!(texts.get("b", 5.9, || "x".into()), "x", "each key has its own");
assert_eq!(texts.get("a", 6.0, || "3".into()), "3");
// a clock that went back (a test, a new session) makes the text again
assert_eq!(texts.get("a", 1.0, || "4".into()), "4");
// "b", last made at 5.9, ended long ago
texts.get("a", 100.0, || "5".into());
assert_eq!(texts.0.len(), 1);
}
}
/// ` · 2:05 left`: the time a job has left, as it follows a percentage.
pub fn left_text(d: std::time::Duration) -> String {
tlf!(" · {time} left", time = filmcraft_engine::export::format_eta(d))
+47 -9
View File
@@ -157,6 +157,7 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
elems.push(("progress.showFinished".into(), r.rect, "Show finished jobs".into()));
ui.add_space(4.0);
let active = jobs.iter().filter(|j| j["finished"] != json!(true)).count();
let (now, texts) = (app.now(), &mut app.steady_texts);
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
let mut shown = 0;
for j in &jobs {
@@ -176,25 +177,27 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
(true, Some(e)) if e.to_ascii_lowercase().contains("cancel") => tl!("Cancelled").to_string(),
(true, Some(e)) => tlf!("Failed: {e}", e),
(true, None) => tl!("Done").to_string(),
(false, _) => {
(false, _) => texts.get(&format!("job.{id}"), now, || {
let s = j["status"].as_str().unwrap_or("");
let n = format!("{} / {}", j["done"].as_u64().unwrap_or(0), j["total"].as_u64().unwrap_or(0));
let left = super::eta_suffix(j);
if s.is_empty() { format!("{:.0} % ({n}){left}", frac * 100.0) } else { format!("{:.0} % {s}{left}", frac * 100.0) }
}
}),
};
let bar = Rect::from_min_max(pos2(r.min.x + 8.0, r.min.y + 24.0), pos2(r.max.x - 36.0, r.min.y + 30.0));
ui.painter().rect_filled(bar, 3.0, t.field_bg);
let fill = if err.is_some() && done { t.danger } else { t.accent };
let f = if done && err.is_none() { 1.0 } else { frac };
ui.painter().rect_filled(Rect::from_min_max(bar.min, pos2(bar.min.x + bar.width() * f, bar.max.y)), 3.0, fill);
ui.painter().text(
pos2(r.min.x + 8.0, r.max.y - 4.0),
Align2::LEFT_BOTTOM,
&status,
Tokens::ui(10.5),
if err.is_some() { t.danger } else { t.text_dim },
);
let col = if err.is_some() { t.danger } else { t.text_dim };
if done {
ui.painter().text(pos2(r.min.x + 8.0, r.max.y - 4.0), Align2::LEFT_BOTTOM, &status, Tokens::ui(10.5), col);
} else {
// once a second, with digits of one width, so the counts and the time left don't shuffle the line
let (pieces, _) = crate::widgets::tabular_text(ui.painter(), &status, &Tokens::ui(10.5), col);
let h = pieces.iter().map(|(_, g)| g.size().y).fold(0.0, f32::max);
crate::widgets::paint_tabular(ui.painter(), pos2(r.min.x + 8.0, r.max.y - 4.0 - h / 2.0), pieces, col);
}
elems.push((format!("progress.job.{id}"), r, format!("{label}: {status}")));
if !done {
let cr = Rect::from_center_size(pos2(r.max.x - 18.0, bar.center().y), vec2(20.0, 20.0));
@@ -226,3 +229,38 @@ pub fn progress(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::Ordering;
use super::*;
/// A running job's row made its text on every repaint (four times a second), so its count and
/// its time left shuffled the line.
#[test]
fn a_running_jobs_row_changes_once_a_second() {
let mut app = FilmcraftApp::new(filmcraft_engine::Session::default());
let job = filmcraft_engine::Job { id: 7, label: "Proxies".into(), progress: Default::default(), result: Default::default() };
let p = job.progress.clone();
p.total.store(100, Ordering::Relaxed);
p.done.store(10, Ordering::Relaxed);
app.session.jobs.push(job);
let ctx = egui::Context::default();
let draw = |app: &mut FilmcraftApp, now: f64| {
app.set_now_for_test(now);
app.auto.begin_frame();
ctx.run_ui(egui::RawInput::default(), |ui| {
let rect = ui.max_rect();
progress(app, ui, rect);
})
.textures_delta
.clear();
app.auto.find("progress.job.7").map(|e| e.label.clone())
};
assert_eq!(draw(&mut app, 8.0).as_deref(), Some("Proxies: 10 % (10 / 100)"));
p.done.store(50, Ordering::Relaxed);
assert_eq!(draw(&mut app, 8.5).as_deref(), Some("Proxies: 10 % (10 / 100)"), "not before a second has passed");
assert_eq!(draw(&mut app, 9.0).as_deref(), Some("Proxies: 50 % (50 / 100)"));
}
}
+15 -5
View File
@@ -18,6 +18,18 @@ pub(super) fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect
p.text(pos2(rect.min.x + 14.0, qtop), Align2::LEFT_CENTER, tlf!("Queue ({n})", n), Tokens::semibold(13.0), t.text);
let body = Rect::from_min_max(pos2(rect.min.x + 8.0, qtop + 14.0), pos2(rect.max.x - 8.0, rect.max.y - 40.0));
let items: Vec<Value> = app.session.execute("export.queue.list", json!({})).ok().and_then(|v| v["items"].as_array().cloned()).unwrap_or_default();
let now = app.now();
let encoding: std::collections::HashMap<u64, String> = items
.iter()
.filter(|it| it["status"] == "encoding")
.map(|it| {
let id = it["id"].as_u64().unwrap_or(0);
let text = app.steady_texts.get(&format!("queue.{id}"), now, || {
tlf!("Encoding {n}%", n = format!("{:.0}", it["progress"].as_f64().unwrap_or(0.0) * 100.0)) + &crate::panels::eta_suffix(it)
});
(id, text)
})
.collect();
let mut action: Option<(&'static str, Value)> = None;
let mut child = ui.new_child(egui::UiBuilder::new().max_rect(body).id_salt("export-queue"));
egui::ScrollArea::vertical().id_salt("export-queue-scroll").auto_shrink([false, false]).show(&mut child, |ui| {
@@ -36,10 +48,7 @@ pub(super) fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect
let preset = it["preset"].as_str().filter(|s| !s.is_empty()).unwrap_or(CUSTOM);
let (label, col) = match status {
"ready" => (tl!("Ready").to_string(), t.text_dim),
"encoding" => (
tlf!("Encoding {n}%", n = format!("{:.0}", it["progress"].as_f64().unwrap_or(0.0) * 100.0)) + &crate::panels::eta_suffix(it),
t.accent,
),
"encoding" => (encoding.get(&id).cloned().unwrap_or_default(), t.accent),
"done" => (tl!("Done").to_string(), t.render_green),
"failed" => (tlf!("Failed: {e}", e = it["error"].as_str().unwrap_or("")), t.danger),
_ => (tl!("Cancelled").to_string(), t.text_faint),
@@ -47,7 +56,8 @@ pub(super) fn queue_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect
if status == "encoding" {
// the progress and the time left on a line of their own: the column is narrow, and a wrapped "· 15 s left" reads badly
ui.label(egui::RichText::new(crate::i18n::t(preset)).size(11.0).color(t.text_dim));
ui.label(egui::RichText::new(label).size(11.0).color(col));
// once a second, with digits of one width, so the time left doesn't shuffle the line
crate::widgets::tabular_label(ui, &label, &Tokens::ui(11.0), col);
} else {
ui.label(egui::RichText::new(format!("{} · {label}", crate::i18n::t(preset))).size(11.0).color(col));
}
+77
View File
@@ -542,6 +542,49 @@ pub(crate) fn zoom_thumb_cursor(ui: &Ui, resp: &Response, thumb: Rect, handle_gr
}
}
/// `text` laid out with each digit centred in a cell as wide as the widest digit (tabular figures,
/// which egui's fonts don't switch on), so a number that changes doesn't move the text after it:
/// the pieces with their x offsets, and the width of the whole.
pub fn tabular_text(painter: &egui::Painter, text: &str, font: &egui::FontId, color: Color32) -> (Vec<(f32, std::sync::Arc<egui::Galley>)>, f32) {
let layout = |s: String| painter.layout_no_wrap(s, font.clone(), color);
let cell = ('0'..='9').map(|c| layout(c.to_string()).size().x).fold(0.0, f32::max);
// each digit alone; the runs between them whole, keeping their kerning
let mut runs: Vec<(bool, String)> = Vec::new();
for c in text.chars() {
match runs.last_mut() {
Some((false, run)) if !c.is_ascii_digit() => run.push(c),
_ => runs.push((c.is_ascii_digit(), c.to_string())),
}
}
let (mut pieces, mut x) = (Vec::with_capacity(runs.len()), 0.0);
for (digit, run) in runs {
let g = layout(run);
let w = g.size().x;
pieces.push((if digit { x + (cell - w) / 2.0 } else { x }, g));
x += if digit { cell } else { w };
}
(pieces, x)
}
/// Paint [`tabular_text`]'s pieces with their left edge at `left_center.x`, centred on its y.
pub fn paint_tabular(painter: &egui::Painter, left_center: egui::Pos2, pieces: Vec<(f32, std::sync::Arc<egui::Galley>)>, color: Color32) {
for (dx, g) in pieces {
let y = left_center.y - g.size().y / 2.0;
painter.galley(pos2(left_center.x + dx, y), g, color);
}
}
/// [`tabular_text`] as a label in `ui`'s layout, read by screen readers as `egui::Label` is.
pub fn tabular_label(ui: &mut egui::Ui, text: &str, font: &egui::FontId, color: Color32) -> egui::Response {
let (pieces, width) = tabular_text(ui.painter(), text, font, color);
let height = pieces.iter().map(|(_, g)| g.size().y).fold(font.size, f32::max);
let sense = if ui.memory(|m| m.options.screen_reader) { egui::Sense::focusable_noninteractive() } else { egui::Sense::hover() };
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), sense);
paint_tabular(ui.painter(), pos2(rect.min.x, rect.center().y), pieces, color);
resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Label, ui.is_enabled(), text));
resp
}
#[cfg(test)]
mod tests {
use super::*;
@@ -568,6 +611,24 @@ mod tests {
.collect()
}
/// Tabular text: every digit takes the same room, so `11:11` and `88:08` are as wide.
#[test]
fn tabular_text_gives_every_digit_the_same_room() {
let ctx = egui::Context::default();
crate::theme::install(&ctx, &Tokens::for_kind(ThemeKind::Dark)); // the app's UI font
let mut widths = Vec::new();
ctx.run_ui(egui::RawInput::default(), |ui| {
for s in ["Exporting… 11% · 11:11 left", "Exporting… 88% · 88:08 left"] {
widths.push(tabular_text(ui.painter(), s, &Tokens::ui(11.0), Color32::WHITE).1);
}
let plain = |s: &str| ui.painter().layout_no_wrap(s.to_string(), Tokens::ui(11.0), Color32::WHITE).size().x;
assert!(plain("11:11") < plain("88:08"), "the font's own digits differ in width");
})
.textures_delta
.clear();
assert!((widths[0] - widths[1]).abs() < 0.01, "{widths:?}");
}
#[test]
fn the_text_of_a_search_field_is_centred_vertically_in_it() {
for text in ["", "effects"] {
@@ -590,6 +651,22 @@ mod tests {
.clear();
}
/// The queue's progress line is painted digit by digit; a screen reader still reads it whole.
#[test]
fn a_tabular_label_is_read_as_its_text() {
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
let mut harness = Harness::builder().with_size(egui::vec2(240.0, 60.0)).build_ui_state(
|ui, _: &mut ()| {
tabular_label(ui, "Encoding 88% · 37:00 left", &Tokens::ui(11.0), Color32::WHITE);
},
(),
);
harness.run_steps(2);
harness.check_accessibility();
assert!(harness.query_by_label("Encoding 88% · 37:00 left").is_some());
}
/// The edit field behind a hot number uses the caller's property label, not the generic
/// "Value", so Effect Controls and graphics fields are distinguishable by screen readers.
#[test]
+4 -3
View File
@@ -131,9 +131,10 @@ progress and stop it:
item carry `offlineMedia`: the offline items the export drew the placeholder for (see §2b), `[]`
when none. The export itself still succeeds; deciding what to do about it is up to the caller.
- Every job in `jobs.list`, and every encoding item of `export.queue.list`, carries `etaSeconds`: the
time left at the job's speed over the last 15 seconds, `null` until a second of progress has been
measured (a loudness pass, a seek) and once it is done. The app shows it as `37% · 2:05 left` in the
status bar, the Export queue and the Progress panel.
time left at the job's speed over the last 15 seconds, measured between the batches of frames it
finishes and counting down in between, `null` until a second of progress has been measured (a
loudness pass, a seek) and once it is done. The app shows it as `37% · 2:05 left` in the status
bar, the Export queue and the Progress panel, each refreshed once a second.
- With `"_meta": {"progressToken": T}` on the `tools/call`, the server sends
`notifications/progress` `{progressToken: T, progress: <frames done>, total: <frames>, message:
<job status>}` at most every 100 ms while frames advance. Without a token it sends none.