fix(avatars): cache image handles so avatars don't flicker on redraw

iced's `image::Handle::from_bytes` assigns a fresh *unique* id on every call
(unlike `from_path`, which hashes). `avatar_view` built the handle inline in
`view()`, so every repaint produced a "new" image and iced re-uploaded the
texture each frame. Any redraw triggered it — notably the redraws fired on
mouse movement — so visible avatars flickered constantly while the mouse moved.

Fix: cache handles by a content hash of the PNG bytes (thread-local, UI thread)
and reuse the same `Handle` across redraws, giving a stable texture id. Covers
both presets and custom uploads.

Build + clippy clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 17:13:00 -04:00
co-authored by Claude Opus 4.8
parent f029a30ea7
commit e917c5393f
+28 -1
View File
@@ -2915,6 +2915,33 @@ fn avatar_badge<'a>(name: &str, key: &str, size: f32) -> Element<'a, AppMessage>
.into()
}
thread_local! {
/// Cache of avatar image handles, keyed by a hash of the PNG bytes, so the
/// SAME `image::Handle` (and thus the same GPU texture id) is reused across
/// redraws. `image::Handle::from_bytes` mints a fresh *unique* id on every
/// call, so building handles inline in `view()` made iced re-upload the
/// texture on every repaint — including the redraws fired on each mouse move —
/// which showed up as constant flicker. Lives on the (single) UI thread.
static AVATAR_HANDLE_CACHE: std::cell::RefCell<HashMap<u64, iced::widget::image::Handle>> =
std::cell::RefCell::new(HashMap::new());
}
/// A stable image handle for these exact PNG bytes (cached by content hash), so
/// it keeps the same id across redraws — see [`AVATAR_HANDLE_CACHE`].
fn cached_image_handle(bytes: bytes::Bytes) -> iced::widget::image::Handle {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
bytes.as_ref().hash(&mut hasher);
let key = hasher.finish();
AVATAR_HANDLE_CACHE.with(|cache| {
cache
.borrow_mut()
.entry(key)
.or_insert_with(|| iced::widget::image::Handle::from_bytes(bytes))
.clone()
})
}
/// Render a participant's avatar (W4): the chosen preset image if any, else the
/// monogram fallback. `name`/`key` feed the monogram; `size` is the diameter.
fn avatar_view<'a>(
@@ -2928,7 +2955,7 @@ fn avatar_view<'a>(
.map(bytes::Bytes::from_static)
.or_else(|| avatar.custom_png().map(bytes::Bytes::from));
match png_bytes {
Some(b) => iced::widget::image(iced::widget::image::Handle::from_bytes(b))
Some(b) => iced::widget::image(cached_image_handle(b))
.width(iced::Length::Fixed(size))
.height(iced::Length::Fixed(size))
.into(),