Cap per-page texture caches to stop unbounded RSS growth

Every page held an uncapped HashMap<String, gdk::Texture> that only ever grew
— gdk textures keep their decoded RGBA resident, so every image/avatar/cover
scrolled past stayed in memory for the session (the multi-GB RSS climb). Add a
bounded TextureCache (insertion-order eviction past 200 textures; the core
disk/byte cache still holds the source for cheap re-decode) and use it on all
seven pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
enki
2026-06-16 10:27:46 -07:00
parent 4f2a3443d4
commit 15629ec5a9
8 changed files with 78 additions and 19 deletions
@@ -1,8 +1,68 @@
//! Texture helpers: decode-at-size so a 4000px photo never becomes a
//! 4000px texture for a 34px avatar.
use std::collections::{HashMap, VecDeque};
use relm4::gtk::{gdk, gdk_pixbuf, prelude::*};
/// Bounded texture cache. gdk textures keep their decoded RGBA in memory, so an
/// uncapped per-page `HashMap<String, Texture>` pinned every image ever
/// scrolled past — the cause of the multi-GB RSS climb. This evicts in
/// insertion order past `cap` (the disk/byte cache in core still holds the
/// source, so re-display just re-decodes, no network). Capacity is in number of
/// textures; with decode-at-size that bounds the footprint.
#[derive(Debug)]
pub struct TextureCache {
map: HashMap<String, gdk::Texture>,
order: VecDeque<String>,
cap: usize,
}
impl TextureCache {
pub fn new(cap: usize) -> Self {
Self {
map: HashMap::new(),
order: VecDeque::new(),
cap: cap.max(1),
}
}
pub fn get(&self, url: &str) -> Option<&gdk::Texture> {
self.map.get(url)
}
pub fn contains_key(&self, url: &str) -> bool {
self.map.contains_key(url)
}
pub fn insert(&mut self, url: String, texture: gdk::Texture) {
if self.map.insert(url.clone(), texture).is_none() {
self.order.push_back(url);
}
while self.map.len() > self.cap {
let Some(old) = self.order.pop_front() else { break };
self.map.remove(&old);
}
}
pub fn len(&self) -> usize {
self.map.len()
}
pub fn is_empty(&self) -> bool {
self.map.is_empty()
}
pub fn clear(&mut self) {
self.map.clear();
self.order.clear();
}
pub fn values(&self) -> impl Iterator<Item = &gdk::Texture> {
self.map.values()
}
}
/// Avatars and other small chrome.
pub const AVATAR_PX: i32 = 128;
/// Inline note images, stream/video thumbnails.
+2 -2
View File
@@ -75,7 +75,7 @@ pub struct BookmarksWidgets {
refresh_btn: gtk::Button,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
picture_slots: HashMap<String, Vec<gtk::Picture>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
}
fn ref_label(item: &BookmarkRef) -> String {
@@ -589,7 +589,7 @@ impl Component for BookmarksPage {
refresh_btn,
avatar_slots: HashMap::new(),
picture_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
};
// Data load is triggered lazily by main_view on first view.
ComponentParts { model, widgets }
+2 -2
View File
@@ -275,7 +275,7 @@ pub struct FeedWidgets {
poll_slots: HashMap<EventId, Vec<(String, gtk::ProgressBar)>>,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
picture_slots: HashMap<String, Vec<gtk::Picture>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
}
/// Hard caps on the feed list. The ListBox isn't virtualised, so every row
@@ -2148,7 +2148,7 @@ impl Component for FeedPage {
poll_slots: HashMap::new(),
avatar_slots: HashMap::new(),
picture_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
};
model.rebuild_sources(&mut widgets);
{
+3 -3
View File
@@ -6,7 +6,7 @@ use std::sync::Arc;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::{gdk, glib};
use relm4::gtk::glib;
use relm4::prelude::*;
use sunstone_core::groups::{Group, GroupRef};
use sunstone_core::nostr_sdk::prelude::*;
@@ -86,7 +86,7 @@ pub struct GroupsWidgets {
chat_entry: gtk::Entry,
leave_btn: gtk::Button,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
}
fn chat_row(message: &ChatMessage) -> gtk::ListBoxRow {
@@ -523,7 +523,7 @@ impl Component for GroupsPage {
chat_entry,
leave_btn,
avatar_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
};
// Live chat pump.
+3 -3
View File
@@ -7,7 +7,7 @@ use std::time::Duration;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::{gdk, glib};
use relm4::gtk::glib;
use relm4::prelude::*;
use sunstone_core::dms::{Conversation, DirectMessage};
use sunstone_core::nostr_sdk::prelude::*;
@@ -59,7 +59,7 @@ pub struct MessagesWidgets {
chat_entry: gtk::Entry,
error_label: gtk::Label,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
}
fn message_time(at: Timestamp) -> String {
@@ -451,7 +451,7 @@ impl Component for MessagesPage {
chat_entry,
error_label,
avatar_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
};
// Long-lived gift wrap subscription + notification pump.
@@ -6,7 +6,7 @@ use std::sync::Arc;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::{gdk, glib};
use relm4::gtk::glib;
use relm4::prelude::*;
use sunstone_core::notifications::{Notification, NotificationKind};
use sunstone_core::nostr_sdk::prelude::*;
@@ -76,7 +76,7 @@ pub struct NotificationsWidgets {
new_pill: gtk::Label,
mark_read_btn: gtk::Button,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
}
/// Badge icon + colour class per kind. Mention is special-cased to an "@"
@@ -730,7 +730,7 @@ impl Component for NotificationsPage {
new_pill,
mark_read_btn,
avatar_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
};
// Live subscription + notification pump.
+3 -3
View File
@@ -5,7 +5,7 @@ use std::sync::Arc;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::{gdk, glib};
use relm4::gtk::glib;
use relm4::prelude::*;
use sunstone_core::nostr_sdk::prelude::*;
use sunstone_core::streams::{stream_matches, ChatMessage, Stream, StreamStatus};
@@ -95,7 +95,7 @@ pub struct StreamsWidgets {
chat_scroll: Option<gtk::ScrolledWindow>,
picture_slots: HashMap<String, Vec<gtk::Picture>>,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
/// Zap buttons per stream index (grid pill + player CTA), so the
/// sending spinner hits the right button(s).
zap_slots: HashMap<usize, Vec<gtk::Button>>,
@@ -753,7 +753,7 @@ impl Component for StreamsPage {
chat_scroll: None,
picture_slots: HashMap::new(),
avatar_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
zap_slots: HashMap::new(),
zap_feedback: crate::components::zap::ZapFeedback::new(),
};
+2 -3
View File
@@ -5,7 +5,6 @@ use std::sync::Arc;
use relm4::adw;
use relm4::adw::prelude::*;
use relm4::gtk::gdk;
use relm4::prelude::*;
use sunstone_core::video::Video;
use sunstone_core::Core;
@@ -65,7 +64,7 @@ pub struct VideosWidgets {
load_more: gtk::Button,
picture_slots: HashMap<String, Vec<gtk::Picture>>,
avatar_slots: HashMap<String, Vec<adw::Avatar>>,
textures: HashMap<String, gdk::Texture>,
textures: crate::components::media::TextureCache,
/// Zap buttons per video index, for sending/result feedback.
zap_slots: HashMap<usize, Vec<gtk::Button>>,
zap_feedback: crate::components::zap::ZapFeedback,
@@ -432,7 +431,7 @@ impl Component for VideosPage {
load_more,
picture_slots: HashMap::new(),
avatar_slots: HashMap::new(),
textures: HashMap::new(),
textures: crate::components::media::TextureCache::new(200),
zap_slots: HashMap::new(),
zap_feedback: crate::components::zap::ZapFeedback::new(),
};