use log::debug;
use std::collections::{HashMap, HashSet};
use std::io::{self, Write};
use super::layout::{Layout, ScrollState};
use super::terminal;
use crate::frame::{DocumentMeta, HighlightRect, HighlightSpec, TileCache, VisibleTiles};
use crate::renderer::{TileRenderer, TileResponse};
#[derive(Clone, Copy, Eq, PartialEq, Hash, Debug)]
pub(super) enum PlacementSlot {
Content(usize),
Sidebar(usize),
OverlayPrimary(usize, usize),
OverlayOverflow(usize, usize),
}
impl PlacementSlot {
pub(super) fn placement_id(self) -> u32 {
match self {
PlacementSlot::Content(_) | PlacementSlot::Sidebar(_) => 1,
PlacementSlot::OverlayPrimary(_, r) => (2 * r + 1) as u32,
PlacementSlot::OverlayOverflow(_, r) => (2 * r + 2) as u32,
}
}
pub(super) fn tile_idx(self) -> usize {
match self {
PlacementSlot::Content(i)
| PlacementSlot::Sidebar(i)
| PlacementSlot::OverlayPrimary(i, _)
| PlacementSlot::OverlayOverflow(i, _) => i,
}
}
fn is_overlay(self) -> bool {
matches!(
self,
PlacementSlot::OverlayPrimary(..) | PlacementSlot::OverlayOverflow(..)
)
}
}
pub(super) struct TileImageIds {
pub content_id: u32,
pub sidebar_id: u32,
}
pub(super) struct HighlightImages {
pub full_id: u32,
pub p75_id: u32,
pub p50_id: u32,
pub p25_id: u32,
pub active_full_id: u32,
pub active_p75_id: u32,
pub active_p50_id: u32,
pub active_p25_id: u32,
}
impl HighlightImages {
fn all_ids(&self) -> [u32; 8] {
[
self.full_id,
self.p75_id,
self.p50_id,
self.p25_id,
self.active_full_id,
self.active_p75_id,
self.active_p50_id,
self.active_p25_id,
]
}
}
pub(super) struct DisplayState {
pub map: HashMap<usize, TileImageIds>,
next_id: u32,
evict_distance: usize,
overlay_rects: HashMap<usize, Vec<HighlightRect>>,
highlight_images: Option<HighlightImages>,
live_slots: HashMap<PlacementSlot, u32>,
}
#[cfg_attr(not(test), allow(dead_code))]
pub(super) struct LoadAction {
pub idx: usize,
pub content_id: u32,
pub sidebar_id: u32,
pub evict: Vec<(usize, TileImageIds)>,
}
impl DisplayState {
pub(super) fn new(evict_distance: usize) -> Self {
Self::new_with_start_id(evict_distance, 100)
}
pub(super) fn new_with_start_id(evict_distance: usize, start_id: u32) -> Self {
Self {
map: HashMap::new(),
next_id: start_id,
evict_distance,
overlay_rects: HashMap::new(),
highlight_images: None,
live_slots: HashMap::new(),
}
}
pub(super) fn all_image_ids(&self) -> Vec<u32> {
let mut ids = Vec::new();
for tile_ids in self.map.values() {
ids.push(tile_ids.content_id);
ids.push(tile_ids.sidebar_id);
}
if let Some(ref imgs) = self.highlight_images {
ids.extend_from_slice(&imgs.all_ids());
}
ids
}
pub(super) fn plan_load(&mut self, idx: usize) -> Option<LoadAction> {
if self.map.contains_key(&idx) {
return None;
}
let content_id = self.next_id;
let sidebar_id = self.next_id + 1;
self.next_id += 2;
self.map.insert(
idx,
TileImageIds {
content_id,
sidebar_id,
},
);
let to_evict: Vec<usize> = self
.map
.keys()
.filter(|&&k| (k as isize - idx as isize).unsigned_abs() > self.evict_distance)
.copied()
.collect();
let evict = to_evict
.into_iter()
.filter_map(|k| {
self.overlay_rects.remove(&k);
self.live_slots.retain(|slot, _| slot.tile_idx() != k);
self.map.remove(&k).map(|ids| (k, ids))
})
.collect();
Some(LoadAction {
idx,
content_id,
sidebar_id,
evict,
})
}
pub(super) fn ensure_loaded(
&mut self,
cache: &mut TileCache,
idx: usize,
rh: &mut ForkHandle<'_>,
) -> anyhow::Result<()> {
if let Some(action) = self.plan_load(idx) {
if !cache.contains(idx) {
if rh.in_flight.insert(idx) {
let _ = rh.renderer.send_render_tile(idx);
}
while !cache.contains(idx) {
match rh.renderer.recv()? {
TileResponse::Tile { idx: i, pngs } => {
rh.in_flight.remove(&i);
cache.insert(i, pngs);
}
TileResponse::Rects { idx: i, rects } => {
self.set_overlay_rects(i, rects);
}
}
}
}
execute_load(&action, cache.get(idx).unwrap())?;
}
Ok(())
}
pub(super) fn set_overlay_rects(&mut self, idx: usize, rects: Vec<HighlightRect>) {
self.overlay_rects.insert(idx, rects);
}
pub(super) fn overlay_rects(&self, idx: usize) -> &[HighlightRect] {
self.overlay_rects.get(&idx).map_or(&[], |v| v.as_slice())
}
pub(super) fn has_overlay(&self, idx: usize) -> bool {
self.overlay_rects.contains_key(&idx)
}
pub(super) fn ensure_highlight_images(&mut self) -> io::Result<&HighlightImages> {
if let Some(ref imgs) = self.highlight_images {
return Ok(imgs);
}
let base = self.next_id;
self.next_id += 8;
use crate::frame::{PATTERN_HEIGHT, PATTERN_WIDTH};
terminal::send_image(crate::frame::HIGHLIGHT_PNG, base)?;
for (i, pattern) in [
&crate::frame::PATTERN_P75,
&crate::frame::PATTERN_P50,
&crate::frame::PATTERN_P25,
]
.iter()
.enumerate()
{
terminal::send_raw_image(*pattern, PATTERN_WIDTH, PATTERN_HEIGHT, base + 1 + i as u32)?;
}
terminal::send_image(crate::frame::HIGHLIGHT_ACTIVE_PNG, base + 4)?;
for (i, pattern) in [
&crate::frame::PATTERN_ACTIVE_P75,
&crate::frame::PATTERN_ACTIVE_P50,
&crate::frame::PATTERN_ACTIVE_P25,
]
.iter()
.enumerate()
{
terminal::send_raw_image(*pattern, PATTERN_WIDTH, PATTERN_HEIGHT, base + 5 + i as u32)?;
}
self.highlight_images = Some(HighlightImages {
full_id: base,
p75_id: base + 1,
p50_id: base + 2,
p25_id: base + 3,
active_full_id: base + 4,
active_p75_id: base + 5,
active_p50_id: base + 6,
active_p25_id: base + 7,
});
Ok(self.highlight_images.as_ref().unwrap())
}
pub(super) fn highlight_images(&self) -> Option<&HighlightImages> {
self.highlight_images.as_ref()
}
pub(super) fn clear_all(&mut self) {
self.map.clear();
self.highlight_images = None;
self.live_slots.clear();
}
pub(super) fn clear_overlay_state(&mut self) {
self.overlay_rects.clear();
}
pub(super) fn delete_overlay_placements(&mut self) -> io::Result<()> {
if let Some(imgs) = &self.highlight_images {
delete_placements_for_ids(&imgs.all_ids())?;
}
self.live_slots.retain(|slot, _| !slot.is_overlay());
Ok(())
}
pub(super) fn delete_placements(&mut self) -> io::Result<()> {
let mut out = std::io::stdout();
for (slot, image_id) in self.live_slots.drain() {
let pid = slot.placement_id();
write!(out, "\x1b_Ga=d,d=i,i={image_id},p={pid},q=2\x1b\\")?;
}
out.flush()
}
pub(super) fn track_placement(
&mut self,
out: &mut impl Write,
slot: PlacementSlot,
image_id: u32,
) -> io::Result<u32> {
let pid = slot.placement_id();
if let Some(old_id) = self.live_slots.insert(slot, image_id)
&& old_id != image_id
{
write!(out, "\x1b_Ga=d,d=i,i={old_id},p={pid},q=2\x1b\\")?;
}
Ok(pid)
}
pub(super) fn delete_stale_slots(
&mut self,
out: &mut impl Write,
keep: &HashSet<PlacementSlot>,
) -> io::Result<()> {
let stale: Vec<PlacementSlot> = self
.live_slots
.keys()
.filter(|s| !keep.contains(*s))
.copied()
.collect();
for slot in stale {
if let Some(image_id) = self.live_slots.remove(&slot) {
let pid = slot.placement_id();
write!(out, "\x1b_Ga=d,d=i,i={image_id},p={pid},q=2\x1b\\")?;
}
}
Ok(())
}
#[cfg(test)]
pub(super) fn live_slot_count(&self) -> usize {
self.live_slots.len()
}
#[cfg(test)]
pub(super) fn live_slot_image(&self, slot: PlacementSlot) -> Option<u32> {
self.live_slots.get(&slot).copied()
}
}
fn delete_placements_for_ids(ids: &[u32]) -> io::Result<()> {
let mut out = std::io::stdout();
for &id in ids {
write!(out, "\x1b_Ga=d,d=i,i={id},q=2\x1b\\")?;
}
out.flush()
}
pub(super) fn collect_new_slots(
visible: &VisibleTiles,
loaded: &DisplayState,
layout: &Layout,
include_overlays: bool,
) -> HashSet<PlacementSlot> {
use super::terminal::overlay_slots_for_tile;
let mut slots = HashSet::new();
match visible {
VisibleTiles::Single { idx, src_y, src_h } => {
slots.insert(PlacementSlot::Content(*idx));
slots.insert(PlacementSlot::Sidebar(*idx));
if include_overlays {
overlay_slots_for_tile(
&mut slots,
*idx,
loaded.overlay_rects(*idx),
*src_y,
*src_h,
0,
layout.image_rows,
layout.cell_h as u32,
);
}
}
VisibleTiles::Split {
top_idx,
top_src_y,
top_src_h,
bot_idx,
bot_src_h,
} => {
slots.insert(PlacementSlot::Content(*top_idx));
slots.insert(PlacementSlot::Sidebar(*top_idx));
slots.insert(PlacementSlot::Content(*bot_idx));
slots.insert(PlacementSlot::Sidebar(*bot_idx));
if include_overlays {
let (top_rows, bot_rows) =
super::terminal::split_rows_pub(*top_src_h, layout.cell_h, layout.image_rows);
overlay_slots_for_tile(
&mut slots,
*top_idx,
loaded.overlay_rects(*top_idx),
*top_src_y,
*top_src_h,
0,
top_rows,
layout.cell_h as u32,
);
overlay_slots_for_tile(
&mut slots,
*bot_idx,
loaded.overlay_rects(*bot_idx),
0,
*bot_src_h,
top_rows,
bot_rows,
layout.cell_h as u32,
);
}
}
}
slots
}
fn execute_load(action: &LoadAction, pngs: &crate::frame::TilePngs) -> anyhow::Result<()> {
terminal::send_image(&pngs.content, action.content_id)?;
terminal::send_image(&pngs.sidebar, action.sidebar_id)?;
for (_, ids) in &action.evict {
let _ = terminal::delete_image(ids.content_id);
let _ = terminal::delete_image(ids.sidebar_id);
}
Ok(())
}
pub(super) struct ForkHandle<'a> {
pub renderer: &'a mut TileRenderer,
pub in_flight: &'a mut HashSet<usize>,
}
fn visible_tiles_for_render(
meta: &DocumentMeta,
scroll: &ScrollState,
layout: &Layout,
) -> VisibleTiles {
let y = scroll.y_offset;
let visible = meta.visible_tiles(y, scroll.vp_h);
match &visible {
VisibleTiles::Split { .. } => {
let cell_h = layout.cell_h as u32;
let snapped = (y / cell_h) * cell_h;
if snapped == y {
visible
} else {
meta.visible_tiles(snapped, scroll.vp_h)
}
}
VisibleTiles::Single { .. } => visible,
}
}
#[allow(clippy::too_many_arguments)]
pub(super) fn redraw(
meta: &DocumentMeta,
cache: &mut TileCache,
loaded: &mut DisplayState,
layout: &Layout,
scroll: &ScrollState,
filename: &str,
acc_peek: Option<u32>,
flash: Option<&str>,
include_overlays: bool,
rh: &mut ForkHandle<'_>,
) -> anyhow::Result<()> {
let visible = visible_tiles_for_render(meta, scroll, layout);
match &visible {
VisibleTiles::Single { idx, .. } => {
loaded.ensure_loaded(cache, *idx, rh)?;
}
VisibleTiles::Split {
top_idx, bot_idx, ..
} => {
loaded.ensure_loaded(cache, *top_idx, rh)?;
loaded.ensure_loaded(cache, *bot_idx, rh)?;
}
}
let new_slots = collect_new_slots(&visible, loaded, layout, include_overlays);
{
let mut out = std::io::stdout();
loaded.delete_stale_slots(&mut out, &new_slots)?;
out.flush()?;
}
terminal::place_content_tiles(&visible, loaded, layout, scroll)?;
terminal::place_sidebar_tiles(&visible, loaded, meta.sidebar_width_px, layout)?;
terminal::place_overlay_rects(&visible, loaded, layout)?;
terminal::draw_status_bar(layout, scroll, filename, acc_peek, flash)?;
Ok(())
}
pub(super) fn send_prefetch(
rh: &mut ForkHandle<'_>,
meta: &DocumentMeta,
cache: &TileCache,
y_offset: u32,
) {
let current = (y_offset / meta.tile_height_px) as usize;
for idx in [current + 1, current + 2, current.wrapping_sub(1)] {
if idx < meta.tile_count && !cache.contains(idx) && !rh.in_flight.contains(&idx) {
debug!("prefetch: requesting tile {idx} (current={current})");
let _ = rh.renderer.send_render_tile(idx);
rh.in_flight.insert(idx);
}
}
}
pub(super) fn update_overlays(
meta: &DocumentMeta,
loaded: &mut DisplayState,
cache: &mut TileCache,
layout: &Layout,
scroll: &ScrollState,
spec: &HighlightSpec,
rh: &mut ForkHandle<'_>,
) -> anyhow::Result<()> {
let visible = visible_tiles_for_render(meta, scroll, layout);
let indices: Vec<usize> = match &visible {
VisibleTiles::Single { idx, .. } => vec![*idx],
VisibleTiles::Split {
top_idx, bot_idx, ..
} => vec![*top_idx, *bot_idx],
};
let mut needed = 0;
for &idx in &indices {
if loaded.has_overlay(idx) {
continue;
}
let _ = rh.renderer.send_find_rects(idx, spec);
needed += 1;
}
while needed > 0 {
match rh.renderer.recv()? {
TileResponse::Rects { idx, rects } => {
needed -= 1;
loaded.set_overlay_rects(idx, rects);
}
TileResponse::Tile { idx, pngs } => {
rh.in_flight.remove(&idx);
cache.insert(idx, pngs);
}
}
}
if indices
.iter()
.any(|idx| !loaded.overlay_rects(*idx).is_empty())
{
loaded.ensure_highlight_images()?;
}
Ok(())
}
pub(super) fn drain_responses(
rh: &mut ForkHandle<'_>,
cache: &mut TileCache,
display: &mut DisplayState,
) -> anyhow::Result<()> {
while let Some(resp) = rh.renderer.try_recv()? {
match resp {
TileResponse::Tile { idx, pngs } => {
debug!(
"drain: received tile {idx} ({} + {} bytes)",
pngs.content.len(),
pngs.sidebar.len()
);
rh.in_flight.remove(&idx);
cache.insert(idx, pngs);
}
TileResponse::Rects { idx, rects } => {
display.set_overlay_rects(idx, rects);
}
}
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub(super) fn redraw_and_prefetch(
meta: &DocumentMeta,
cache: &mut TileCache,
display: &mut DisplayState,
layout: &Layout,
scroll: &ScrollState,
filename: &str,
acc_peek: Option<u32>,
flash: Option<&str>,
search_spec: Option<&HighlightSpec>,
rh: &mut ForkHandle<'_>,
) -> anyhow::Result<()> {
drain_responses(rh, cache, display)?;
redraw(
meta,
cache,
display,
layout,
scroll,
filename,
acc_peek,
flash,
search_spec.is_some(),
rh,
)?;
if let Some(spec) = search_spec {
update_overlays(meta, display, cache, layout, scroll, spec, rh)?;
let visible = visible_tiles_for_render(meta, scroll, layout);
terminal::place_overlay_rects(&visible, display, layout)?;
}
send_prefetch(rh, meta, cache, scroll.y_offset);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_load_allocates_ids() {
let mut loaded = DisplayState::new(3);
let action = loaded.plan_load(0).unwrap();
assert_eq!(action.idx, 0);
assert_eq!(action.content_id, 100);
assert_eq!(action.sidebar_id, 101);
assert!(action.evict.is_empty());
}
#[test]
fn plan_load_already_loaded_returns_none() {
let mut loaded = DisplayState::new(3);
loaded.plan_load(0); assert!(loaded.plan_load(0).is_none());
}
#[test]
fn plan_load_evicts_distant_tiles() {
let mut loaded = DisplayState::new(2); loaded.plan_load(0);
loaded.plan_load(1);
loaded.plan_load(2);
let action = loaded.plan_load(5).unwrap();
assert!(!action.evict.is_empty());
assert!(action.evict.iter().any(|(idx, _)| *idx == 0));
assert!(!loaded.map.contains_key(&0));
}
#[test]
fn plan_load_increments_ids() {
let mut loaded = DisplayState::new(3);
let a1 = loaded.plan_load(0).unwrap();
let a2 = loaded.plan_load(1).unwrap();
assert_eq!(a1.content_id, 100);
assert_eq!(a1.sidebar_id, 101);
assert_eq!(a2.content_id, 102);
assert_eq!(a2.sidebar_id, 103);
}
#[test]
fn clear_overlay_state_empties_rects() {
let mut loaded = DisplayState::new(3);
loaded.set_overlay_rects(0, vec![]);
assert!(loaded.has_overlay(0));
loaded.clear_overlay_state();
assert!(!loaded.has_overlay(0));
}
#[test]
fn placement_id_is_stable_by_slot() {
assert_eq!(PlacementSlot::Content(0).placement_id(), 1);
assert_eq!(PlacementSlot::Content(42).placement_id(), 1);
assert_eq!(PlacementSlot::Sidebar(0).placement_id(), 1);
assert_eq!(PlacementSlot::OverlayPrimary(5, 0).placement_id(), 1);
assert_eq!(PlacementSlot::OverlayOverflow(5, 0).placement_id(), 2);
assert_eq!(PlacementSlot::OverlayPrimary(5, 3).placement_id(), 7);
assert_eq!(PlacementSlot::OverlayOverflow(5, 3).placement_id(), 8);
}
#[test]
fn track_placement_emits_no_delete_for_new_slot() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
let pid = loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
assert_eq!(pid, 1);
assert!(out.is_empty());
assert_eq!(loaded.live_slot_image(PlacementSlot::Content(0)), Some(100));
}
#[test]
fn track_placement_emits_delete_when_image_changes() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
let slot = PlacementSlot::OverlayPrimary(3, 0);
loaded.track_placement(&mut out, slot, 200).unwrap();
out.clear();
loaded.track_placement(&mut out, slot, 204).unwrap();
let emitted = String::from_utf8(out).unwrap();
assert!(
emitted.contains("a=d,d=i,i=200,p=1"),
"expected stale delete for old image_id; got {emitted:?}"
);
assert_eq!(loaded.live_slot_image(slot), Some(204));
}
#[test]
fn track_placement_same_image_is_noop() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
let slot = PlacementSlot::Content(0);
loaded.track_placement(&mut out, slot, 100).unwrap();
out.clear();
loaded.track_placement(&mut out, slot, 100).unwrap();
assert!(out.is_empty());
}
#[test]
fn delete_stale_slots_removes_only_absent_slots() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
loaded
.track_placement(&mut out, PlacementSlot::Content(1), 102)
.unwrap();
loaded
.track_placement(&mut out, PlacementSlot::Sidebar(0), 101)
.unwrap();
out.clear();
let mut keep = HashSet::new();
keep.insert(PlacementSlot::Content(0));
keep.insert(PlacementSlot::Sidebar(0));
loaded.delete_stale_slots(&mut out, &keep).unwrap();
let emitted = String::from_utf8(out).unwrap();
assert!(emitted.contains("i=102,p=1"), "got {emitted:?}");
assert!(!emitted.contains("i=100"));
assert!(!emitted.contains("i=101"));
assert_eq!(loaded.live_slot_count(), 2);
}
#[test]
fn eviction_drops_live_slots_for_evicted_tile() {
let mut loaded = DisplayState::new(2); loaded.plan_load(0);
let mut out = Vec::<u8>::new();
loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
loaded.plan_load(5);
assert!(
loaded.live_slot_image(PlacementSlot::Content(0)).is_none(),
"evicted tile's live slots must be cleared"
);
}
#[test]
fn delete_overlay_placements_clears_overlay_slots_only() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
loaded
.track_placement(&mut out, PlacementSlot::OverlayPrimary(0, 0), 200)
.unwrap();
loaded.delete_overlay_placements().unwrap();
assert_eq!(
loaded.live_slot_image(PlacementSlot::Content(0)),
Some(100),
"tile slots must survive overlay deletion"
);
assert!(
loaded
.live_slot_image(PlacementSlot::OverlayPrimary(0, 0))
.is_none(),
"overlay slots must be cleared"
);
}
#[test]
fn delete_placements_clears_all_live_slots() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
loaded
.track_placement(&mut out, PlacementSlot::OverlayPrimary(0, 0), 200)
.unwrap();
loaded.delete_placements().unwrap();
assert_eq!(loaded.live_slot_count(), 0);
}
#[test]
fn clear_all_resets_live_slots() {
let mut loaded = DisplayState::new(3);
let mut out = Vec::<u8>::new();
loaded
.track_placement(&mut out, PlacementSlot::Content(0), 100)
.unwrap();
loaded.clear_all();
assert_eq!(loaded.live_slot_count(), 0);
}
}