use std::collections::{HashMap, HashSet};
use ratatui::layout::Rect;
use crate::tui::media::{ImagePreviewFragmentKey, ImagePreviewTarget};
#[derive(Clone, Default)]
pub(super) struct FramePlacements {
previews: HashMap<ImagePreviewFragmentKey, Rect>,
avatars: HashMap<(String, isize), (u16, u16, bool)>,
popup_avatar: Option<(String, bool, Rect)>,
}
#[derive(Default)]
pub(super) struct PlacementDiff {
pub(super) need_clear: bool,
pub(super) unchanged_previews: HashSet<ImagePreviewFragmentKey>,
pub(super) unchanged_avatars: HashSet<(String, isize)>,
pub(super) popup_avatar_unchanged: bool,
}
impl FramePlacements {
pub(super) fn insert_preview(&mut self, target: &ImagePreviewTarget, area: Rect) {
self.previews.insert(target.fragment_key(), area);
}
pub(super) fn insert_avatar(&mut self, url: String, row: isize, fingerprint: (u16, u16, bool)) {
self.avatars.insert((url, row), fingerprint);
}
pub(super) fn set_popup_avatar(&mut self, popup: Option<(String, bool, Rect)>) {
self.popup_avatar = popup;
}
pub(super) fn diff(&self, previous: &FramePlacements) -> PlacementDiff {
let mut diff = PlacementDiff::default();
for (key, area) in &self.previews {
if previous.previews.get(key) == Some(area) {
diff.unchanged_previews.insert(key.clone());
} else {
diff.need_clear = true;
}
}
for (key, fingerprint) in &self.avatars {
if previous.avatars.get(key) == Some(fingerprint) {
diff.unchanged_avatars.insert(key.clone());
} else {
diff.need_clear = true;
}
}
if previous
.previews
.keys()
.any(|key| !self.previews.contains_key(key))
|| previous
.avatars
.keys()
.any(|key| !self.avatars.contains_key(key))
{
diff.need_clear = true;
}
if self.popup_avatar == previous.popup_avatar {
diff.popup_avatar_unchanged = true;
} else {
diff.need_clear = true;
}
diff
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::discord::ids::{Id, marker::MessageMarker};
use crate::tui::media::ImagePreviewTarget;
fn preview_target(message_id: u64, y_offset: usize) -> ImagePreviewTarget {
ImagePreviewTarget {
viewer: false,
selected: false,
thread_card: false,
message_index: 0,
preview_index: 0,
body_line_index: None,
preview_x_offset_columns: 0,
preview_y_offset_rows: y_offset,
preview_width: 20,
preview_height: 10,
visible_preview_height: 10,
top_clip_rows: 0,
accent_color: None,
show_play_marker: false,
message_id: Id::<MessageMarker>::new(message_id),
url: "https://cdn.discordapp.com/image.png".to_owned(),
filename: "image.png".to_owned(),
}
}
#[test]
fn avatar_change_keeps_message_preview_unchanged() {
let target = preview_target(1, 0);
let mut previous = FramePlacements::default();
previous.insert_preview(&target, Rect::new(10, 5, 20, 10));
previous.insert_avatar("avatar".to_owned(), 4, (3, 0, false));
for (row, circular) in [(3, false), (4, true)] {
let mut current = FramePlacements::default();
current.insert_preview(&target, Rect::new(10, 5, 20, 10));
current.insert_avatar("avatar".to_owned(), row, (3, 0, circular));
let diff = current.diff(&previous);
assert!(diff.need_clear);
assert!(diff.unchanged_previews.contains(&target.fragment_key()));
assert!(!diff.unchanged_avatars.contains(&("avatar".to_owned(), row)));
}
}
#[test]
fn placement_diff_tracks_preview_lifecycle() {
let target = preview_target(1, 0);
let mut previous = FramePlacements::default();
previous.insert_preview(&target, Rect::new(10, 5, 20, 10));
previous.insert_avatar("avatar".to_owned(), 4, (3, 0, false));
for (name, rect, keep_avatar, need_clear, preview_unchanged) in [
(
"vertical scroll",
Some(Rect::new(10, 3, 20, 10)),
false,
true,
false,
),
(
"identical frame",
Some(Rect::new(10, 5, 20, 10)),
true,
false,
true,
),
("removed preview", None, false, true, false),
] {
let mut current = FramePlacements::default();
if let Some(rect) = rect {
current.insert_preview(&target, rect);
}
if keep_avatar {
current.insert_avatar("avatar".to_owned(), 4, (3, 0, false));
}
let diff = current.diff(&previous);
assert_eq!(diff.need_clear, need_clear, "{name}");
assert_eq!(
diff.unchanged_previews.contains(&target.fragment_key()),
preview_unchanged,
"{name}"
);
if rect.is_none() {
assert!(diff.unchanged_previews.is_empty(), "{name}");
}
if keep_avatar {
assert!(
diff.unchanged_avatars.contains(&("avatar".to_owned(), 4)),
"{name}"
);
assert!(diff.popup_avatar_unchanged, "{name}");
}
}
}
#[test]
fn split_preview_fragments_are_tracked_independently() {
let top = ImagePreviewTarget {
visible_preview_height: 3,
..preview_target(1, 0)
};
let bottom = ImagePreviewTarget {
preview_y_offset_rows: 6,
visible_preview_height: 4,
top_clip_rows: 6,
..preview_target(1, 0)
};
assert_eq!(top.key(), bottom.key());
assert_ne!(top.fragment_key(), bottom.fragment_key());
let mut previous = FramePlacements::default();
previous.insert_preview(&top, Rect::new(10, 5, 20, 3));
previous.insert_preview(&bottom, Rect::new(10, 11, 20, 4));
let mut current = FramePlacements::default();
current.insert_preview(&top, Rect::new(10, 5, 20, 3));
current.insert_preview(&bottom, Rect::new(10, 10, 20, 4));
let diff = current.diff(&previous);
assert!(diff.need_clear);
assert!(diff.unchanged_previews.contains(&top.fragment_key()));
assert!(!diff.unchanged_previews.contains(&bottom.fragment_key()));
}
}