From c60482f75363dcc88c9d49aa0f489cba3dbfa654 Mon Sep 17 00:00:00 2001 From: wstock <1394687+wstock@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:39:52 +0000 Subject: [PATCH] Issue #266: filmstrip labels cut file names on characters, not bytes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The detail view's filmstrip shortened a long file name with `&name[..13]`, a byte slice. For a name with multi-byte characters byte 13 can fall inside a character (`202407層三限定訂閱圖(4)`: inside '限'), and the slice panicked the UI thread as soon as that photo scrolled into the strip, so the app quit. `film_label` now counts characters: more than 14 are cut to 13 and an ellipsis, as before for ASCII names. Tests: a unit test of `film_label` (CJK, exactly 14 characters, ASCII, empty) and a headless filmstrip test with the name from the issue; both panicked with the reported message before the fix. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/detail.rs | 26 +++++++++++++++++++++-- crates/ui-egui/src/tests_filmstrip.rs | 30 +++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 crates/ui-egui/src/tests_filmstrip.rs diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index b7f4d6d6d..3f0237aad 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -29,6 +29,8 @@ pub mod widgets; #[cfg(test)] mod tests_curve; #[cfg(test)] +mod tests_filmstrip; +#[cfg(test)] mod tests_grid; #[cfg(test)] mod tests_library_problem; diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index 3b3622bed..0dc17cc98 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -1493,6 +1493,12 @@ fn eye_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response // ------------------------------------------------------------------------ filmstrip +/// A filmstrip cell's file-name label: names longer than 14 characters are cut to 13 and an +/// ellipsis. Counts characters, not bytes, so a CJK name is never cut inside a character (#266). +fn film_label(name: &str) -> String { + if name.chars().nth(14).is_some() { format!("{}…", name.chars().take(13).collect::()) } else { name.to_string() } +} + pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let t = Tokens::get(ui.ctx()); ui.painter().rect_filled(r, 0.0, t.canvas); @@ -1536,8 +1542,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { let names = app.ui.settings.film_names; if let Some(ph) = app.session.catalog.photo(*id).filter(|_| names) { let name = ph.file_name.rsplit_once('.').map(|(n, _)| n).unwrap_or(&ph.file_name); - let short: String = if name.len() > 14 { format!("{}…", &name[..13]) } else { name.to_string() }; - p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, short, t.font(10.0), t.text_dim); + p.text(pos2(cr.left() + 8.0, cr.top() + 10.0), Align2::LEFT_CENTER, film_label(name), t.font(10.0), t.text_dim); p.text(pos2(cr.right() - 8.0, cr.top() + 10.0), Align2::RIGHT_CENTER, &ph.format, t.semibold(8.5), t.text_dim); } let img_area = Rect::from_min_max(cr.min + vec2(10.0, 22.0), cr.max - vec2(10.0, 8.0)); @@ -1648,3 +1653,20 @@ fn straighten_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::R } } } + +#[cfg(test)] +mod tests { + use super::film_label; + + #[test] + fn film_labels_cut_on_characters_not_bytes() { + // the name from #266: byte 13 falls inside '限' + assert_eq!(film_label("202407層三限定訂閱圖(4)"), "202407層三限定訂閱圖…"); + assert_eq!(film_label("写真"), "写真"); + // exactly 14 characters (42 bytes) is shown whole + assert_eq!(film_label("一二三四五六七八九十一二三四"), "一二三四五六七八九十一二三四"); + assert_eq!(film_label("IMG_20240712_153012"), "IMG_20240712_…"); + assert_eq!(film_label("DSC_0001"), "DSC_0001"); + assert_eq!(film_label(""), ""); + } +} diff --git a/crates/ui-egui/src/tests_filmstrip.rs b/crates/ui-egui/src/tests_filmstrip.rs new file mode 100644 index 000000000..959c93484 --- /dev/null +++ b/crates/ui-egui/src/tests_filmstrip.rs @@ -0,0 +1,30 @@ +//! Headless test of the filmstrip's file-name labels (#266): a name with multi-byte (CJK) +//! characters is drawn without panicking. + +use std::time::Duration; + +use lightcraft_catalog::Op; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +#[test] +fn filmstrip_shows_a_cjk_file_name() { + let mut session = lightcraft_engine::Session::with_demo(); + let first = session.visible_cloned()[0]; + let source = session.catalog.photo(first).expect("demo photo").source.clone(); + // the name from #266: cutting it at byte 13 lands inside '限' + session.catalog.apply(Op::SetFile { id: first, file_name: "202407層三限定訂閱圖(4).jpg".into(), source }).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(h.app.ui.settings.film_names); + let film = format!("film:{}", first.0); + assert!(h.app.widgets.iter().any(|(w, _)| *w == film), "{film} drawn in the filmstrip"); +}