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