diff --git a/src/ui/chat2.rs b/src/ui/chat2.rs index 25a94de..2d34c51 100644 --- a/src/ui/chat2.rs +++ b/src/ui/chat2.rs @@ -1 +1,2 @@ mod blocks; +mod renderer; diff --git a/src/ui/chat2/renderer.rs b/src/ui/chat2/renderer.rs new file mode 100644 index 0000000..7318f43 --- /dev/null +++ b/src/ui/chat2/renderer.rs @@ -0,0 +1,348 @@ +use std::cmp::Ordering; + +use async_trait::async_trait; +use toss::Size; + +use super::blocks::{Blocks, Range}; + +#[async_trait] +pub trait Renderer { + type Error; + + fn size(&self) -> Size; + fn scrolloff(&self) -> i32; + + fn blocks(&self) -> &Blocks; + fn blocks_mut(&mut self) -> &mut Blocks; + fn into_blocks(self) -> Blocks; + + async fn expand_top(&mut self) -> Result<(), Self::Error>; + async fn expand_bottom(&mut self) -> Result<(), Self::Error>; +} + +/// A range of all the lines that are visible given the renderer's size. +pub fn visible_area(r: &R) -> Range +where + R: Renderer, +{ + let height: i32 = r.size().height.into(); + Range::new(0, height) +} + +/// The renderer's visible area, reduced by its scrolloff at the top and bottom. +fn scroll_area(r: &R) -> Range +where + R: Renderer, +{ + let range = visible_area(r); + let scrolloff = r.scrolloff(); + let top = range.top + scrolloff; + let bottom = top.max(range.bottom - scrolloff); + Range::new(top, bottom) +} + +/// Compute a delta that makes the object partially or fully overlap the area +/// when added to the object. This delta should be as close to zero as possible. +/// +/// If the object has a height of zero, it must be within the area or exactly on +/// its border to be considered overlapping. +/// +/// If the object has a nonzero height, at least one line of the object must be +/// within the area for the object to be considered overlapping. +fn overlap_delta(area: Range, object: Range) -> i32 { + assert!(object.top <= object.bottom, "object range not well-formed"); + assert!(area.top <= area.bottom, "area range not well-formed"); + + if object.top == object.bottom || area.top == area.bottom { + // Delta that moves the object.bottom to area.top. If this is positive, + // we need to move the object because it is too high. + let move_to_top = area.top - object.bottom; + + // Delta that moves the object.top to area.bottom. If this is negative, + // we need to move the object because it is too low. + let move_to_bottom = area.bottom - object.top; + + // move_to_top <= move_to_bottom because... + // + // Case 1: object.top == object.bottom + // Premise follows from rom area.top <= area.bottom + // + // Case 2: area.top == area.bottom + // Premise follows from object.top <= object.bottom + 0.clamp(move_to_top, move_to_bottom) + } else { + // Delta that moves object.bottom one line below area.top. If this is + // positive, we need to move the object because it is too high. + let move_to_top = (area.top + 1) - object.bottom; + + // Delta that moves object.top one line above area.bottom. If this is + // negative, we need to move the object because it is too low. + let move_to_bottom = (area.bottom - 1) - object.top; + + // move_to_top <= move_to_bottom because... + // + // We know that area.top < area.bottom and object.top < object.bottom, + // otherwise we'd be in the previous `if` branch. + // + // We get the largest value for move_to_top if area.top is largest and + // object.bottom is smallest. We get the smallest value for + // move_to_bottom if area.bottom is smallest and object.top is largest. + // + // This means that the worst case scenario is when area.top and + // area.bottom as well as object.top and object.bottom are closest + // together. In other words: + // + // area.top + 1 == area.bottom + // object.top + 1 == object.bottom + // + // Inserting that into our formulas for move_to_top and move_to_bottom, + // we get: + // + // move_to_top = (area.top + 1) - (object.top + 1) = area.top + object.top + // move_to_bottom = (area.top + 1 - 1) - object.top = area.top + object.top + 0.clamp(move_to_top, move_to_bottom) + } +} + +pub fn overlaps(area: Range, object: Range) -> bool { + overlap_delta(area, object) == 0 +} + +/// Move the object such that it overlaps the area. +fn overlap(area: Range, object: Range) -> Range { + object.shifted(overlap_delta(area, object)) +} + +/// Compute a delta that makes the object fully overlap the area when added to +/// the object. This delta should be as close to zero as possible. +/// +/// If the object is higher than the area, it should be moved such that +/// object.top == area.top. +fn full_overlap_delta(area: Range, object: Range) -> i32 { + assert!(object.top <= object.bottom, "object range not well-formed"); + assert!(area.top <= area.bottom, "area range not well-formed"); + + // Delta that moves object.top to area.top. If this is positive, we need to + // move the object because it is too high. + let move_to_top = area.top - object.top; + + // Delta that moves object.bottom to area.bottom. If this is negative, we + // need to move the object because it is too low. + let move_to_bottom = area.bottom - object.bottom; + + // If the object is higher than the area, move_to_top becomes larger than + // move_to_bottom. In that case, this function should return move_to_top. + 0.min(move_to_bottom).max(move_to_top) +} + +async fn expand_upwards_until(r: &mut R, top: i32) -> Result<(), R::Error> +where + R: Renderer, +{ + loop { + let blocks = r.blocks(); + if blocks.end().top || blocks.range().top <= top { + break; + } + + r.expand_top().await?; + } + + Ok(()) +} + +async fn expand_downwards_until(r: &mut R, bottom: i32) -> Result<(), R::Error> +where + R: Renderer, +{ + loop { + let blocks = r.blocks(); + if blocks.end().bottom || blocks.range().bottom >= bottom { + break; + } + + r.expand_bottom().await?; + } + + Ok(()) +} + +pub async fn expand_to_fill_visible_area(r: &mut R) -> Result<(), R::Error> +where + R: Renderer, +{ + let area = visible_area(r); + expand_upwards_until(r, area.top).await?; + expand_downwards_until(r, area.bottom).await?; + Ok(()) +} + +/// Expand blocks such that the screen is full for any offset where the +/// specified block is visible. +pub async fn expand_to_fill_screen_around_block(r: &mut R, id: &Id) -> Result<(), R::Error> +where + Id: Eq, + R: Renderer, +{ + let screen = visible_area(r); + let (block, _) = r.blocks().find_block(id).expect("no block with that id"); + + let top = overlap(block, screen.with_bottom(block.top)).top; + let bottom = overlap(block, screen.with_top(block.bottom)).bottom; + + expand_upwards_until(r, top).await?; + expand_downwards_until(r, bottom).await?; + + Ok(()) +} + +pub fn scroll_to_set_block_top(r: &mut R, id: &Id, top: i32) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, _)) = r.blocks().find_block(id) { + let delta = top - range.top; + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn scroll_so_block_is_centered(r: &mut R, id: &Id) +where + Id: Eq, + R: Renderer, +{ + let area = visible_area(r); + let (range, block) = r.blocks().find_block(id).expect("no block with that id"); + let focus = block.focus(range); + let focus_height = focus.bottom - focus.top; + let top = (area.top + area.bottom - focus_height) / 2; + r.blocks_mut().shift(top - range.top); +} + +pub fn scroll_blocks_fully_above_screen(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks_mut(); + let delta = area.top - blocks.range().bottom; + blocks.shift(delta); +} + +pub fn scroll_blocks_fully_below_screen(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks_mut(); + let delta = area.bottom - blocks.range().top; + blocks.shift(delta); +} + +pub fn scroll_so_block_focus_overlaps_scroll_area(r: &mut R, id: &Id) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, block)) = r.blocks().find_block(id) { + let area = scroll_area(r); + let delta = overlap_delta(area, block.focus(range)); + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn scroll_so_block_focus_fully_overlaps_scroll_area(r: &mut R, id: &Id) -> bool +where + Id: Eq, + R: Renderer, +{ + if let Some((range, block)) = r.blocks().find_block(id) { + let area = scroll_area(r); + let delta = full_overlap_delta(area, block.focus(range)); + r.blocks_mut().shift(delta); + true + } else { + false + } +} + +pub fn clamp_scroll_biased_upwards(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks().range(); + + // Delta that moves blocks.top to the top of the screen. If this is + // negative, we need to move the blocks because they're too low. + let move_to_top = blocks.top - area.top; + + // Delta that moves blocks.bottom to the bottom of the screen. If this is + // positive, we need to move the blocks because they're too high. + let move_to_bottom = blocks.bottom - area.bottom; + + // If the screen is higher, the blocks should rather be moved to the top + // than the bottom because of the upwards bias. + let delta = 0.max(move_to_bottom).min(move_to_top); + r.blocks_mut().shift(delta); +} + +pub fn clamp_scroll_biased_downwards(r: &mut R) +where + R: Renderer, +{ + let area = visible_area(r); + let blocks = r.blocks().range(); + + // Delta that moves blocks.top to the top of the screen. If this is + // negative, we need to move the blocks because they're too low. + let move_to_top = area.top - blocks.top; + + // Delta that moves blocks.bottom to the bottom of the screen. If this is + // positive, we need to move the blocks because they're too high. + let move_to_bottom = area.bottom - blocks.bottom; + + // If the screen is higher, the blocks should rather be moved to the bottom + // than the top because of the downwards bias. + let delta = 0.min(move_to_top).max(move_to_bottom); + r.blocks_mut().shift(delta); +} + +pub fn find_cursor_starting_at<'a, Id, R>(r: &'a R, id: &Id) -> Option<&'a Id> +where + Id: Eq, + R: Renderer, +{ + let area = scroll_area(r); + let (range, block) = r.blocks().find_block(id)?; + let delta = overlap_delta(area, block.focus(range)); + match delta.cmp(&0) { + Ordering::Equal => Some(block.id()), + + // Blocks must be scrolled downwards to become visible, meaning the + // cursor must be above the visible area. + Ordering::Greater => r + .blocks() + .iter() + .filter(|(_, block)| block.can_be_cursor()) + .find(|(range, block)| overlaps(area, block.focus(*range))) + .map(|(_, block)| block.id()), + + // Blocks must be scrolled upwards to become visible, meaning the cursor + // must be below the visible area. + Ordering::Less => r + .blocks() + .iter() + .rev() + .filter(|(_, block)| block.can_be_cursor()) + .find(|(range, block)| overlaps(area, block.focus(*range))) + .map(|(_, block)| block.id()), + } +}