mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-25 02:30:15 -06:00
Merge PR #2077: LR-BEHAV-BG-TASKS: one progress bar, spinner and busy button
# Conflicts: # crates/ui-egui/src/lib.rs # docs/whats-new.md
This commit is contained in:
@@ -188,6 +188,7 @@ pub struct Button<'a> {
|
||||
kind: Kind,
|
||||
size: Size,
|
||||
enabled: bool,
|
||||
busy: bool,
|
||||
widget: Option<String>,
|
||||
tooltip: Option<&'a str>,
|
||||
min_width: f32,
|
||||
@@ -204,6 +205,7 @@ impl<'a> Button<'a> {
|
||||
kind: Kind::default(),
|
||||
size: Size::default(),
|
||||
enabled: true,
|
||||
busy: false,
|
||||
widget: None,
|
||||
tooltip: None,
|
||||
min_width: 0.0,
|
||||
@@ -256,6 +258,14 @@ impl<'a> Button<'a> {
|
||||
self
|
||||
}
|
||||
|
||||
/// Working on what it was pressed for: a spinner takes the icon's place (or sits before the
|
||||
/// text, inside the button's own padding, so the button keeps its width), a click does nothing,
|
||||
/// and screen readers hear it is busy.
|
||||
pub fn busy(mut self, busy: bool) -> Self {
|
||||
self.busy = busy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on
|
||||
/// screen, as every registered id is.
|
||||
pub fn widget(mut self, id: impl Into<String>) -> Self {
|
||||
@@ -331,14 +341,30 @@ impl<'a> Button<'a> {
|
||||
};
|
||||
// a button in a disabled part of the window is disabled too
|
||||
let enabled = self.enabled && ui.is_enabled();
|
||||
let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() });
|
||||
let resp = ui.interact(
|
||||
rect,
|
||||
id,
|
||||
if !enabled {
|
||||
Sense::hover()
|
||||
} else if self.busy {
|
||||
// still reachable by the keyboard, but nothing to press
|
||||
Sense::focusable_noninteractive()
|
||||
} else {
|
||||
Sense::click()
|
||||
},
|
||||
);
|
||||
let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone()));
|
||||
if self.busy {
|
||||
ui.ctx().accesskit_node_builder(resp.id, |node| node.set_busy());
|
||||
}
|
||||
if let Some(w) = &self.widget {
|
||||
crate::widgets::register(ui.ctx(), w.clone(), rect);
|
||||
}
|
||||
let state = if !enabled {
|
||||
State::Disabled
|
||||
} else if self.busy {
|
||||
State::Idle
|
||||
} else if resp.is_pointer_button_down_on() {
|
||||
State::Pressed
|
||||
} else if resp.hovered() {
|
||||
@@ -362,16 +388,29 @@ impl<'a> Button<'a> {
|
||||
}
|
||||
let galley = self.galley(ui);
|
||||
let icon_s = self.size.icon();
|
||||
let content_w = match (&galley, self.icon) {
|
||||
(Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x,
|
||||
(Some(g), None) => g.size().x,
|
||||
(None, Some(_)) => icon_s,
|
||||
(None, None) => 0.0,
|
||||
// busy: the spinner is the icon's slot; with text alone it is a smaller one in the button's
|
||||
// own padding (a little tighter), so the button never grows
|
||||
let spinner_only = self.busy && self.icon.is_none() && galley.is_some();
|
||||
let (slot, gap) = if spinner_only { ((self.size.padding() * 2.0 - 12.0).clamp(8.0, icon_s), 4.0) } else { (icon_s, self.size.gap()) };
|
||||
let has_slot = self.icon.is_some() || spinner_only;
|
||||
let content_w = match (&galley, has_slot) {
|
||||
(Some(g), true) => slot + gap + g.size().x,
|
||||
(Some(g), false) => g.size().x,
|
||||
(None, true) => slot,
|
||||
(None, false) => 0.0,
|
||||
};
|
||||
let mut x = rect.center().x - content_w / 2.0;
|
||||
if let Some(icon) = self.icon {
|
||||
paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content);
|
||||
x += icon_s + self.size.gap();
|
||||
if has_slot {
|
||||
let slot_rect = Rect::from_center_size(pos2(x + slot / 2.0, rect.center().y), vec2(slot, slot));
|
||||
if self.busy {
|
||||
if ui.is_rect_visible(rect) {
|
||||
crate::progress::paint_spinner(p, slot_rect, lk.content, ui.input(|i| i.time));
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(33));
|
||||
}
|
||||
} else if let Some(icon) = self.icon {
|
||||
paint(p, slot_rect, icon, lk.content);
|
||||
}
|
||||
x += slot + gap;
|
||||
}
|
||||
if let Some(g) = galley {
|
||||
let at = pos2(x, rect.center().y - g.size().y / 2.0);
|
||||
|
||||
@@ -1336,7 +1336,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial
|
||||
crate::widgets::announce(&resp, c.name.clone());
|
||||
register(ui.ctx(), format!("import:{i}"), img);
|
||||
let p = ui.painter();
|
||||
p.rect_filled(img, 3.0, Color32::from_gray(30));
|
||||
p.rect_filled(img, 3.0, t.skeleton);
|
||||
// thumbnail (background job; the grid only asks for the cells in view)
|
||||
let slot = Slot::Import(i as u32);
|
||||
if !app.renderer.textures.contains_key(&slot)
|
||||
|
||||
@@ -31,6 +31,7 @@ mod model_setup;
|
||||
pub mod panels;
|
||||
pub mod pick;
|
||||
pub mod print_view;
|
||||
pub mod progress;
|
||||
pub mod rating;
|
||||
pub mod recheck;
|
||||
pub mod region;
|
||||
@@ -150,6 +151,8 @@ mod tests_profile_menu;
|
||||
#[cfg(test)]
|
||||
mod tests_profiles_layout;
|
||||
#[cfg(test)]
|
||||
mod tests_progress;
|
||||
#[cfg(test)]
|
||||
mod tests_quit_unsaved;
|
||||
#[cfg(test)]
|
||||
mod tests_rating;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! that can be cancelled. The tasks come from the engine's registry ([`lightcraft_engine::activity`]); ✕ runs
|
||||
//! `activity.cancel` like any other frontend would.
|
||||
|
||||
use egui::{Align, Layout, Margin, Rect, RichText, Sense, Stroke, pos2, vec2};
|
||||
use egui::{Align, Layout, Margin, RichText, Sense, Stroke, pos2, vec2};
|
||||
use lightcraft_engine::activity::{TaskInfo, Unit};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -136,7 +136,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool {
|
||||
clicked = resp.on_hover_text(tr("Cancel")).clicked();
|
||||
}
|
||||
});
|
||||
bar(ui, t, task, width);
|
||||
bar(ui, task, width);
|
||||
let line = detail_line(task);
|
||||
if !line.is_empty() {
|
||||
ui.add(egui::Label::new(RichText::new(line).font(t.font(11.0)).color(t.text_dim)).truncate());
|
||||
@@ -146,21 +146,11 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool {
|
||||
clicked
|
||||
}
|
||||
|
||||
/// The progress bar; while the amount of work isn't known, a segment sweeps across.
|
||||
fn bar(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) {
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(width, 5.0), Sense::hover());
|
||||
let p = ui.painter();
|
||||
p.rect_filled(rect, 2.5, t.field);
|
||||
let fill = if task.total > 0 {
|
||||
let frac = (task.done as f64 / task.total as f64).clamp(0.0, 1.0) as f32;
|
||||
Rect::from_min_size(rect.min, vec2(rect.width() * frac, rect.height()))
|
||||
} else {
|
||||
let phase = (ui.input(|i| i.time) * 0.7).fract() as f32;
|
||||
let w = rect.width() * 0.3;
|
||||
let x = rect.left() + (rect.width() + w) * phase - w;
|
||||
Rect::from_min_max(pos2(x.max(rect.left()), rect.top()), pos2((x + w).min(rect.right()), rect.bottom()))
|
||||
};
|
||||
if fill.width() > 0.0 {
|
||||
p.rect_filled(fill, 2.5, if task.cancelling { t.text_dim } else { t.accent });
|
||||
}
|
||||
/// The progress bar, named after its task for screen readers; while the amount of work isn't
|
||||
/// known, a segment sweeps across.
|
||||
pub(crate) fn bar(ui: &mut egui::Ui, task: &TaskInfo, width: f32) -> egui::Response {
|
||||
let fraction = (task.total > 0).then(|| (task.done as f64 / task.total as f64) as f32);
|
||||
let response = crate::progress::Progress::new(fraction).width(width).dimmed(task.cancelling).show(ui);
|
||||
crate::widgets::screen_reader_name(ui.ctx(), response.id, tr(&task.label));
|
||||
response
|
||||
}
|
||||
|
||||
@@ -279,7 +279,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo
|
||||
display = super::detail::fit_rect(img_area, texture_aspect, zoom, native, ppp, app.ui.pan);
|
||||
p.image(tex.tex.id(), display, uv, Color32::WHITE);
|
||||
} else {
|
||||
p.rect_filled(img.intersect(img_area), 0.0, Color32::from_gray(38));
|
||||
p.rect_filled(img.intersect(img_area), 0.0, Tokens::get(ui.ctx()).skeleton);
|
||||
}
|
||||
if photo.flag == Flag::Reject {
|
||||
p.rect_filled(display, 0.0, Color32::from_black_alpha(110));
|
||||
|
||||
@@ -141,6 +141,23 @@ pub fn detail_toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, on
|
||||
});
|
||||
}
|
||||
|
||||
/// The bar under the Denoise slider while a photo's picture is made: at most 240 px wide, and
|
||||
/// sweeping while the number of tiles is not known yet. The percentage is `progress::percent`, as the
|
||||
/// bar's accessible value is: 100 % only once done.
|
||||
pub(crate) fn detail_bar(ui: &mut egui::Ui, done: u64, total: u64) -> egui::Response {
|
||||
let fraction = crate::progress::fraction_of(done, total);
|
||||
let text = match fraction {
|
||||
Some(f) => tr_format!("Making the denoised picture… {}%", crate::progress::percent(f)),
|
||||
None => tr("Starting…").to_string(),
|
||||
};
|
||||
crate::progress::Progress::new(fraction).label(text).width(ui.available_width().min(240.0)).widget("denoise:progress").show(ui)
|
||||
}
|
||||
|
||||
/// The bar in Settings, AI Denoise: 380 px wide however wide the tab is.
|
||||
pub(crate) fn settings_bar(ui: &mut egui::Ui, done: u64, total: u64, text: String) -> egui::Response {
|
||||
crate::progress::Progress::new(crate::progress::fraction_of(done, total)).label(text).width(380.0).widget("denoise:settingsProgress").show(ui)
|
||||
}
|
||||
|
||||
/// The line under the Denoise slider for the photo `id`, whose Amount is `amount`.
|
||||
pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, amount: f64) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
@@ -168,7 +185,12 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am
|
||||
let now = ui.ctx().input(|i| i.time);
|
||||
app.caches.denoise.next_pump = app.caches.denoise.next_pump.min(now + 0.05);
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(60));
|
||||
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| note(ui, "Waiting to start…", t.text_dim));
|
||||
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
note(ui, "Waiting to start…", t.text_dim);
|
||||
})
|
||||
});
|
||||
} else if amount > 0.0 {
|
||||
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
|
||||
note(ui, "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).", t.text_dim);
|
||||
@@ -193,14 +215,7 @@ pub fn detail_status(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, am
|
||||
}
|
||||
PhotoState::Running { done, total } => {
|
||||
egui::Frame::NONE.inner_margin(pad).show(ui, |ui| {
|
||||
let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let text = if total > 0 {
|
||||
tr_format!("Making the denoised picture… {}%", (frac * 100.0).round() as u32)
|
||||
} else {
|
||||
tr("Starting…").to_string()
|
||||
};
|
||||
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(ui.available_width().min(240.0)).text(RichText::new(text).size(11.0)));
|
||||
register(ui.ctx(), "denoise:progress", bar.rect);
|
||||
detail_bar(ui, done as u64, total as u64);
|
||||
});
|
||||
}
|
||||
PhotoState::Failed { why, unsupported } => {
|
||||
@@ -245,8 +260,12 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
.map(|v| v["downloads"].as_array().cloned().unwrap_or_default())
|
||||
.unwrap_or_default();
|
||||
heading(ui, t, "Models");
|
||||
let r = ui.add_enabled(runtime && app.services.pick_denoise_model.is_some(), egui::Button::new(tr("Install from file…")));
|
||||
register(ui.ctx(), "denoise:installFile", r.rect);
|
||||
let installing = downloads.iter().any(|d| d["id"] == "local-install" && d["state"].as_str() == Some("installing"));
|
||||
let r = crate::button::Button::new("Install from file…")
|
||||
.enabled(runtime && app.services.pick_denoise_model.is_some())
|
||||
.busy(installing)
|
||||
.widget("denoise:installFile")
|
||||
.show(ui);
|
||||
if r.clicked()
|
||||
&& let Some(path) = app.services.pick_denoise_model.as_mut().and_then(|f| f().into_iter().next())
|
||||
{
|
||||
@@ -321,7 +340,6 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
|
||||
let (running, queued) = (app.caches.denoise.running, app.caches.denoise.queued);
|
||||
match running {
|
||||
Some((done, total)) => {
|
||||
let frac = if total > 0 { (done as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let name = status["running"]["photo"]
|
||||
.as_u64()
|
||||
.and_then(|p| app.session.catalog.photo(PhotoId(p)).map(|p| p.file_name.clone()))
|
||||
@@ -333,8 +351,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V
|
||||
done = done,
|
||||
total = total
|
||||
);
|
||||
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(380.0).text(RichText::new(text).size(11.5)));
|
||||
register(ui.ctx(), "denoise:settingsProgress", bar.rect);
|
||||
settings_bar(ui, done, total, text);
|
||||
}
|
||||
None if queued > 0 => hint(ui, t, &count(queued, "photo waiting", "photos waiting")),
|
||||
None => {
|
||||
@@ -438,17 +455,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value,
|
||||
match (dl_state, dl) {
|
||||
(Some("running"), Some(d)) => {
|
||||
let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0));
|
||||
let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let text = if total > 0 && got >= total {
|
||||
tr("Checking…").to_string()
|
||||
} else {
|
||||
tr_format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)), host = host)
|
||||
};
|
||||
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5))));
|
||||
register(ui.ctx(), format!("denoise:progress:{id}"), bar.rect);
|
||||
crate::progress::Progress::new(crate::progress::fraction_of(got, total))
|
||||
.label(text)
|
||||
.width(crate::progress::ROW_WIDTH.min(ui.available_width()))
|
||||
.widget(format!("denoise:progress:{id}"))
|
||||
.show(ui);
|
||||
}
|
||||
(Some("done" | "installing"), _) => {
|
||||
ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
ui.label(RichText::new(tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
(Some("failed"), Some(d)) => {
|
||||
let why = sentence(d["error"].as_str().unwrap_or("The download failed"));
|
||||
|
||||
@@ -1323,7 +1323,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect
|
||||
p.rect_filled(frame.expand(5.0), 4.0, Color32::from_black_alpha(190));
|
||||
match tex {
|
||||
Some(t) => p.image(t.tex.id(), frame, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE),
|
||||
None => p.rect_filled(frame, 0.0, Color32::from_gray(40)),
|
||||
None => p.rect_filled(frame, 0.0, Tokens::get(ui.ctx()).skeleton),
|
||||
};
|
||||
// the visible part of the photo, in normalized image coordinates
|
||||
let vis = canvas.intersect(img);
|
||||
@@ -2836,7 +2836,8 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click());
|
||||
crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default());
|
||||
let failed = app.renderer.thumb(*id).is_none() && app.renderer.failure(Slot::Thumb(*id)).is_some();
|
||||
crate::widgets::announce(&resp, film_spoken(app.session.catalog.photo(*id).map_or("", |p| p.file_name.as_str()), failed));
|
||||
register(ui.ctx(), format!("film:{}", id.0), cr);
|
||||
let state = app.session.selection.state_of(*id);
|
||||
let sel = state == lightcraft_engine::SelectionState::Active;
|
||||
@@ -2859,7 +2860,14 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
}
|
||||
let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0));
|
||||
super::grid::request_thumb(app, *id, (256.0 * ppp.min(2.0) / 2.0) as usize * 2, 8);
|
||||
if let Some(tex) = app.renderer.thumb(*id) {
|
||||
let thumb = app.renderer.thumb(*id);
|
||||
if thumb.is_none() {
|
||||
p.rect_filled(img_area, 0.0, t.skeleton);
|
||||
if failed {
|
||||
p.text(img_area.center(), Align2::CENTER_CENTER, "!", t.semibold(14.0), t.text);
|
||||
}
|
||||
}
|
||||
if let Some(tex) = thumb {
|
||||
let [tw, th] = tex.size;
|
||||
let s = (img_area.width() / tw as f32).min(img_area.height() / th as f32);
|
||||
let fr = Rect::from_center_size(img_area.center(), vec2(tw as f32 * s, th as f32 * s));
|
||||
@@ -2927,6 +2935,17 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) {
|
||||
});
|
||||
}
|
||||
|
||||
/// What a screen reader says for a filmstrip thumbnail: the file, and why there is no picture
|
||||
/// when the thumbnail failed (the painted `!` has no name of its own). The name is empty when the
|
||||
/// catalog no longer has the photo (or its file name is blank); the reason then stands alone.
|
||||
pub(crate) fn film_spoken(file_name: &str, failed: bool) -> String {
|
||||
match (file_name.is_empty(), failed) {
|
||||
(_, false) => file_name.to_string(),
|
||||
(true, true) => crate::i18n::tr("This photo can't be opened").to_string(),
|
||||
(false, true) => format!("{file_name}, {}", crate::i18n::tr("This photo can't be opened")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Rating, flag and edited badges along a filmstrip thumbnail's bottom edge (Settings → Interface).
|
||||
fn film_badges(p: &egui::Painter, t: &Tokens, fr: Rect, ph: &lightcraft_catalog::Photo) {
|
||||
use crate::icons::{Icon, paint};
|
||||
|
||||
@@ -133,7 +133,14 @@ fn watermark_preview(
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
let note = if e.is_some() { "Preview unavailable" } else { "Loading preview…" };
|
||||
let r = ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim));
|
||||
let r = ui
|
||||
.horizontal(|ui| {
|
||||
if e.is_none() {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
}
|
||||
ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim))
|
||||
})
|
||||
.inner;
|
||||
if let Some(e) = e {
|
||||
r.on_hover_text(e);
|
||||
}
|
||||
@@ -611,7 +618,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
|
||||
});
|
||||
}
|
||||
None => {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Checking names…")).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
}
|
||||
let more = total.saturating_sub(RENAME_PREVIEW_ROWS);
|
||||
@@ -1666,10 +1676,8 @@ fn sam_model_body(app: &mut LightcraftApp, ui: &mut egui::Ui, error: Option<&str
|
||||
let seg = &app.session.segmenter;
|
||||
if d.running {
|
||||
ui.add_space(4.0);
|
||||
let frac = if d.total > 0 { d.done as f64 / d.total as f64 } else { 0.0 };
|
||||
let text = format!("{:.2} / {:.2} GB · {}", gb(d.done), gb(d.total), d.file);
|
||||
let r = ui.add(egui::ProgressBar::new(frac as f32).text(text));
|
||||
crate::widgets::register(ui.ctx(), "progress:samDownload", r.rect);
|
||||
crate::progress::Progress::new(crate::progress::fraction_of(d.done, d.total)).label(text).widget("progress:samDownload").show(ui);
|
||||
let r = ui.button(crate::i18n::tr("Cancel Download"));
|
||||
crate::widgets::register(ui.ctx(), "button:samCancel", r.rect);
|
||||
if r.clicked() {
|
||||
|
||||
@@ -190,6 +190,9 @@ fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Val
|
||||
};
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim));
|
||||
if st["checking"] == true {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
}
|
||||
let r = ui.label(text);
|
||||
register(ui.ctx(), format!("faces:device:{what}"), r.rect);
|
||||
if let Some(why) = why {
|
||||
@@ -312,10 +315,7 @@ fn scan_progress(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
return;
|
||||
}
|
||||
let text = format!("Scanning photos for faces: {left} left · {faces} faces so far");
|
||||
let bar = ui.add(
|
||||
egui::ProgressBar::new((1.0 - left as f32 / peak as f32).clamp(0.0, 1.0)).desired_width(380.0).text(RichText::new(text).font(t.font(11.5))),
|
||||
);
|
||||
register(ui.ctx(), "faces:scanProgress", bar.rect);
|
||||
crate::progress::Progress::new(Some(1.0 - left as f32 / peak as f32)).label(text).widget("faces:scanProgress").show(ui);
|
||||
}
|
||||
|
||||
fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, dl: Option<&Value>, can_run: bool) {
|
||||
@@ -361,17 +361,22 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value,
|
||||
match (dl_state, dl) {
|
||||
(Some("running"), Some(d)) => {
|
||||
let (got, total) = (d["bytes"].as_u64().unwrap_or(0), d["total"].as_u64().unwrap_or(0));
|
||||
let frac = if total > 0 { (got as f32 / total as f32).clamp(0.0, 1.0) } else { 0.0 };
|
||||
let text = if total > 0 && got >= total {
|
||||
"Checking…".to_string()
|
||||
} else {
|
||||
format!("{} of {} from {host}", mb(Some(got)), mb(Some(total)))
|
||||
};
|
||||
let bar = ui.add(egui::ProgressBar::new(frac).desired_width(260.0).text(RichText::new(text).font(t.font(11.5))));
|
||||
register(ui.ctx(), format!("faces:progress:{id}"), bar.rect);
|
||||
crate::progress::Progress::new(crate::progress::fraction_of(got, total))
|
||||
.label(text)
|
||||
.width(crate::progress::ROW_WIDTH.min(ui.available_width()))
|
||||
.widget(format!("faces:progress:{id}"))
|
||||
.show(ui);
|
||||
}
|
||||
(Some("done"), _) => {
|
||||
ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
|
||||
ui.horizontal(|ui| {
|
||||
crate::progress::spinner(ui, 12.0);
|
||||
ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim));
|
||||
});
|
||||
}
|
||||
(Some("failed"), Some(d)) => {
|
||||
let why = sentence(d["error"].as_str().unwrap_or("The download failed"));
|
||||
|
||||
@@ -800,16 +800,18 @@ fn cell(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, r: Rect, square
|
||||
}
|
||||
} else {
|
||||
let ph = img_rect.shrink(if square { 20.0 } else { 0.0 });
|
||||
p.rect_filled(ph, 0.0, Color32::from_gray(38));
|
||||
p.rect_filled(ph, 0.0, t.skeleton);
|
||||
// not drawn yet (or unreadable): the selection is still visible
|
||||
if active {
|
||||
p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside);
|
||||
} else if selected {
|
||||
p.rect_stroke(ph, 0.0, Stroke::new(2.0, Color32::from_gray(170)), StrokeKind::Outside);
|
||||
// a light theme's skeleton is itself mid-grey: the ring takes the dim text colour
|
||||
let ring = if t.skeleton.r() > 0x80 { t.text_dim } else { Color32::from_gray(170) };
|
||||
p.rect_stroke(ph, 0.0, Stroke::new(2.0, ring), StrokeKind::Outside);
|
||||
}
|
||||
if app.renderer.failure(Slot::Thumb(id)).is_some() {
|
||||
// unreadable / missing file
|
||||
p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text_dim);
|
||||
p.text(ph.center(), Align2::CENTER_CENTER, "!", t.semibold(18.0), t.text);
|
||||
}
|
||||
}
|
||||
// labels and badges
|
||||
|
||||
@@ -169,7 +169,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
};
|
||||
if let Some(status) = status {
|
||||
ui.add_space(4.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
|
||||
ui.horizontal_top(|ui| {
|
||||
crate::progress::spinner(ui, 14.0);
|
||||
ui.add(egui::Label::new(egui::RichText::new(crate::i18n::tr(status)).color(t.text_dim)).wrap());
|
||||
});
|
||||
ui.ctx().request_repaint_after(std::time::Duration::from_millis(250));
|
||||
}
|
||||
// the model download, while its dialog is closed
|
||||
@@ -177,11 +180,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) {
|
||||
if download.running && app.ui.dialog.is_none() {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
let pct = if download.total > 0 { download.done as f64 / download.total as f64 } else { 0.0 };
|
||||
let r = ui.add(
|
||||
egui::ProgressBar::new(pct as f32).desired_width(ui.available_width() - 70.0).text(format!("SAM 3: {} %", (pct * 100.0).floor())),
|
||||
);
|
||||
register(ui.ctx(), "maskSamProgress", r.rect);
|
||||
// the bar takes what the Details button leaves
|
||||
let details = ui.spacing().interact_size.x + 30.0;
|
||||
sam_download_bar(ui, download.done, download.total, (ui.available_width() - details).max(96.0));
|
||||
if text_button(ui, "maskSamDetails", crate::i18n::tr("Details"), false).clicked() {
|
||||
app.offer_sam_download(None);
|
||||
}
|
||||
@@ -760,6 +761,28 @@ pub(crate) fn start_describe(app: &mut LightcraftApp, op: &str) {
|
||||
app.ui.describe = Some((op.to_string(), String::new()));
|
||||
}
|
||||
|
||||
/// The SAM model's download, `width` wide. The label is a percentage once the size is known and
|
||||
/// just the model's name before, so the bar keeps its height and its accessible name.
|
||||
pub(crate) fn sam_download_bar(ui: &mut egui::Ui, done: u64, total: u64, width: f32) -> egui::Response {
|
||||
let fraction = crate::progress::fraction_of(done, total);
|
||||
let label = match fraction {
|
||||
Some(f) => format!("SAM 3: {} %", crate::progress::percent(f)),
|
||||
None => "SAM 3".to_string(),
|
||||
};
|
||||
crate::progress::Progress::new(fraction).label(label).width(width).widget("maskSamProgress").show(ui)
|
||||
}
|
||||
|
||||
/// Whether the segmenter has work (loading, analyzing, a request, a detail pass running or waiting
|
||||
/// to start): nothing new starts then.
|
||||
fn segmenter_working(analyzing: bool, busy: bool, detail_busy: bool, detail_due: bool) -> bool {
|
||||
analyzing || busy || detail_busy || detail_due
|
||||
}
|
||||
|
||||
/// Enter in the Describe field is the Select button: it starts nothing while the segmenter works.
|
||||
fn enter_submits(working: bool, lost_focus: bool, enter_pressed: bool) -> bool {
|
||||
!working && lost_focus && enter_pressed
|
||||
}
|
||||
|
||||
/// The Describe field: type what to select ("sky", "the red car") and press Return.
|
||||
fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
let Some((op, mut text)) = app.ui.describe.clone() else { return };
|
||||
@@ -777,7 +800,9 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
let mut submit = false;
|
||||
// the (translated) Select button is placed first, at the right; the field takes what is left
|
||||
ui.allocate_ui_with_layout(vec2(ui.available_width(), 24.0), egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
if text_button(ui, "maskDescribeGo", crate::i18n::tr("Select"), false).clicked() {
|
||||
let seg = &app.session.segmenter;
|
||||
let working = segmenter_working(seg.analyzing(), seg.busy(), seg.detail_busy(), app.ui.detail_due.is_some());
|
||||
if crate::button::Button::new("Select").small().busy(working).widget("button:maskDescribeGo").show(ui).clicked() {
|
||||
submit = true;
|
||||
}
|
||||
let r = ui.add(
|
||||
@@ -789,7 +814,8 @@ fn describe_field(app: &mut LightcraftApp, ui: &mut egui::Ui, new_mask: bool) {
|
||||
if !r.has_focus() && !r.lost_focus() && text.is_empty() {
|
||||
r.request_focus();
|
||||
}
|
||||
if r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||
// Enter is the Select button: nothing starts while the segmenter is working
|
||||
if enter_submits(working, r.lost_focus(), ui.input(|i| i.key_pressed(egui::Key::Enter))) {
|
||||
submit = true;
|
||||
}
|
||||
});
|
||||
@@ -897,6 +923,28 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_detail_pass_counts_as_the_segmenter_working() {
|
||||
assert!(segmenter_working(false, false, true, false));
|
||||
assert!(segmenter_working(true, false, false, false));
|
||||
assert!(segmenter_working(false, true, false, false));
|
||||
assert!(!segmenter_working(false, false, false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pending_detail_pass_counts_as_the_segmenter_working() {
|
||||
// a new prompt finishing before it starts would replace its target, and that mask never refines
|
||||
assert!(segmenter_working(false, false, false, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enter_in_the_describe_field_starts_nothing_while_the_segmenter_works() {
|
||||
assert!(enter_submits(false, true, true));
|
||||
assert!(!enter_submits(true, true, true));
|
||||
assert!(!enter_submits(false, false, true));
|
||||
assert!(!enter_submits(false, true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masking_context_actions_use_catalog_translations() {
|
||||
let saved = crate::i18n::language();
|
||||
|
||||
@@ -856,6 +856,8 @@ fn paint_cell(
|
||||
clip.add(egui::Shape::mesh(mesh));
|
||||
}
|
||||
None => {
|
||||
// the page is paper in every theme (its colour is the print's, not the theme's), so
|
||||
// these stay fixed greys: a photo still to come, and an empty cell
|
||||
clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Work in progress, shown one way everywhere: a [`Progress`] bar (a known fraction, or a gentle
|
||||
//! sweep when nothing is known yet) and a [`spinner`] for short waits beside text or in a busy
|
||||
//! [`crate::button::Button`]. It replaces `egui::ProgressBar` and `ui.spinner()`.
|
||||
//!
|
||||
//! ```ignore
|
||||
//! Progress::new(Some(done as f32 / total as f32)).label(text).widget("denoise:progress").show(ui);
|
||||
//! Progress::new(None).label(tr("Starting…")).show(ui);
|
||||
//! ui.horizontal(|ui| { spinner(ui, 14.0); ui.label(tr("Selecting…")); });
|
||||
//! ```
|
||||
//!
|
||||
//! A bar announces itself to screen readers as a progress indicator with its percentage. Motion
|
||||
//! asks for repaints only while it is on screen. There is no reduced-motion setting in the app,
|
||||
//! so the motion is kept slow and small: one gentle sweep, one slow turning arc.
|
||||
|
||||
use std::f32::consts::TAU;
|
||||
|
||||
use egui::{Color32, Pos2, Rect, Response, Sense, Shape, Stroke, Ui, pos2, vec2};
|
||||
|
||||
use crate::theme::Tokens;
|
||||
|
||||
/// A bar in a model row: it leaves the row's Cancel button its place.
|
||||
pub const ROW_WIDTH: f32 = 260.0;
|
||||
/// The narrowest a bar that fills the available width is drawn.
|
||||
const MIN_WIDTH: f32 = 96.0;
|
||||
/// Height of a bar that carries text.
|
||||
const HEIGHT_WITH_TEXT: f32 = 18.0;
|
||||
/// Height of a bar without text.
|
||||
const HEIGHT_PLAIN: f32 = 6.0;
|
||||
/// Seconds an indeterminate bar takes to sweep across and back.
|
||||
const SWEEP_SECONDS: f64 = 1.8;
|
||||
/// Seconds a spinner takes for one turn.
|
||||
const TURN_SECONDS: f64 = 1.2;
|
||||
/// How often moving things redraw.
|
||||
const FRAME: std::time::Duration = std::time::Duration::from_millis(33);
|
||||
|
||||
/// `fraction` as a number from 0 to 1: NaN counts as nothing done, and out-of-range values stop at
|
||||
/// the ends.
|
||||
pub fn clamp_fraction(fraction: f32) -> f32 {
|
||||
if fraction.is_nan() { 0.0 } else { fraction.clamp(0.0, 1.0) }
|
||||
}
|
||||
|
||||
/// `done` of `total` as a fraction, or `None` while the total is not known (0): an unknown total
|
||||
/// is a bar that sweeps, not one stuck at 0 %.
|
||||
pub fn fraction_of(done: u64, total: u64) -> Option<f32> {
|
||||
(total > 0).then(|| clamp_fraction(done as f32 / total as f32))
|
||||
}
|
||||
|
||||
/// The whole percent a bar says for `fraction`: rounded down, so 100 comes only at exactly 1.0, but
|
||||
/// with a little slack so a fraction that is exact in decimals (53 of 100, held as 0.52999997)
|
||||
/// still says 53. A bar's text and its accessible value both come from here, so they agree.
|
||||
pub fn percent(fraction: f32) -> u32 {
|
||||
let f = clamp_fraction(fraction);
|
||||
let whole = (f64::from(f) * 100.0 + 1e-3).floor() as u32;
|
||||
if f < 1.0 { whole.min(99) } else { 100 }
|
||||
}
|
||||
|
||||
/// A progress bar, built and then shown with [`Progress::show`].
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Progress {
|
||||
fraction: Option<f32>,
|
||||
label: Option<String>,
|
||||
widget: Option<String>,
|
||||
width: Option<f32>,
|
||||
dimmed: bool,
|
||||
}
|
||||
|
||||
impl Progress {
|
||||
/// `Some(fraction)` of the work done (clamped to 0..=1, NaN as 0), or `None` while it is not
|
||||
/// known how much there is.
|
||||
pub fn new(fraction: Option<f32>) -> Self {
|
||||
Progress { fraction: fraction.map(clamp_fraction), label: None, widget: None, width: None, dimmed: false }
|
||||
}
|
||||
|
||||
/// The fraction drawn, once clamped (`None` for an indeterminate bar).
|
||||
pub fn fraction(&self) -> Option<f32> {
|
||||
self.fraction
|
||||
}
|
||||
|
||||
/// Text on the bar (a percentage, a count, a file's name), already translated.
|
||||
pub fn label(mut self, text: impl Into<String>) -> Self {
|
||||
self.label = Some(text.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Registered under this id for scripts, tests and `ui.clickWidget` (e.g. `progress:samDownload`).
|
||||
pub fn widget(mut self, id: impl Into<String>) -> Self {
|
||||
self.widget = Some(id.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Drawn in the quiet text colour instead of the accent (work that is being stopped).
|
||||
pub fn dimmed(mut self, dimmed: bool) -> Self {
|
||||
self.dimmed = dimmed;
|
||||
self
|
||||
}
|
||||
|
||||
/// This wide; by default the bar fills the width the layout has left.
|
||||
pub fn width(mut self, width: f32) -> Self {
|
||||
self.width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
/// Show it where the layout puts it.
|
||||
pub fn show(self, ui: &mut Ui) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let label = self.label.as_deref().filter(|s| !s.is_empty());
|
||||
let width = self.width.unwrap_or_else(|| ui.available_size_before_wrap().x.max(MIN_WIDTH));
|
||||
let height = if label.is_some() { HEIGHT_WITH_TEXT } else { HEIGHT_PLAIN };
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
|
||||
resp.widget_info(|| {
|
||||
let mut info = match label {
|
||||
Some(l) => egui::WidgetInfo::labeled(egui::WidgetType::ProgressIndicator, ui.is_enabled(), l),
|
||||
None => egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator),
|
||||
};
|
||||
info.value = self.fraction.map(|f| f64::from(percent(f)));
|
||||
info
|
||||
});
|
||||
if let Some(w) = &self.widget {
|
||||
crate::widgets::register(ui.ctx(), w.clone(), rect);
|
||||
}
|
||||
if !ui.is_rect_visible(rect) {
|
||||
return resp;
|
||||
}
|
||||
let p = ui.painter_at(rect);
|
||||
let radius = height / 2.0;
|
||||
p.rect_filled(rect, radius, t.inset);
|
||||
p.rect_stroke(rect, radius, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside);
|
||||
let inner = rect.shrink(1.0);
|
||||
let fill = match self.fraction {
|
||||
Some(f) => Rect::from_min_size(inner.min, vec2(inner.width() * f, inner.height())),
|
||||
None => {
|
||||
// a short bar sweeping across and back, eased so it slows at the ends
|
||||
let phase = (ui.input(|i| i.time) / SWEEP_SECONDS).fract() as f32;
|
||||
let eased = 0.5 - 0.5 * (phase * TAU).cos();
|
||||
let len = inner.width() * 0.3;
|
||||
let x = inner.left() + (inner.width() - len) * eased;
|
||||
ui.ctx().request_repaint_after(FRAME);
|
||||
Rect::from_min_size(pos2(x, inner.top()), vec2(len, inner.height()))
|
||||
}
|
||||
};
|
||||
if fill.width() > 0.0 {
|
||||
p.with_clip_rect(inner).rect_filled(fill, radius - 1.0, if self.dimmed { t.text_dim } else { t.accent });
|
||||
}
|
||||
if let Some(l) = label {
|
||||
// the text reads on the track in the theme's colour, and in white where the fill is
|
||||
let font = t.font(11.5);
|
||||
let on_track = p.layout_no_wrap(l.to_string(), font.clone(), t.text);
|
||||
let on_fill = p.layout_no_wrap(l.to_string(), font, Color32::WHITE);
|
||||
let at = pos2(rect.center().x - on_track.size().x / 2.0, rect.center().y - on_track.size().y / 2.0);
|
||||
let clipped = ui.painter_at(rect);
|
||||
clipped.galley(at, on_track, t.text);
|
||||
clipped.with_clip_rect(rect.intersect(fill)).galley(at, on_fill, Color32::WHITE);
|
||||
}
|
||||
resp
|
||||
}
|
||||
}
|
||||
|
||||
/// The arc a spinner draws at `time` seconds: points on a circle of `radius` around `center`,
|
||||
/// covering three quarters of the turn and rotating once per [`TURN_SECONDS`].
|
||||
fn arc_points(center: Pos2, radius: f32, time: f64) -> Vec<Pos2> {
|
||||
let start = ((time / TURN_SECONDS).fract() as f32) * TAU;
|
||||
let steps = 24;
|
||||
(0..=steps)
|
||||
.map(|i| {
|
||||
let a = start + TAU * 0.75 * (i as f32 / steps as f32);
|
||||
center + vec2(a.cos(), a.sin()) * radius
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Draw a spinner of `size` points centred in `rect`, in `color`, at `time` seconds. Callers that
|
||||
/// draw by hand (a busy button) use this; it asks for no repaint itself.
|
||||
pub fn paint_spinner(p: &egui::Painter, rect: Rect, color: Color32, time: f64) {
|
||||
let size = rect.width().min(rect.height());
|
||||
let width = (size / 8.0).max(1.5);
|
||||
let radius = ((size - width) / 2.0).max(0.5);
|
||||
p.add(Shape::line(arc_points(rect.center(), radius, time), Stroke::new(width, color)));
|
||||
}
|
||||
|
||||
/// A small turning arc beside text that says what is being waited for. It is drawn as a vector
|
||||
/// arc, not a glyph, and asks for repaints only while it is on screen.
|
||||
pub fn spinner(ui: &mut Ui, size: f32) -> Response {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(size, size), Sense::hover());
|
||||
resp.widget_info(|| egui::WidgetInfo::new(egui::WidgetType::ProgressIndicator));
|
||||
if ui.is_rect_visible(rect) {
|
||||
paint_spinner(ui.painter(), rect, t.text_dim, ui.input(|i| i.time));
|
||||
ui.ctx().request_repaint_after(FRAME);
|
||||
}
|
||||
resp
|
||||
}
|
||||
@@ -169,7 +169,7 @@ pub fn body(app: &LightcraftApp, ui: &mut egui::Ui, dlg: &mut Dialog) {
|
||||
ui.add_space(6.0);
|
||||
let Some(c) = counts.as_ref() else {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
crate::progress::spinner(ui, 14.0);
|
||||
match progress(app) {
|
||||
Some(ScanStage::Listing(folders, files)) => ui.label(listing_text(folders, files)),
|
||||
Some(ScanStage::Checking(done, total)) if total > 0 => ui.label(format!("{} {done} / {total}", crate::i18n::tr("Scanning…"))),
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Progress bars, spinners and busy buttons: the fraction is always a sane number, a bar is
|
||||
//! announced as progress with its percentage and reachable by its id, a busy button is announced
|
||||
//! busy, ignores clicks and keeps its width, and a thumbnail's placeholder stands apart from what
|
||||
//! it sits on in every theme.
|
||||
|
||||
use egui::{Pos2, Rect, pos2, vec2};
|
||||
|
||||
use crate::button::{Button, contrast};
|
||||
use crate::icons::Icon;
|
||||
use crate::progress::{Progress, clamp_fraction, fraction_of, spinner};
|
||||
use crate::theme::{ThemeKind, Tokens};
|
||||
|
||||
/// What a screen reader gets for one node.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct Node {
|
||||
role: String,
|
||||
label: String,
|
||||
busy: bool,
|
||||
value: Option<f64>,
|
||||
}
|
||||
|
||||
/// Run three frames of `draw` (pointer `events` in the second); return the nodes of the last
|
||||
/// frame with an accessibility tree, the automation ids registered in the last frame, and what
|
||||
/// `draw` returned.
|
||||
fn frames<T: Default>(events: Vec<egui::Event>, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<Node>, Vec<(String, Rect)>, T) {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
ctx.enable_accesskit();
|
||||
let (mut nodes, mut ids, mut last) = (Vec::new(), Vec::new(), T::default());
|
||||
for frame in 0..3 {
|
||||
let raw = egui::RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
|
||||
events: if frame == 1 { events.clone() } else { Vec::new() },
|
||||
..Default::default()
|
||||
};
|
||||
let mut out = ctx.run_ui(raw, |ui| last = draw(ui));
|
||||
out.textures_delta.clear();
|
||||
ids = crate::widgets::take_registry(&ctx);
|
||||
if let Some(update) = out.platform_output.accesskit_update.take() {
|
||||
nodes = update
|
||||
.nodes
|
||||
.iter()
|
||||
.map(|(_, n)| Node {
|
||||
role: format!("{:?}", n.role()),
|
||||
label: n.label().unwrap_or_default().to_string(),
|
||||
busy: n.is_busy(),
|
||||
value: n.numeric_value(),
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
(nodes, ids, last)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_fraction_is_always_between_nothing_and_all() {
|
||||
assert_eq!(clamp_fraction(f32::NAN), 0.0);
|
||||
assert_eq!(clamp_fraction(-3.0), 0.0);
|
||||
assert_eq!(clamp_fraction(7.5), 1.0);
|
||||
assert_eq!(clamp_fraction(f32::INFINITY), 1.0);
|
||||
assert_eq!(clamp_fraction(f32::NEG_INFINITY), 0.0);
|
||||
assert_eq!(clamp_fraction(0.25), 0.25);
|
||||
assert_eq!(Progress::new(Some(f32::NAN)).fraction(), Some(0.0));
|
||||
assert_eq!(Progress::new(Some(2.0)).fraction(), Some(1.0));
|
||||
assert_eq!(Progress::new(None).fraction(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_total_is_an_indeterminate_bar_not_an_empty_one() {
|
||||
assert_eq!(fraction_of(0, 0), None);
|
||||
assert_eq!(fraction_of(5, 0), None);
|
||||
assert_eq!(fraction_of(0, 10), Some(0.0));
|
||||
assert_eq!(fraction_of(5, 10), Some(0.5));
|
||||
assert_eq!(fraction_of(20, 10), Some(1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bar_is_progress_with_a_value_and_reachable_by_its_id() {
|
||||
let (nodes, ids, _) = frames(Vec::new(), |ui| {
|
||||
Progress::new(Some(0.42)).label("Making the denoised picture… 42%").widget("denoise:progress").show(ui);
|
||||
});
|
||||
let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("no progress node: {nodes:?}"));
|
||||
assert_eq!(bar.label, "Making the denoised picture… 42%");
|
||||
assert_eq!(bar.value, Some(42.0));
|
||||
assert!(ids.iter().any(|(id, r)| id == "denoise:progress" && r.width() > 0.0), "{ids:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_indeterminate_bar_says_progress_without_a_value_and_is_drawn() {
|
||||
let (nodes, ids, width) = frames(Vec::new(), |ui| Progress::new(None).label("Starting…").widget("p:x").show(ui).rect.width());
|
||||
let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}"));
|
||||
assert_eq!(bar.value, None);
|
||||
assert!(ids.iter().any(|(id, _)| id == "p:x"));
|
||||
assert!(width >= 96.0, "fills the width the layout has");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bar_fills_the_layout_unless_given_a_width() {
|
||||
let (_, _, (fill, fixed, tiny_room)) = frames(Vec::new(), |ui| {
|
||||
let fill = Progress::new(Some(0.5)).show(ui).rect.width();
|
||||
let fixed = Progress::new(Some(0.5)).width(150.0).show(ui).rect.width();
|
||||
let tiny_room = ui.scope(|ui| {
|
||||
ui.set_max_width(40.0);
|
||||
Progress::new(Some(0.5)).show(ui).rect.width()
|
||||
});
|
||||
(fill, fixed, tiny_room.inner)
|
||||
});
|
||||
assert!(fill > 300.0, "fills: {fill}");
|
||||
assert_eq!(fixed, 150.0);
|
||||
assert!(tiny_room >= 96.0, "never a sliver: {tiny_room}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_spinner_takes_the_room_it_is_given() {
|
||||
let (_, _, size) = frames(Vec::new(), |ui| spinner(ui, 14.0).rect.size());
|
||||
assert_eq!(size, vec2(14.0, 14.0));
|
||||
}
|
||||
|
||||
fn click_at(at: Pos2) -> Vec<egui::Event> {
|
||||
vec![
|
||||
egui::Event::PointerMoved(at),
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() },
|
||||
egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() },
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_button_ignores_clicks_and_is_announced_busy() {
|
||||
let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0));
|
||||
let click = |busy: bool| {
|
||||
let mut clicked = false;
|
||||
let (nodes, _, ()) = frames(click_at(pos2(40.0, 15.0)), |ui| clicked |= Button::new("Analyze").busy(busy).show_at(ui, place).clicked());
|
||||
(nodes, clicked)
|
||||
};
|
||||
let (nodes, clicked) = click(true);
|
||||
assert!(!clicked, "a busy button does nothing");
|
||||
let node = nodes.iter().find(|n| n.role == "Button" && n.label == "Analyze").unwrap_or_else(|| panic!("{nodes:?}"));
|
||||
assert!(node.busy, "announced busy");
|
||||
|
||||
let (nodes, clicked) = click(false);
|
||||
assert!(clicked, "the same button, not busy, is clicked");
|
||||
assert!(!nodes.iter().any(|n| n.busy), "{nodes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_busy_button_stays_reachable_by_the_keyboard() {
|
||||
let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0));
|
||||
let mut asked = false;
|
||||
let (_, _, focused) = frames(Vec::new(), |ui| {
|
||||
let r = Button::new("Analyze").busy(true).show_at(ui, place);
|
||||
if !std::mem::replace(&mut asked, true) {
|
||||
r.request_focus();
|
||||
}
|
||||
r.has_focus()
|
||||
});
|
||||
assert!(focused, "focus is kept while it works");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_button_keeps_its_width_while_busy() {
|
||||
for (text, icon) in [("Analyze", None), ("Analyze", Some(Icon::Trash)), ("", Some(Icon::Trash))] {
|
||||
for small in [false, true] {
|
||||
let (_, _, (idle, busy)) = frames(Vec::new(), |ui| {
|
||||
let mut make = |b: bool| {
|
||||
let mut btn = if text.is_empty() { Button::icon_only(Icon::Trash, "Remove") } else { Button::new(text) };
|
||||
if let Some(i) = icon {
|
||||
btn = btn.icon(i);
|
||||
}
|
||||
if small {
|
||||
btn = btn.small();
|
||||
}
|
||||
btn.busy(b).show(ui).rect.size()
|
||||
};
|
||||
(make(false), make(true))
|
||||
});
|
||||
assert_eq!(idle, busy, "{text:?} {icon:?} small={small}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_thumbnail_placeholder_stands_apart_in_every_theme() {
|
||||
for kind in ThemeKind::ALL {
|
||||
let t = Tokens::for_kind(kind);
|
||||
for (name, behind) in [("grid", t.grid_bg), ("cell", t.cell), ("selected cell", t.cell_selected), ("canvas", t.canvas)] {
|
||||
let c = contrast(t.skeleton, behind);
|
||||
// the light themes' surfaces are close together, so the placeholder has to be set
|
||||
// further off them to read at all
|
||||
let floor = if matches!(kind, ThemeKind::Silver | ThemeKind::Paper) { 1.15 } else { 1.1 };
|
||||
assert!(c >= floor, "{kind:?}: the placeholder on the {name} is {c:.2}:1");
|
||||
}
|
||||
let mark = contrast(t.text, t.skeleton);
|
||||
assert!(mark >= 3.0, "{kind:?}: the failure mark on the placeholder is {mark:.2}:1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_filmstrip_thumbnail_is_announced_with_its_reason() {
|
||||
let spoken = |failed: bool| {
|
||||
let (nodes, _, _) = frames(Vec::new(), |ui| {
|
||||
let resp = ui.interact(Rect::from_min_size(pos2(10.0, 10.0), vec2(120.0, 80.0)), egui::Id::new("film-test"), egui::Sense::click());
|
||||
crate::widgets::announce(&resp, crate::panels::detail::film_spoken("IMG_0001.jpg", failed));
|
||||
});
|
||||
nodes.into_iter().find(|n| n.role == "Button").map(|n| n.label)
|
||||
};
|
||||
assert_eq!(spoken(false).as_deref(), Some("IMG_0001.jpg"));
|
||||
assert_eq!(spoken(true).as_deref(), Some("IMG_0001.jpg, This photo can't be opened"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_filmstrip_thumbnail_without_a_file_name_has_no_leading_comma() {
|
||||
assert_eq!(crate::panels::detail::film_spoken("", true), "This photo can't be opened");
|
||||
assert_eq!(crate::panels::detail::film_spoken("", false), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sam_bar_keeps_its_name_and_height_while_the_size_is_unknown() {
|
||||
let (nodes, _, (known, unknown)) = frames(Vec::new(), |ui| {
|
||||
let known = crate::panels::masking::sam_download_bar(ui, 5, 10, 200.0).rect.height();
|
||||
let unknown = crate::panels::masking::sam_download_bar(ui, 0, 0, 200.0).rect.height();
|
||||
(known, unknown)
|
||||
});
|
||||
assert_eq!(known, unknown);
|
||||
let bars: Vec<_> = nodes.iter().filter(|n| n.role == "ProgressIndicator").collect();
|
||||
assert_eq!(bars.len(), 2, "{nodes:?}");
|
||||
assert!(bars.iter().all(|b| b.label.starts_with("SAM 3")), "{bars:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_bars_keep_their_widths_in_a_wide_container() {
|
||||
let (_, ids, (detail, settings, unknown)) = frames(Vec::new(), |ui| {
|
||||
let detail = crate::panels::denoise::detail_bar(ui, 3, 10).rect.width();
|
||||
let settings = crate::panels::denoise::settings_bar(ui, 3, 10, "Denoising a.jpg: 3 of 10 tiles".into()).rect.width();
|
||||
let unknown = crate::panels::denoise::detail_bar(ui, 0, 0).rect.width();
|
||||
(detail, settings, unknown)
|
||||
});
|
||||
assert_eq!((detail, settings, unknown), (240.0, 380.0, 240.0));
|
||||
assert!(ids.iter().any(|(id, _)| id == "denoise:settingsProgress"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_denoise_bar_label_and_its_accessible_value_agree() {
|
||||
// the number drawn on the bar is the one announced, and exact decimals stay exact
|
||||
for (done, total, says) in
|
||||
[(3, 7, 42.0), (53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (99, 100, 99.0), (100, 100, 100.0), (999, 1000, 99.0)]
|
||||
{
|
||||
let (nodes, _, _) = frames(Vec::new(), |ui| {
|
||||
crate::panels::denoise::detail_bar(ui, done, total);
|
||||
});
|
||||
let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}"));
|
||||
let shown: f64 = bar.label.rsplit(' ').next().unwrap().trim_end_matches('%').parse().unwrap();
|
||||
assert_eq!((shown, bar.value), (says, Some(says)), "{done}/{total}: {bar:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_sam_bar_text_and_its_accessible_value_agree() {
|
||||
for (done, total, says) in [(53, 100, 53.0), (59, 100, 59.0), (29, 100, 29.0), (999, 1000, 99.0), (100, 100, 100.0)] {
|
||||
let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::masking::sam_download_bar(ui, done, total, 200.0).rect.width());
|
||||
let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}"));
|
||||
assert_eq!(bar.label, format!("SAM 3: {says} %"), "{done}/{total}");
|
||||
assert_eq!(bar.value, Some(says), "{done}/{total}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_activity_bar_is_named_after_its_task() {
|
||||
let task = lightcraft_engine::activity::TaskInfo {
|
||||
id: 1,
|
||||
kind: "export",
|
||||
label: "Exporting".into(),
|
||||
done: 3,
|
||||
total: 25,
|
||||
unit: lightcraft_engine::activity::Unit::Count,
|
||||
detail: String::new(),
|
||||
cancellable: true,
|
||||
cancelling: false,
|
||||
age_ms: 600,
|
||||
};
|
||||
let (nodes, _, _) = frames(Vec::new(), |ui| crate::panels::activity::bar(ui, &task, 200.0).rect.width());
|
||||
let bar = nodes.iter().find(|n| n.role == "ProgressIndicator").unwrap_or_else(|| panic!("{nodes:?}"));
|
||||
assert_eq!(bar.label, "Exporting");
|
||||
assert_eq!(bar.value, Some(12.0));
|
||||
}
|
||||
@@ -44,6 +44,9 @@ pub struct Tokens {
|
||||
pub grid_bg: Color32,
|
||||
pub cell: Color32,
|
||||
pub cell_selected: Color32,
|
||||
/// A thumbnail's place while its picture is still being made: static, apart from the grid, the
|
||||
/// cells and the canvas in every theme.
|
||||
pub skeleton: Color32,
|
||||
pub divider: Color32,
|
||||
pub inset: Color32,
|
||||
pub field: Color32,
|
||||
@@ -125,6 +128,7 @@ impl Default for Tokens {
|
||||
grid_bg: Color32::from_rgb(0x0f, 0x0f, 0x0f),
|
||||
cell: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
||||
cell_selected: Color32::from_rgb(0x2d, 0x2d, 0x2d),
|
||||
skeleton: Color32::from_rgb(0x25, 0x25, 0x25),
|
||||
divider: Color32::from_rgb(0x1c, 0x1c, 0x1c),
|
||||
inset: Color32::from_rgb(0x23, 0x23, 0x23),
|
||||
field: Color32::from_rgb(0x22, 0x22, 0x22),
|
||||
@@ -172,6 +176,7 @@ impl Tokens {
|
||||
grid_bg: rgb(0x080808),
|
||||
cell: rgb(0x121212),
|
||||
cell_selected: rgb(0x262626),
|
||||
skeleton: rgb(0x1d1d1d),
|
||||
divider: rgb(0x0e0e0e),
|
||||
inset: rgb(0x181818),
|
||||
field: rgb(0x161616),
|
||||
@@ -189,6 +194,7 @@ impl Tokens {
|
||||
grid_bg: rgb(0xd9d9d9),
|
||||
cell: rgb(0xcbcbcb),
|
||||
cell_selected: rgb(0xb4b4b4),
|
||||
skeleton: rgb(0xa6a6a6),
|
||||
divider: rgb(0xc4c4c4),
|
||||
inset: rgb(0xdcdcdc),
|
||||
field: rgb(0xf6f6f6),
|
||||
@@ -219,6 +225,7 @@ impl Tokens {
|
||||
grid_bg: rgb(0xf2f2f2),
|
||||
cell: rgb(0xe8e8e8),
|
||||
cell_selected: rgb(0xd2d2d2),
|
||||
skeleton: rgb(0xbcbcbc),
|
||||
divider: rgb(0xe0e0e0),
|
||||
inset: rgb(0xf0f0f0),
|
||||
field: rgb(0xffffff),
|
||||
|
||||
@@ -461,7 +461,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-BEHAV-BATCH | Batch apply to selection | P0 | ✅ | `cmd:photo.rate`, `cmd:develop.paste`, `cmd:preset.apply`, `cmd:app.export` | |
|
||||
| LR-BEHAV-PREVIEW-HOVER | Hover previews | P1 | ✅ | `crates/ui-egui/src/panels/presets.rs`, `crates/ui-egui/src/panels/profiles.rs`, `crates/ui-egui/src/panels/right.rs` (`versions`), `crates/ui-egui/src/panels/detail.rs` | presets, profile menu, profile browser and versions preview in the loupe |
|
||||
| LR-BEHAV-PROGRESSIVE | Progressive rendering | P0 | ✅ | `crates/engine/src/media.rs`, `crates/preview/src/lib.rs` | |
|
||||
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |
|
||||
| LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `crates/ui-egui/src/progress.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise, Find Missing Photos and Reload from Disk (`cmd:photo.reload`, its files decoded on a worker thread, cancellable; `wait: true` for agents) each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) |
|
||||
| LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline |
|
||||
| LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) |
|
||||
| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) |
|
||||
@@ -927,3 +927,4 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
| 2026-10-16 | minor | LR-VIEW-PHOTOGRID: when the photos shown change by those an edit touched (an import's batch, a capture time, a crop), the grid reads only those photos' dates and shapes again instead of every photo of the list (a 16-photo batch at 181,749 photos: grid frame 101 ms → 7 ms); status unchanged |
|
||||
| 2026-10-19 | minor | LRC-LIB-LIBFOLDERS: the sidebar trees indent by a narrower step (14 / 12 / 10 / 8 pt) when the deepest open row would not fit the panel, instead of cutting its name or scrolling sideways at once (issue #2298); status unchanged (✅) |
|
||||
| 2026-10-15 | patch | LR-EXP-DIM: Width & Height (`fit: widthHeight`) limits the width and the height as the photo stands, beside Dimensions (W × H, either orientation), as in Lightroom (#1620); status unchanged (✅) |
|
||||
| 2026-10-16 | patch | LR-BEHAV-BG-TASKS: progress bars and spinners come from one component (`progress.rs`): the activity stack, model downloads, denoise and the face scan share it; busy buttons and a themed thumbnail placeholder added; status unchanged |
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
- The stars in the bottom bar, the Info panel and the filter bar are now one shared control, and the badges on grid
|
||||
cells, filmstrip thumbnails and the Compare caption use the same drawing. Hovering a star in the filter bar now
|
||||
previews the rating like the others do, and with a star focused (Tab), ← and → lower and raise the rating.
|
||||
### Progress looks the same everywhere
|
||||
- Download, scan, denoise and activity progress now use one bar that fills the width it is given, with its text on
|
||||
it, and a gentle sweep while the amount of work isn't known yet. Waits that only said "Selecting…", "Waiting to
|
||||
start…" or "Installing…" now show a small turning arc beside the text, and a thumbnail still being made shows a
|
||||
quiet placeholder that fits the theme in the grid, the filmstrip and Compare. Screen readers hear a progress
|
||||
indicator with its percentage.
|
||||
|
||||
### Sharper resampling in HDR and panorama merges
|
||||
- Merged photos (HDR and panorama) are resampled with a sharper filter (Lanczos-3 instead of bilinear). Moving a
|
||||
|
||||
Reference in New Issue
Block a user