From 2d1d7e146f4e19cb4c0e9c10ac84ae0ce28de50f Mon Sep 17 00:00:00 2001 From: Vincent Bernardoff Date: Sat, 17 Oct 2026 18:19:44 +0200 Subject: [PATCH] fix: decode cached images by content and wrap tooltip text Detect still-image formats from bytes because media cache filenames have no extensions. Keep small sources at their native size. Constrain tooltip text to its flex slot so failure reasons wrap within the background. Cover extensionless PNG, JPEG, and WebP files and update the design docs. All 309 GUI tests pass, with one ignored. Co-Authored-By: GPT-6 --- crates/tinyclient/src/gui/components/overlay.rs | 4 ++-- .../tinyclient/src/gui/conversation/images.rs | 17 +++++++++++++++-- docs/design/polish/01-system.md | 4 +++- docs/design/polish/06-composer.md | 3 +++ 4 files changed, 23 insertions(+), 5 deletions(-) diff --git a/crates/tinyclient/src/gui/components/overlay.rs b/crates/tinyclient/src/gui/components/overlay.rs index 9fbc7ec..7225a94 100644 --- a/crates/tinyclient/src/gui/components/overlay.rs +++ b/crates/tinyclient/src/gui/components/overlay.rs @@ -30,8 +30,8 @@ impl Render for Tip { .font_family(theme::FONT) .type_small() .text_color(theme::text()) - .child(self.words.clone()) - .children(self.shortcut.clone().map(|keys| div().text_color(theme::text_faint()).child(keys))) + .child(div().flex_1().min_w_0().whitespace_normal().overflow_hidden().child(self.words.clone())) + .children(self.shortcut.clone().map(|keys| div().flex_none().whitespace_nowrap().text_color(theme::text_faint()).child(keys))) } } diff --git a/crates/tinyclient/src/gui/conversation/images.rs b/crates/tinyclient/src/gui/conversation/images.rs index a319bec..ed32737 100644 --- a/crates/tinyclient/src/gui/conversation/images.rs +++ b/crates/tinyclient/src/gui/conversation/images.rs @@ -139,9 +139,10 @@ pub fn decode(path: &Path, edge: u32, crop: Option) -> Option { #[cfg(not(target_os = "macos"))] fn decode_portable(path: &Path, edge: u32, crop: Option) -> Option { - let edge = edge.clamp(1, 8192); - let mut img = image::open(path).ok()?.into_rgba8(); + let mut img = image::ImageReader::open(path).ok()?.with_guessed_format().ok()?.decode().ok()?.into_rgba8(); let (w, h) = (img.width(), img.height()); + // Cache paths have no suffix, and a requested thumbnail must not enlarge a small source. + let edge = edge.clamp(1, 8192).min(w.max(h)); let (nw, nh) = if w >= h { let nh = ((h as u64 * edge as u64) / w as u64).max(1) as u32; (edge, nh) @@ -693,6 +694,18 @@ mod tests { Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures").join(name) } + #[test] + fn cached_images_decode_without_filename_extensions() { + for (ix, format) in [image::ImageFormat::Png, image::ImageFormat::Jpeg, image::ImageFormat::WebP].into_iter().enumerate() { + let path = std::env::temp_dir().join(format!("tc-extensionless-{}-{ix}", std::process::id())); + let image = image::RgbImage::from_pixel(32, 16, image::Rgb([220, 40, 60])); + image.save_with_format(&path, format).unwrap(); + let decoded = decode(&path, 16, None).expect("cache files have no extension; use their bytes"); + assert_eq!((decoded.width, decoded.height), (16, 8)); + std::fs::remove_file(path).unwrap(); + } + } + #[test] fn a_moving_picture_is_read_for_frames_only_when_they_are_wanted() { // Only these are read whole: a GIF, a WebP marked animated, a PNG with an animation chunk. diff --git a/docs/design/polish/01-system.md b/docs/design/polish/01-system.md index 8636479..f59d553 100644 --- a/docs/design/polish/01-system.md +++ b/docs/design/polish/01-system.md @@ -178,6 +178,8 @@ Items that do not apply are disabled, never hidden; names follow the open place - **Animates, only**: menu and popover in 120 / out 80 (opacity + 4 toward anchor); modal and toast in 160 / out 100 (opacity + 8 rise); scrollbar fade 150 after 800 idle; typing dots stepped at 400. Reduce Motion: all 0. Nothing else. - **Tooltips**: 500 delay, appear whole, 0 delay for 1.5 s after one closes, gone on mouse-down or leave. Every - icon-only button has one, with its shortcut in `text_faint`. + icon-only button has one, with its shortcut in `text_faint`. Text wraps within + the 240-point tooltip; the shortcut stays on one line beside it. Long failure + reasons must not paint outside the tooltip background. - **Focus**, keyboard only: rows and menu items a 2 inset `focus` ring over any fill; controls a 2 gap then a 2 ring; fields a `focus` border + 3 halo while focused. The list cursor is the ring, the open conversation the fill (`07` R8). diff --git a/docs/design/polish/06-composer.md b/docs/design/polish/06-composer.md index d0a8c0f..a73bd3f 100644 --- a/docs/design/polish/06-composer.md +++ b/docs/design/polish/06-composer.md @@ -182,6 +182,9 @@ removing a file (Discord) and replacing a photo (Telegram) are P2. are computed from the trigger bounds before layout. Grid = `uniform_list` of rows (9 emoji / 1 justified GIF row / 4 stickers), so ~12 rows exist; images decode in `Images` only for visible rows and drop on scroll-off; the entity and `media//pick/` die on close. +- **Still-image decode**: media cache filenames have no extension. On Linux and + Windows, detect the image format from its bytes, as on macOS. Thumbnail decoding + shrinks large sources but never enlarges a smaller source to the requested edge. - **GIF decode rule**: every cell is a first-frame still (`images.rs` reads index 0 only). The one hovered cell swaps to `img()` of the provider's smallest GIF rendition (Tenor `tinygif`, ~220 px): this rev's `img()` steps frames itself and stops under `reduce_motion()`. Played only if width × height × 4 × frames ≤ 8 MB, else it stays still; dropped