use crate::{LibraryModel, MainWindow, PreviewThumbnails};
use indicatrix_vault::db::sqlite::Database;
use slint::{ComponentHandle, Rgba8Pixel, SharedPixelBuffer};
use std::{
cell::RefCell,
collections::{HashMap, VecDeque},
sync::{
Arc, Condvar, Mutex, MutexGuard, PoisonError,
atomic::{AtomicBool, Ordering},
},
thread,
time::Duration,
};
use tracing::warn;
const MAX_CACHED_DESIGNS: usize = 600;
const MAX_QUEUED_REQUESTS: usize = 64;
const VERSION_BUMP_INTERVAL: Duration = Duration::from_millis(100);
type Pixels = SharedPixelBuffer<Rgba8Pixel>;
enum Entry {
Loading,
Ready {
front: Option<Pixels>,
top: Option<Pixels>,
last_used: u64,
},
}
enum Lookup {
Ready {
front: Option<Pixels>,
top: Option<Pixels>,
},
Loading,
Requested,
}
#[derive(Default)]
struct State {
entries: HashMap<i64, Entry>,
queue: VecDeque<i64>,
in_flight: Option<i64>,
in_flight_stale: bool,
tick: u64,
closed: bool,
}
impl State {
fn lookup_or_enqueue(&mut self, entry_id: i64) -> Lookup {
self.tick += 1;
let tick = self.tick;
match self.entries.get_mut(&entry_id) {
Some(Entry::Ready {
front,
top,
last_used,
}) => {
*last_used = tick;
return Lookup::Ready {
front: front.clone(),
top: top.clone(),
};
}
Some(Entry::Loading) => return Lookup::Loading,
None => {}
}
self.entries.insert(entry_id, Entry::Loading);
self.queue.push_back(entry_id);
if self.queue.len() > MAX_QUEUED_REQUESTS
&& let Some(dropped) = self.queue.pop_front()
&& matches!(self.entries.get(&dropped), Some(Entry::Loading))
{
self.entries.remove(&dropped);
}
Lookup::Requested
}
fn begin_next(&mut self) -> Option<i64> {
let entry_id = self.queue.pop_back()?;
self.in_flight = Some(entry_id);
self.in_flight_stale = false;
Some(entry_id)
}
fn store_decoded(&mut self, entry_id: i64, front: Option<Pixels>, top: Option<Pixels>) {
self.in_flight = None;
if std::mem::take(&mut self.in_flight_stale) {
return;
}
self.tick += 1;
let last_used = self.tick;
self.entries.insert(
entry_id,
Entry::Ready {
front,
top,
last_used,
},
);
self.evict_to_capacity();
}
fn evict_to_capacity(&mut self) {
let ready = self
.entries
.values()
.filter(|entry| matches!(entry, Entry::Ready { .. }))
.count();
if ready <= MAX_CACHED_DESIGNS {
return;
}
let oldest = self
.entries
.iter()
.filter_map(|(&id, entry)| match entry {
Entry::Ready { last_used, .. } => Some((*last_used, id)),
Entry::Loading => None,
})
.min();
if let Some((_, id)) = oldest {
self.entries.remove(&id);
}
}
fn forget(&mut self, entry_id: i64) {
self.entries.remove(&entry_id);
self.queue.retain(|&queued| queued != entry_id);
if self.in_flight == Some(entry_id) {
self.in_flight_stale = true;
}
}
}
struct Shared {
state: Mutex<State>,
wake: Condvar,
bump_pending: AtomicBool,
}
impl Shared {
fn new() -> Self {
Self {
state: Mutex::new(State::default()),
wake: Condvar::new(),
bump_pending: AtomicBool::new(false),
}
}
fn lock(&self) -> MutexGuard<'_, State> {
self.state.lock().unwrap_or_else(PoisonError::into_inner)
}
fn lookup(&self, entry_id: i64) -> Lookup {
let found = self.lock().lookup_or_enqueue(entry_id);
if matches!(found, Lookup::Requested) {
self.wake.notify_one();
}
found
}
fn next_request(&self) -> Option<i64> {
let mut state = self
.wake
.wait_while(self.lock(), |state| state.queue.is_empty() && !state.closed)
.unwrap_or_else(PoisonError::into_inner);
if state.closed {
return None;
}
state.begin_next()
}
fn finish(&self, entry_id: i64, front: Option<Pixels>, top: Option<Pixels>) {
self.lock().store_decoded(entry_id, front, top);
}
fn close(&self) {
self.lock().closed = true;
self.wake.notify_all();
}
}
struct CloseOnDrop(Arc<Shared>);
impl CloseOnDrop {
fn shared(&self) -> &Shared {
&self.0
}
}
impl Drop for CloseOnDrop {
fn drop(&mut self) {
self.0.close();
}
}
#[derive(Clone)]
pub struct PreviewThumbnailCache(Arc<Shared>);
impl PreviewThumbnailCache {
pub fn invalidate(&self, ui_weak: &slint::Weak<MainWindow>, entry_id: i64) {
self.0.lock().forget(entry_id);
request_version_bump(&self.0, ui_weak);
}
}
thread_local! {
static BUMP_TIMER: RefCell<Option<slint::Timer>> = const { RefCell::new(None) };
}
fn bump_cache_version(ui: &MainWindow) {
let library = ui.global::<LibraryModel>();
library.set_preview_cache_version(library.get_preview_cache_version() + 1);
}
fn request_version_bump(shared: &Arc<Shared>, ui_weak: &slint::Weak<MainWindow>) {
if shared.bump_pending.swap(true, Ordering::AcqRel) {
return;
}
let shared_for_timer = Arc::clone(shared);
if ui_weak
.upgrade_in_event_loop(move |ui| arm_bump_timer(&ui, shared_for_timer))
.is_err()
{
shared.bump_pending.store(false, Ordering::Release);
}
}
fn arm_bump_timer(ui: &MainWindow, shared: Arc<Shared>) {
let ui_weak = ui.as_weak();
let timer = slint::Timer::default();
timer.start(
slint::TimerMode::SingleShot,
VERSION_BUMP_INTERVAL,
move || {
shared.bump_pending.store(false, Ordering::Release);
if let Some(ui) = ui_weak.upgrade() {
bump_cache_version(&ui);
}
},
);
BUMP_TIMER.with(|cell| *cell.borrow_mut() = Some(timer));
}
fn run_decoder(shared: &Arc<Shared>, db: &Mutex<Database>, ui_weak: &slint::Weak<MainWindow>) {
while let Some(entry_id) = shared.next_request() {
let images = db
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_preview_images(entry_id)
.unwrap_or_default();
let front = images.front.as_deref().and_then(decode_to_pixel_buffer);
let top = images.top.as_deref().and_then(decode_to_pixel_buffer);
shared.finish(entry_id, front, top);
request_version_bump(shared, ui_weak);
}
}
pub fn setup_preview_thumbnail_callback(
ui: &MainWindow,
db: &Arc<Mutex<Database>>,
) -> PreviewThumbnailCache {
let shared = Arc::new(Shared::new());
let handle = PreviewThumbnailCache(Arc::clone(&shared));
let decoder_shared = Arc::clone(&shared);
let db = Arc::clone(db);
let ui_weak = ui.as_weak();
if let Err(e) = thread::Builder::new()
.name("preview-thumbnail-decoder".into())
.spawn(move || run_decoder(&decoder_shared, &db, &ui_weak))
{
warn!("could not start the preview thumbnail decoder: {e}");
}
let lifetime = CloseOnDrop(shared);
ui.global::<LibraryModel>()
.on_get_preview_thumbnails(move |id: i32, _cache_version: i32| {
match lifetime.shared().lookup(i64::from(id)) {
Lookup::Ready { front, top } => to_slint_thumbnails(front.as_ref(), top.as_ref()),
Lookup::Loading | Lookup::Requested => PreviewThumbnails::default(),
}
});
handle
}
fn decode_to_pixel_buffer(png: &[u8]) -> Option<Pixels> {
let decoded = image::load_from_memory(png).ok()?.to_rgba8();
let (width, height) = decoded.dimensions();
let mut buffer = SharedPixelBuffer::<Rgba8Pixel>::new(width, height);
let dst: &mut [Rgba8Pixel] = buffer.make_mut_slice();
let src: &[Rgba8Pixel] = bytemuck::cast_slice(decoded.as_raw());
dst.copy_from_slice(src);
Some(buffer)
}
fn to_slint_thumbnails(front: Option<&Pixels>, top: Option<&Pixels>) -> PreviewThumbnails {
PreviewThumbnails {
front: front.map_or_else(slint::Image::default, |buf| {
slint::Image::from_rgba8(buf.clone())
}),
top: top.map_or_else(slint::Image::default, |buf| {
slint::Image::from_rgba8(buf.clone())
}),
has_front: front.is_some(),
has_top: top.is_some(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pixels() -> Pixels {
SharedPixelBuffer::<Rgba8Pixel>::new(1, 1)
}
fn is_ready(state: &State, entry_id: i64) -> bool {
matches!(state.entries.get(&entry_id), Some(Entry::Ready { .. }))
}
#[test]
fn a_miss_is_queued_once_and_then_reported_loading() {
let mut state = State::default();
assert!(matches!(state.lookup_or_enqueue(7), Lookup::Requested));
assert!(matches!(state.lookup_or_enqueue(7), Lookup::Loading));
assert_eq!(state.queue, VecDeque::from([7]));
}
#[test]
fn the_decoder_takes_the_newest_request_first() {
let mut state = State::default();
for id in [1, 2, 3] {
state.lookup_or_enqueue(id);
}
assert_eq!(state.begin_next(), Some(3));
assert_eq!(state.begin_next(), Some(2));
assert_eq!(state.begin_next(), Some(1));
assert_eq!(state.begin_next(), None);
}
#[test]
fn an_overflowing_queue_forgets_its_oldest_request() {
let mut state = State::default();
for id in 0..=MAX_QUEUED_REQUESTS as i64 {
state.lookup_or_enqueue(id);
}
assert_eq!(state.queue.len(), MAX_QUEUED_REQUESTS);
assert!(!state.entries.contains_key(&0));
assert!(matches!(state.lookup_or_enqueue(0), Lookup::Requested));
}
#[test]
fn the_least_recently_looked_up_design_is_evicted() {
let mut state = State::default();
for id in 0..MAX_CACHED_DESIGNS as i64 {
state.store_decoded(id, Some(pixels()), Some(pixels()));
}
assert!(matches!(state.lookup_or_enqueue(0), Lookup::Ready { .. }));
state.store_decoded(MAX_CACHED_DESIGNS as i64, Some(pixels()), None);
assert!(is_ready(&state, 0));
assert!(!state.entries.contains_key(&1));
assert!(is_ready(&state, MAX_CACHED_DESIGNS as i64));
assert_eq!(state.entries.len(), MAX_CACHED_DESIGNS);
}
#[test]
fn a_decode_finishing_after_its_design_was_invalidated_is_discarded() {
let mut state = State::default();
state.lookup_or_enqueue(5);
assert_eq!(state.begin_next(), Some(5));
state.forget(5);
state.store_decoded(5, Some(pixels()), Some(pixels()));
assert!(!state.entries.contains_key(&5));
assert!(matches!(state.lookup_or_enqueue(5), Lookup::Requested));
assert_eq!(state.begin_next(), Some(5));
state.store_decoded(5, Some(pixels()), Some(pixels()));
assert!(is_ready(&state, 5));
}
#[test]
fn invalidating_a_design_removes_it_from_the_cache_and_the_queue() {
let mut state = State::default();
state.store_decoded(1, Some(pixels()), Some(pixels()));
state.lookup_or_enqueue(2);
state.forget(1);
state.forget(2);
assert!(state.entries.is_empty());
assert!(state.queue.is_empty());
}
}